bombadillo/gemini/gemini.go

474 lines
12 KiB
Go
Raw Normal View History

package gemini
import (
2019-09-27 05:08:57 +00:00
"bytes"
"crypto/sha1"
"crypto/tls"
"fmt"
"io/ioutil"
2019-09-19 03:27:56 +00:00
"strconv"
"strings"
"time"
)
2019-09-19 03:27:56 +00:00
type Capsule struct {
2019-11-10 18:41:12 +00:00
MimeMaj string
2019-09-19 03:27:56 +00:00
MimeMin string
2019-11-10 18:41:12 +00:00
Status int
Content string
2019-09-27 05:08:57 +00:00
Links []string
2019-09-19 03:27:56 +00:00
}
type TofuDigest struct {
2019-11-10 18:41:12 +00:00
certs map[string]string
ClientCert tls.Certificate
}
var BlockBehavior = "block"
//------------------------------------------------\\
// + + + R E C E I V E R S + + + \\
//--------------------------------------------------\\
func (t *TofuDigest) LoadCertificate(cert, key string) {
certificate, err := tls.LoadX509KeyPair(cert, key)
if err != nil {
t.ClientCert = tls.Certificate{}
return
}
t.ClientCert = certificate
}
func (t *TofuDigest) Purge(host string) error {
host = strings.ToLower(host)
if host == "*" {
t.certs = make(map[string]string)
return nil
} else if _, ok := t.certs[strings.ToLower(host)]; ok {
2019-09-27 05:08:57 +00:00
delete(t.certs, host)
return nil
}
return fmt.Errorf("Invalid host %q", host)
}
2020-05-09 18:04:06 +00:00
func (t *TofuDigest) Add(host, hash string, time int64) {
t.certs[strings.ToLower(host)] = fmt.Sprintf("%s|%d", hash, time)
}
2019-09-27 05:08:57 +00:00
func (t *TofuDigest) Exists(host string) bool {
if _, ok := t.certs[strings.ToLower(host)]; ok {
return true
}
2019-09-27 05:08:57 +00:00
return false
}
2019-09-27 05:08:57 +00:00
func (t *TofuDigest) Find(host string) (string, error) {
if hash, ok := t.certs[strings.ToLower(host)]; ok {
return hash, nil
}
return "", fmt.Errorf("Invalid hostname, no key saved")
}
func (t *TofuDigest) Match(host, localCert string, cState *tls.ConnectionState) error {
2019-09-28 17:20:23 +00:00
now := time.Now()
for _, cert := range cState.PeerCertificates {
2020-05-09 18:04:06 +00:00
if localCert != hashCert(cert.Raw) {
2019-09-28 17:20:23 +00:00
continue
}
if now.Before(cert.NotBefore) {
return fmt.Errorf("Certificate is not valid yet")
}
if now.After(cert.NotAfter) {
return fmt.Errorf("EXP")
}
if err := cert.VerifyHostname(host); err != nil {
return fmt.Errorf("Certificate error: %s", err)
}
return nil
2019-09-27 05:08:57 +00:00
}
2019-09-28 17:20:23 +00:00
return fmt.Errorf("No matching certificate was found for host %q", host)
}
func (t *TofuDigest) newCert(host string, cState *tls.ConnectionState) error {
host = strings.ToLower(host)
now := time.Now()
2019-10-12 04:33:57 +00:00
var reasons strings.Builder
2019-09-28 17:20:23 +00:00
2019-10-12 04:33:57 +00:00
for index, cert := range cState.PeerCertificates {
if index > 0 {
reasons.WriteString("; ")
}
2019-09-28 17:20:23 +00:00
if now.Before(cert.NotBefore) {
2019-11-10 18:41:12 +00:00
reasons.WriteString(fmt.Sprintf("Cert [%d] is not valid yet", index+1))
2019-09-28 17:20:23 +00:00
continue
}
if now.After(cert.NotAfter) {
2019-11-10 18:41:12 +00:00
reasons.WriteString(fmt.Sprintf("Cert [%d] is expired", index+1))
2019-09-28 17:20:23 +00:00
continue
}
if err := cert.VerifyHostname(host); err != nil {
2019-11-10 18:41:12 +00:00
reasons.WriteString(fmt.Sprintf("Cert [%d] hostname does not match", index+1))
2019-09-28 17:20:23 +00:00
continue
}
2020-05-09 18:04:06 +00:00
t.Add(host, hashCert(cert.Raw), cert.NotAfter.Unix())
2019-09-28 17:20:23 +00:00
return nil
2019-09-27 05:08:57 +00:00
}
2019-09-28 17:20:23 +00:00
2019-10-12 04:33:57 +00:00
return fmt.Errorf(reasons.String())
2019-09-27 05:08:57 +00:00
}
func (t *TofuDigest) GetCertAndTimestamp(host string) (string, int64, error) {
certTs, err := t.Find(host)
if err != nil {
return "", -1, err
}
certTsSplit := strings.SplitN(certTs, "|", -1)
if len(certTsSplit) < 2 {
_ = t.Purge(host)
return certTsSplit[0], -1, fmt.Errorf("Invalid certstring, no delimiter")
}
ts, err := strconv.ParseInt(certTsSplit[1], 10, 64)
if err != nil {
_ = t.Purge(host)
return certTsSplit[0], -1, err
}
now := time.Now()
if ts < now.Unix() {
// Ignore error return here since an error would indicate
// the host does not exist and we have already checked for
// that and the desired outcome of the action is that the
// host will no longer exist, so we are good either way
_ = t.Purge(host)
return "", -1, fmt.Errorf("Expired cert")
}
return certTsSplit[0], ts, nil
}
2019-09-27 05:08:57 +00:00
func (t *TofuDigest) IniDump() string {
if len(t.certs) < 1 {
return ""
}
var out strings.Builder
out.WriteString("[CERTS]\n")
for k, v := range t.certs {
out.WriteString(k)
out.WriteString("=")
out.WriteString(v)
out.WriteString("\n")
}
return out.String()
}
//------------------------------------------------\\
// + + + F U N C T I O N S + + + \\
//--------------------------------------------------\\
2019-09-27 05:08:57 +00:00
func Retrieve(host, port, resource string, td *TofuDigest) (string, error) {
if host == "" || port == "" {
2019-09-19 03:27:56 +00:00
return "", fmt.Errorf("Incomplete request url")
}
addr := host + ":" + port
conf := &tls.Config{
2019-11-10 18:41:12 +00:00
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
}
conf.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return &td.ClientCert, nil
}
2019-09-19 03:27:56 +00:00
conn, err := tls.Dial("tcp", addr, conf)
if err != nil {
2019-10-14 05:23:04 +00:00
return "", fmt.Errorf("TLS Dial Error: %s", err.Error())
}
2019-09-19 03:27:56 +00:00
defer conn.Close()
connState := conn.ConnectionState()
2019-09-28 17:20:23 +00:00
// Begin TOFU screening...
// If no certificates are offered, bail out
if len(connState.PeerCertificates) < 1 {
2019-09-27 05:08:57 +00:00
return "", fmt.Errorf("Insecure, no certificates offered by server")
}
localCert, localTs, err := td.GetCertAndTimestamp(host)
if localTs > 0 {
// See if we have a matching cert
err := td.Match(host, localCert, &connState)
2019-09-28 17:20:23 +00:00
if err != nil && err.Error() != "EXP" {
// If there is no match and it isnt because of an expiration
// just return the error
2019-09-28 17:20:23 +00:00
return "", err
} else if err != nil {
// The cert expired, see if they are offering one that is valid...
2019-09-28 17:20:23 +00:00
err := td.newCert(host, &connState)
if err != nil {
// If there are no valid certs to offer, let the client know
return "", err
}
}
2019-09-28 17:20:23 +00:00
} else {
err = td.newCert(host, &connState)
if err != nil {
// If there are no valid certs to offer, let the client know
return "", err
}
2019-09-27 05:08:57 +00:00
}
2019-09-19 03:27:56 +00:00
send := "gemini://" + addr + "/" + resource + "\r\n"
2019-09-19 03:27:56 +00:00
_, err = conn.Write([]byte(send))
if err != nil {
2019-09-19 03:27:56 +00:00
return "", err
}
result, err := ioutil.ReadAll(conn)
if err != nil {
2019-09-19 03:27:56 +00:00
return "", err
}
2019-09-19 03:27:56 +00:00
return string(result), nil
}
2019-09-27 05:08:57 +00:00
func Fetch(host, port, resource string, td *TofuDigest) ([]byte, error) {
rawResp, err := Retrieve(host, port, resource, td)
2019-09-20 23:15:53 +00:00
if err != nil {
return make([]byte, 0), err
2019-11-10 18:41:12 +00:00
}
2019-09-20 23:15:53 +00:00
resp := strings.SplitN(rawResp, "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return make([]byte, 0), fmt.Errorf("Invalid response from server")
2019-11-10 18:41:12 +00:00
}
2019-09-20 23:15:53 +00:00
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
2019-11-10 18:41:12 +00:00
return make([]byte, 0), fmt.Errorf("Invalid response format from server")
}
}
2019-09-20 23:15:53 +00:00
// Get status code single digit form
status, err := strconv.Atoi(string(header[0][0]))
if err != nil {
return make([]byte, 0), fmt.Errorf("Invalid status response from server")
}
if status != 2 {
switch status {
case 1:
return make([]byte, 0), fmt.Errorf("[1] Queries cannot be saved.")
case 3:
return make([]byte, 0), fmt.Errorf("[3] Redirects cannot be saved.")
case 4:
return make([]byte, 0), fmt.Errorf("[4] Temporary Failure.")
case 5:
return make([]byte, 0), fmt.Errorf("[5] Permanent Failure.")
case 6:
2019-11-12 00:07:50 +00:00
return make([]byte, 0), fmt.Errorf("[6] Client Certificate Required")
2019-09-20 23:15:53 +00:00
default:
return make([]byte, 0), fmt.Errorf("Invalid response status from server")
}
}
return []byte(resp[1]), nil
}
2019-09-27 05:08:57 +00:00
func Visit(host, port, resource string, td *TofuDigest) (Capsule, error) {
2019-09-19 03:27:56 +00:00
capsule := MakeCapsule()
2019-09-27 05:08:57 +00:00
rawResp, err := Retrieve(host, port, resource, td)
if err != nil {
2019-09-19 03:27:56 +00:00
return capsule, err
2019-11-10 18:41:12 +00:00
}
2019-09-19 03:27:56 +00:00
resp := strings.SplitN(rawResp, "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return capsule, fmt.Errorf("Invalid response from server")
2019-11-10 18:41:12 +00:00
}
2019-09-19 03:27:56 +00:00
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
return capsule, fmt.Errorf("Invalid response format from server")
2019-11-10 18:41:12 +00:00
}
}
2019-09-19 03:27:56 +00:00
body := resp[1]
2019-11-10 18:41:12 +00:00
2019-09-19 03:27:56 +00:00
// Get status code single digit form
capsule.Status, err = strconv.Atoi(string(header[0][0]))
if err != nil {
return capsule, fmt.Errorf("Invalid status response from server")
}
2019-09-19 03:27:56 +00:00
// Parse the meta as needed
var meta string
switch capsule.Status {
case 1:
capsule.Content = header[1]
return capsule, nil
2019-09-19 03:27:56 +00:00
case 2:
mimeAndCharset := strings.Split(header[1], ";")
meta = mimeAndCharset[0]
minMajMime := strings.Split(meta, "/")
if len(minMajMime) < 2 {
return capsule, fmt.Errorf("Improperly formatted mimetype received from server")
}
capsule.MimeMaj = minMajMime[0]
capsule.MimeMin = minMajMime[1]
if capsule.MimeMaj == "text" && capsule.MimeMin == "gemini" {
if len(resource) > 0 && resource[0] != '/' {
resource = fmt.Sprintf("/%s", resource)
2019-09-22 22:08:15 +00:00
} else if resource == "" {
resource = "/"
}
2019-09-22 22:08:15 +00:00
currentUrl := fmt.Sprintf("gemini://%s:%s%s", host, port, resource)
rootUrl := fmt.Sprintf("gemini://%s:%s", host, port)
capsule.Content, capsule.Links = parseGemini(body, rootUrl, currentUrl)
2019-09-19 03:27:56 +00:00
} else {
capsule.Content = body
}
return capsule, nil
case 3:
// The client will handle informing the user of a redirect
// and then request the new url
capsule.Content = header[1]
return capsule, nil
case 4:
return capsule, fmt.Errorf("[4] Temporary Failure. %s", header[1])
case 5:
return capsule, fmt.Errorf("[5] Permanent Failure. %s", header[1])
case 6:
2019-11-12 00:07:50 +00:00
return capsule, fmt.Errorf("[6] Client Certificate Required")
2019-09-19 03:27:56 +00:00
default:
return capsule, fmt.Errorf("Invalid response status from server")
}
}
2019-09-22 22:08:15 +00:00
func parseGemini(b, rootUrl, currentUrl string) (string, []string) {
2019-09-19 03:27:56 +00:00
splitContent := strings.Split(b, "\n")
links := make([]string, 0, 10)
inPreBlock := false
outputIndex := 0
2019-09-19 03:27:56 +00:00
for i, ln := range splitContent {
splitContent[i] = strings.Trim(ln, "\r\n")
isPreBlockDeclaration := strings.HasPrefix(ln, "```")
if isPreBlockDeclaration && !inPreBlock && (BlockBehavior == "both" || BlockBehavior == "alt") {
inPreBlock = !inPreBlock
alt := strings.TrimSpace(ln)
if len(alt) > 3 {
alt = strings.TrimSpace(alt[3:])
splitContent[outputIndex] = fmt.Sprintf("[ %s ]", alt)
outputIndex++
}
} else if isPreBlockDeclaration {
inPreBlock = !inPreBlock
} else if len([]rune(ln)) > 3 && ln[:2] == "=>" && !inPreBlock {
var link, decorator string
subLn := strings.Trim(ln[2:], "\r\n\t \a")
splitPoint := strings.IndexAny(subLn, " \t")
2019-11-10 18:41:12 +00:00
if splitPoint < 0 || len([]rune(subLn))-1 <= splitPoint {
link = subLn
decorator = subLn
} else {
link = strings.Trim(subLn[:splitPoint], "\t\n\r \a")
decorator = strings.Trim(subLn[splitPoint:], "\t\n\r \a")
2019-09-19 03:27:56 +00:00
}
2019-11-10 18:41:12 +00:00
if strings.Index(link, "://") < 0 {
2019-09-22 22:08:15 +00:00
link = handleRelativeUrl(link, rootUrl, currentUrl)
2019-09-19 03:27:56 +00:00
}
2019-09-22 22:08:15 +00:00
links = append(links, link)
2019-09-19 03:27:56 +00:00
linknum := fmt.Sprintf("[%d]", len(links))
splitContent[outputIndex] = fmt.Sprintf("%-5s %s", linknum, decorator)
outputIndex++
} else {
if inPreBlock && (BlockBehavior == "alt" || BlockBehavior == "neither") {
continue
}
splitContent[outputIndex] = ln
outputIndex++
2019-09-19 03:27:56 +00:00
}
}
return strings.Join(splitContent[:outputIndex], "\n"), links
2019-09-19 03:27:56 +00:00
}
2019-09-22 22:08:15 +00:00
func handleRelativeUrl(u, root, current string) string {
if len(u) < 1 {
return u
}
currentIsDir := (current[len(current)-1] == '/')
2019-09-22 22:08:15 +00:00
if u[0] == '/' {
return fmt.Sprintf("%s%s", root, u)
} else if strings.HasPrefix(u, "../") {
currentDir := strings.LastIndex(current, "/")
if currentIsDir {
upOne := strings.LastIndex(current[:currentDir], "/")
dirRoot := current[:upOne]
return dirRoot + u[2:]
}
return current[:currentDir] + u[2:]
2019-09-22 22:08:15 +00:00
}
if strings.HasPrefix(u, "./") {
if len(u) == 2 {
return current
}
u = u[2:]
2019-09-22 22:08:15 +00:00
}
if currentIsDir {
indPrevDir := strings.LastIndex(current[:len(current)-1], "/")
if indPrevDir < 9 {
return current + u
}
return current[:indPrevDir+1] + u
}
ind := strings.LastIndex(current, "/")
2019-11-10 18:41:12 +00:00
current = current[:ind+1]
return current + u
2019-09-22 22:08:15 +00:00
}
2019-09-27 05:08:57 +00:00
func hashCert(cert []byte) string {
hash := sha1.Sum(cert)
hex := make([][]byte, len(hash))
for i, data := range hash {
hex[i] = []byte(fmt.Sprintf("%02X", data))
}
2019-09-28 17:20:23 +00:00
return fmt.Sprintf("%s", string(bytes.Join(hex, []byte(":"))))
2019-09-27 05:08:57 +00:00
}
2019-09-19 03:27:56 +00:00
func MakeCapsule() Capsule {
return Capsule{"", "", 0, "", make([]string, 0, 5)}
}
2019-09-27 05:08:57 +00:00
func MakeTofuDigest() TofuDigest {
return TofuDigest{make(map[string]string), tls.Certificate{}}
2019-09-27 05:08:57 +00:00
}