28 Commits

Author SHA1 Message Date
Adnan Maolood
48fa6a724e examples/client: Fix fingerprint check 2020-12-19 13:44:33 -05:00
Adnan Maolood
80ffa72863 client: Verify expiration time 2020-12-19 13:43:47 -05:00
Adnan Maolood
61b417a5c4 Add ResponseWriter.Flush function 2020-12-18 13:15:34 -05:00
Adnan Maolood
a912ef996a Add examples/stream.go 2020-12-18 12:31:37 -05:00
Adnan Maolood
d9a690a98f Make NewResponseWriter take an io.Writer 2020-12-18 01:47:29 -05:00
Adnan Maolood
04bd0f4520 Update Request documentation 2020-12-18 01:43:18 -05:00
Adnan Maolood
d34d5df89e Add ReadRequest and ReadResponse functions 2020-12-18 01:42:05 -05:00
Adnan Maolood
decd72cc23 Expose Request.Write and Response.Read functions 2020-12-18 01:14:06 -05:00
Adnan Maolood
c329a2487e server: Don't always assume TLS is used 2020-12-18 01:02:04 -05:00
Adnan Maolood
df1794c803 examples: Add missing descriptions 2020-12-18 00:47:30 -05:00
Adnan Maolood
5af1acbd54 examples/html: Read from stdin and write to stdout 2020-12-18 00:45:09 -05:00
Adnan Maolood
36c2086c82 Remove unnecessary variable 2020-12-18 00:35:08 -05:00
Adnan Maolood
d52d0af783 Update QueryEscape documentation 2020-12-18 00:26:47 -05:00
Adnan Maolood
35836f2ff7 Remove Input function 2020-12-18 00:25:06 -05:00
Adnan Maolood
824887eab9 Remove Response.Request field 2020-12-18 00:19:53 -05:00
Adnan Maolood
e2c907a7f6 client: Remove GetInput and CheckRedirect callbacks 2020-12-18 00:12:32 -05:00
Adnan Maolood
a09cb5a23c Update switch statement 2020-12-17 23:03:33 -05:00
Adnan Maolood
7ca7053f66 client: Remove GetCertificate callback 2020-12-17 22:56:48 -05:00
Adnan Maolood
ca35aadaea examples/auth: Fix crash on changing username 2020-12-17 21:10:53 -05:00
Adnan Maolood
805a80dddf Update GetCertificate documentation 2020-12-17 19:54:46 -05:00
Adnan Maolood
28c5c857dc Decouple Client from KnownHostsFile 2020-12-17 19:50:26 -05:00
Adnan Maolood
176b260468 Allow Request.Context to be nil 2020-12-17 17:16:55 -05:00
Adnan Maolood
a1dd8de337 Fix locking up of KnownHostsFile and CertificateDir 2020-12-17 17:15:24 -05:00
Adnan Maolood
7be0715d39 Use RWMutex instead of Mutex 2020-12-17 17:08:45 -05:00
Adnan Maolood
4704b8fbcf Add missing imports 2020-12-17 17:07:00 -05:00
Adnan Maolood
aeafd57956 Make CertificateDir safe for concurrent use by multiple goroutines 2020-12-17 16:52:08 -05:00
Adnan Maolood
e687a05170 Make KnownHostsFile safe for concurrent use 2020-12-17 16:49:59 -05:00
Adnan Maolood
846fa2ac41 client: Add GetCertificate callback 2020-12-17 16:46:16 -05:00
14 changed files with 362 additions and 376 deletions

15
cert.go
View File

@@ -15,6 +15,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -23,15 +24,20 @@ type CertificateStore map[string]tls.Certificate
// CertificateDir represents a certificate store optionally loaded from a directory. // CertificateDir represents a certificate store optionally loaded from a directory.
// The zero value of CertificateDir is an empty store ready to use. // The zero value of CertificateDir is an empty store ready to use.
//
// CertificateDir is safe for concurrent use by multiple goroutines.
type CertificateDir struct { type CertificateDir struct {
CertificateStore CertificateStore
dir bool dir bool
path string path string
mu sync.RWMutex
} }
// Add adds a certificate for the given scope to the store. // Add adds a certificate for the given scope to the store.
// It tries to parse the certificate if it is not already parsed. // It tries to parse the certificate if it is not already parsed.
func (c *CertificateDir) Add(scope string, cert tls.Certificate) { func (c *CertificateDir) Add(scope string, cert tls.Certificate) {
c.mu.Lock()
defer c.mu.Unlock()
if c.CertificateStore == nil { if c.CertificateStore == nil {
c.CertificateStore = CertificateStore{} c.CertificateStore = CertificateStore{}
} }
@@ -47,6 +53,8 @@ func (c *CertificateDir) Add(scope string, cert tls.Certificate) {
// Write writes the provided certificate to the certificate directory. // Write writes the provided certificate to the certificate directory.
func (c *CertificateDir) Write(scope string, cert tls.Certificate) error { func (c *CertificateDir) Write(scope string, cert tls.Certificate) error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.dir { if c.dir {
// Escape slash character // Escape slash character
scope = strings.ReplaceAll(scope, "/", ":") scope = strings.ReplaceAll(scope, "/", ":")
@@ -61,6 +69,8 @@ func (c *CertificateDir) Write(scope string, cert tls.Certificate) error {
// Lookup returns the certificate for the given scope. // Lookup returns the certificate for the given scope.
func (c *CertificateDir) Lookup(scope string) (tls.Certificate, bool) { func (c *CertificateDir) Lookup(scope string) (tls.Certificate, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
cert, ok := c.CertificateStore[scope] cert, ok := c.CertificateStore[scope]
return cert, ok return cert, ok
} }
@@ -87,13 +97,14 @@ func (c *CertificateDir) Load(path string) error {
scope = strings.ReplaceAll(scope, ":", "/") scope = strings.ReplaceAll(scope, ":", "/")
c.Add(scope, cert) c.Add(scope, cert)
} }
c.dir = true c.SetDir(path)
c.path = path
return nil return nil
} }
// SetDir sets the directory that new certificates will be written to. // SetDir sets the directory that new certificates will be written to.
func (c *CertificateDir) SetDir(path string) { func (c *CertificateDir) SetDir(path string) {
c.mu.Lock()
defer c.mu.Unlock()
c.dir = true c.dir = true
c.path = path c.path = path
} }

197
client.go
View File

@@ -2,26 +2,23 @@ package gemini
import ( import (
"bufio" "bufio"
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors" "errors"
"net" "net"
"net/url"
"path"
"strings" "strings"
"sync"
"time" "time"
) )
// Client is a Gemini client. // Client is a Gemini client.
//
// Clients are safe for concurrent use by multiple goroutines.
type Client struct { type Client struct {
// KnownHosts is a list of known hosts. // TrustCertificate is called to determine whether the client
KnownHosts KnownHostsFile // should trust the certificate provided by the server.
// If TrustCertificate is nil, the client will accept any certificate.
// Certificates stores client-side certificates. // If the returned error is not nil, the certificate will not be trusted
Certificates CertificateDir // and the request will be aborted.
TrustCertificate func(hostname string, cert *x509.Certificate) error
// Timeout specifies a time limit for requests made by this // Timeout specifies a time limit for requests made by this
// Client. The timeout includes connection time and reading // Client. The timeout includes connection time and reading
@@ -30,43 +27,9 @@ type Client struct {
// //
// A Timeout of zero means no timeout. // A Timeout of zero means no timeout.
Timeout time.Duration Timeout time.Duration
// InsecureSkipTrust specifies whether the client should trust
// any certificate it receives without checking KnownHosts
// or calling TrustCertificate.
// Use with caution.
InsecureSkipTrust bool
// GetInput is called to retrieve input when the server requests it.
// If GetInput is nil or returns false, no input will be sent and
// the response will be returned.
GetInput func(prompt string, sensitive bool) (input string, ok bool)
// CheckRedirect determines whether to follow a redirect.
// If CheckRedirect is nil, redirects will not be followed.
CheckRedirect func(req *Request, via []*Request) error
// CreateCertificate is called to generate a certificate upon
// the request of a server.
// If CreateCertificate is nil or the returned error is not nil,
// the request will not be sent again and the response will be returned.
CreateCertificate func(scope, path string) (tls.Certificate, error)
// TrustCertificate is called to determine whether the client
// should trust a certificate it has not seen before.
// If TrustCertificate is nil, the certificate will not be trusted
// and the connection will be aborted.
//
// If TrustCertificate returns TrustOnce, the certificate will be added
// to the client's list of known hosts.
// If TrustCertificate returns TrustAlways, the certificate will also be
// written to the known hosts file.
TrustCertificate func(hostname string, cert *x509.Certificate) Trust
mu sync.Mutex
} }
// Get performs a Gemini request for the given url. // Get performs a Gemini request for the given URL.
func (c *Client) Get(url string) (*Response, error) { func (c *Client) Get(url string) (*Response, error) {
req, err := NewRequest(url) req, err := NewRequest(url)
if err != nil { if err != nil {
@@ -77,13 +40,6 @@ func (c *Client) Get(url string) (*Response, error) {
// Do performs a Gemini request and returns a Gemini response. // Do performs a Gemini request and returns a Gemini response.
func (c *Client) Do(req *Request) (*Response, error) { func (c *Client) Do(req *Request) (*Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.do(req, nil)
}
func (c *Client) do(req *Request, via []*Request) (*Response, error) {
// Extract hostname // Extract hostname
colonPos := strings.LastIndex(req.Host, ":") colonPos := strings.LastIndex(req.Host, ":")
if colonPos == -1 { if colonPos == -1 {
@@ -96,123 +52,49 @@ func (c *Client) do(req *Request, via []*Request) (*Response, error) {
InsecureSkipVerify: true, InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) { GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) {
return c.getClientCertificate(req) if req.Certificate != nil {
return req.Certificate, nil
}
return &tls.Certificate{}, nil
}, },
VerifyConnection: func(cs tls.ConnectionState) error { VerifyConnection: func(cs tls.ConnectionState) error {
return c.verifyConnection(req, cs) return c.verifyConnection(req, cs)
}, },
ServerName: hostname, ServerName: hostname,
} }
netConn, err := (&net.Dialer{}).DialContext(req.Context, "tcp", req.Host) // Set connection context
ctx := req.Context
if ctx == nil {
ctx = context.Background()
}
netConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", req.Host)
if err != nil { if err != nil {
return nil, err return nil, err
} }
conn := tls.Client(netConn, config) conn := tls.Client(netConn, config)
// Set connection deadline // Set connection deadline
if d := c.Timeout; d != 0 { if c.Timeout != 0 {
conn.SetDeadline(time.Now().Add(d)) conn.SetDeadline(time.Now().Add(c.Timeout))
} }
// Write the request // Write the request
w := bufio.NewWriter(conn) w := bufio.NewWriter(conn)
req.write(w) req.Write(w)
if err := w.Flush(); err != nil { if err := w.Flush(); err != nil {
return nil, err return nil, err
} }
// Read the response // Read the response
resp := &Response{} resp, err := ReadResponse(conn)
if err := resp.read(conn); err != nil { if err != nil {
return nil, err return nil, err
} }
resp.Request = req
// Store connection state // Store connection state
resp.TLS = conn.ConnectionState() resp.TLS = conn.ConnectionState()
switch {
case resp.Status == StatusCertificateRequired:
// Check to see if a certificate was already provided to prevent an infinite loop
if req.Certificate != nil {
return resp, nil
}
hostname, path := req.URL.Hostname(), strings.TrimSuffix(req.URL.Path, "/")
if c.CreateCertificate != nil {
cert, err := c.CreateCertificate(hostname, path)
if err != nil {
return resp, err
}
c.Certificates.Add(hostname+path, cert)
c.Certificates.Write(hostname+path, cert)
req.Certificate = &cert
return c.do(req, via)
}
return resp, nil
case resp.Status.Class() == StatusClassInput:
if c.GetInput != nil {
input, ok := c.GetInput(resp.Meta, resp.Status == StatusSensitiveInput)
if ok {
req.URL.ForceQuery = true
req.URL.RawQuery = QueryEscape(input)
return c.do(req, via)
}
}
return resp, nil
case resp.Status.Class() == StatusClassRedirect:
if via == nil {
via = []*Request{}
}
via = append(via, req)
target, err := url.Parse(resp.Meta)
if err != nil {
return resp, err
}
target = req.URL.ResolveReference(target)
redirect := NewRequestFromURL(target)
redirect.Context = req.Context
if c.CheckRedirect != nil {
if err := c.CheckRedirect(redirect, via); err != nil {
return resp, err
}
return c.do(redirect, via)
}
}
return resp, nil return resp, nil
} }
func (c *Client) getClientCertificate(req *Request) (*tls.Certificate, error) {
// Request certificates have the highest precedence
if req.Certificate != nil {
return req.Certificate, nil
}
// Search recursively for the certificate
scope := req.URL.Hostname() + strings.TrimSuffix(req.URL.Path, "/")
for {
cert, ok := c.Certificates.Lookup(scope)
if ok {
// Ensure that the certificate is not expired
if cert.Leaf != nil && !time.Now().After(cert.Leaf.NotAfter) {
// Store the certificate
req.Certificate = &cert
return &cert, nil
}
break
}
scope = path.Dir(scope)
if scope == "." {
break
}
}
return &tls.Certificate{}, nil
}
func (c *Client) verifyConnection(req *Request, cs tls.ConnectionState) error { func (c *Client) verifyConnection(req *Request, cs tls.ConnectionState) error {
// Verify the hostname // Verify the hostname
var hostname string var hostname string
@@ -225,33 +107,14 @@ func (c *Client) verifyConnection(req *Request, cs tls.ConnectionState) error {
if err := verifyHostname(cert, hostname); err != nil { if err := verifyHostname(cert, hostname); err != nil {
return err return err
} }
if c.InsecureSkipTrust { // Check expiration date
return nil if !time.Now().Before(cert.NotAfter) {
return errors.New("gemini: certificate expired")
} }
// Check the known hosts // See if the client trusts the certificate
knownHost, ok := c.KnownHosts.Lookup(hostname) if c.TrustCertificate != nil {
if !ok || !time.Now().Before(knownHost.Expires) { return c.TrustCertificate(hostname, cert)
// See if the client trusts the certificate
if c.TrustCertificate != nil {
switch c.TrustCertificate(hostname, cert) {
case TrustOnce:
fingerprint := NewFingerprint(cert.Raw, cert.NotAfter)
c.KnownHosts.Add(hostname, fingerprint)
return nil
case TrustAlways:
fingerprint := NewFingerprint(cert.Raw, cert.NotAfter)
c.KnownHosts.Add(hostname, fingerprint)
c.KnownHosts.Write(hostname, fingerprint)
return nil
}
}
return errors.New("gemini: certificate not trusted")
} }
return nil
fingerprint := NewFingerprint(cert.Raw, cert.NotAfter)
if knownHost.Hex == fingerprint.Hex {
return nil
}
return errors.New("gemini: fingerprint does not match")
} }

View File

@@ -74,11 +74,17 @@ func changeUsername(w *gemini.ResponseWriter, r *gemini.Request) {
return return
} }
username, ok := gemini.Input(r) username, err := gemini.QueryUnescape(r.URL.RawQuery)
if !ok { if err != nil || username == "" {
w.WriteHeader(gemini.StatusInput, "Username") w.WriteHeader(gemini.StatusInput, "Username")
return return
} }
users[fingerprint(r.Certificate.Leaf)].Name = username fingerprint := fingerprint(r.Certificate.Leaf)
user, ok := users[fingerprint]
if !ok {
user = &User{}
users[fingerprint] = user
}
user.Name = username
w.WriteHeader(gemini.StatusRedirect, "/") w.WriteHeader(gemini.StatusRedirect, "/")
} }

View File

@@ -1,5 +1,7 @@
// +build ignore // +build ignore
// This example illustrates a certificate generation tool.
package main package main
import ( import (

View File

@@ -1,14 +1,17 @@
// +build ignore // +build ignore
// This example illustrates a Gemini client.
package main package main
import ( import (
"bufio" "bufio"
"crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log" "log"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -17,6 +20,22 @@ import (
"git.sr.ht/~adnano/go-xdg" "git.sr.ht/~adnano/go-xdg"
) )
var (
hosts gemini.KnownHostsFile
scanner *bufio.Scanner
)
func init() {
// Load known hosts file
path := filepath.Join(xdg.DataHome(), "gemini", "known_hosts")
err := hosts.Load(path)
if err != nil {
log.Println(err)
}
scanner = bufio.NewScanner(os.Stdin)
}
const trustPrompt = `The certificate offered by %s is of unknown trust. Its fingerprint is: const trustPrompt = `The certificate offered by %s is of unknown trust. Its fingerprint is:
%s %s
@@ -26,49 +45,85 @@ Otherwise, this should be safe to trust.
[t]rust always; trust [o]nce; [a]bort [t]rust always; trust [o]nce; [a]bort
=> ` => `
var ( func trustCertificate(hostname string, cert *x509.Certificate) error {
scanner = bufio.NewScanner(os.Stdin) fingerprint := gemini.NewFingerprint(cert.Raw, cert.NotAfter)
client = &gemini.Client{} knownHost, ok := hosts.Lookup(hostname)
) if ok && time.Now().Before(knownHost.Expires) {
// Check fingerprint
func init() { if knownHost.Hex == fingerprint.Hex {
client.Timeout = 30 * time.Second return nil
client.KnownHosts.Load(filepath.Join(xdg.DataHome(), "gemini", "known_hosts"))
client.TrustCertificate = func(hostname string, cert *x509.Certificate) gemini.Trust {
fingerprint := gemini.NewFingerprint(cert.Raw, cert.NotAfter)
fmt.Printf(trustPrompt, hostname, fingerprint.Hex)
scanner.Scan()
switch scanner.Text() {
case "t":
return gemini.TrustAlways
case "o":
return gemini.TrustOnce
default:
return gemini.TrustNone
} }
return errors.New("error: fingerprint does not match!")
} }
client.CreateCertificate = func(hostname, path string) (tls.Certificate, error) {
fmt.Println("Generating client certificate for", hostname, path) fmt.Printf(trustPrompt, hostname, fingerprint.Hex)
return gemini.CreateCertificate(gemini.CertificateOptions{ scanner.Scan()
Duration: time.Hour, switch scanner.Text() {
}) case "t":
hosts.Add(hostname, fingerprint)
hosts.Write(hostname, fingerprint)
return nil
case "o":
hosts.Add(hostname, fingerprint)
return nil
default:
return errors.New("certificate not trusted")
} }
client.GetInput = func(prompt string, sensitive bool) (string, bool) { }
fmt.Printf("%s: ", prompt)
scanner.Scan() func getInput(prompt string, sensitive bool) (input string, ok bool) {
return scanner.Text(), true fmt.Printf("%s ", prompt)
scanner.Scan()
return scanner.Text(), true
}
func do(req *gemini.Request, via []*gemini.Request) (*gemini.Response, error) {
client := gemini.Client{
TrustCertificate: trustCertificate,
} }
resp, err := client.Do(req)
if err != nil {
return resp, err
}
switch resp.Status.Class() {
case gemini.StatusClassInput:
input, ok := getInput(resp.Meta, resp.Status == gemini.StatusSensitiveInput)
if !ok {
break
}
req.URL.ForceQuery = true
req.URL.RawQuery = gemini.QueryEscape(input)
return do(req, via)
case gemini.StatusClassRedirect:
via = append(via, req)
if len(via) > 5 {
return resp, errors.New("too many redirects")
}
target, err := url.Parse(resp.Meta)
if err != nil {
return resp, err
}
target = req.URL.ResolveReference(target)
redirect := *req
redirect.URL = target
return do(&redirect, via)
}
return resp, err
} }
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
fmt.Printf("usage: %s gemini://... [host]", os.Args[0]) fmt.Printf("usage: %s <url> [host]\n", os.Args[0])
os.Exit(1) os.Exit(1)
} }
// Do the request
url := os.Args[1] url := os.Args[1]
req, err := gemini.NewRequest(url) req, err := gemini.NewRequest(url)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -76,13 +131,13 @@ func main() {
if len(os.Args) == 3 { if len(os.Args) == 3 {
req.Host = os.Args[2] req.Host = os.Args[2]
} }
resp, err := do(req, nil)
resp, err := client.Do(req)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
// Handle response
if resp.Status.Class() == gemini.StatusClassSuccess { if resp.Status.Class() == gemini.StatusClassSuccess {
defer resp.Body.Close() defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
@@ -91,6 +146,7 @@ func main() {
} }
fmt.Print(string(body)) fmt.Print(string(body))
} else { } else {
fmt.Printf("request failed: %d %s: %s", resp.Status, resp.Status.Message(), resp.Meta) fmt.Printf("%d %s: %s\n", resp.Status, resp.Status.Message(), resp.Meta)
os.Exit(1)
} }
} }

View File

@@ -7,76 +7,77 @@ package main
import ( import (
"fmt" "fmt"
"html" "html"
"strings" "io"
"os"
"git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
) )
func main() { func main() {
text := gemini.Text{ hw := HTMLWriter{
gemini.LineHeading1("Hello, world!"), out: os.Stdout,
gemini.LineText("This is a gemini text document."),
} }
gemini.ParseLines(os.Stdin, hw.Handle)
html := textToHTML(text) hw.Finish()
fmt.Print(html)
} }
// textToHTML returns the Gemini text response as HTML. type HTMLWriter struct {
func textToHTML(text gemini.Text) string { out io.Writer
var b strings.Builder pre bool
var pre bool list bool
var list bool }
for _, l := range text {
if _, ok := l.(gemini.LineListItem); ok { func (h *HTMLWriter) Handle(line gemini.Line) {
if !list { if _, ok := line.(gemini.LineListItem); ok {
list = true if !h.list {
fmt.Fprint(&b, "<ul>\n") h.list = true
} fmt.Fprint(h.out, "<ul>\n")
} else if list { }
list = false } else if h.list {
fmt.Fprint(&b, "</ul>\n") h.list = false
} fmt.Fprint(h.out, "</ul>\n")
switch l := l.(type) { }
case gemini.LineLink: switch line := line.(type) {
url := html.EscapeString(l.URL) case gemini.LineLink:
name := html.EscapeString(l.Name) url := html.EscapeString(line.URL)
if name == "" { name := html.EscapeString(line.Name)
name = url if name == "" {
} name = url
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name) }
case gemini.LinePreformattingToggle: fmt.Fprintf(h.out, "<p><a href='%s'>%s</a></p>\n", url, name)
pre = !pre case gemini.LinePreformattingToggle:
if pre { h.pre = !h.pre
fmt.Fprint(&b, "<pre>\n") if h.pre {
} else { fmt.Fprint(h.out, "<pre>\n")
fmt.Fprint(&b, "</pre>\n") } else {
} fmt.Fprint(h.out, "</pre>\n")
case gemini.LinePreformattedText: }
fmt.Fprintf(&b, "%s\n", html.EscapeString(string(l))) case gemini.LinePreformattedText:
case gemini.LineHeading1: fmt.Fprintf(h.out, "%s\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(string(l))) case gemini.LineHeading1:
case gemini.LineHeading2: fmt.Fprintf(h.out, "<h1>%s</h1>\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(string(l))) case gemini.LineHeading2:
case gemini.LineHeading3: fmt.Fprintf(h.out, "<h2>%s</h2>\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(string(l))) case gemini.LineHeading3:
case gemini.LineListItem: fmt.Fprintf(h.out, "<h3>%s</h3>\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(string(l))) case gemini.LineListItem:
case gemini.LineQuote: fmt.Fprintf(h.out, "<li>%s</li>\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(string(l))) case gemini.LineQuote:
case gemini.LineText: fmt.Fprintf(h.out, "<blockquote>%s</blockquote>\n", html.EscapeString(string(line)))
if l == "" { case gemini.LineText:
fmt.Fprint(&b, "<br>\n") if line == "" {
} else { fmt.Fprint(h.out, "<br>\n")
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(string(l))) } else {
} fmt.Fprintf(h.out, "<p>%s</p>\n", html.EscapeString(string(line)))
} }
} }
if pre { }
fmt.Fprint(&b, "</pre>\n")
} func (h *HTMLWriter) Finish() {
if list { if h.pre {
fmt.Fprint(&b, "</ul>\n") fmt.Fprint(h.out, "</pre>\n")
} }
return b.String() if h.list {
fmt.Fprint(h.out, "</ul>\n")
}
} }

View File

@@ -1,5 +1,7 @@
// +build ignore // +build ignore
// This example illustrates a Gemini server.
package main package main
import ( import (

44
examples/stream.go Normal file
View File

@@ -0,0 +1,44 @@
// +build ignore
// This example illustrates a streaming Gemini server.
package main
import (
"crypto/tls"
"crypto/x509/pkix"
"fmt"
"log"
"time"
"git.sr.ht/~adnano/go-gemini"
)
func main() {
var server gemini.Server
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err)
}
server.CreateCertificate = func(hostname string) (tls.Certificate, error) {
return gemini.CreateCertificate(gemini.CertificateOptions{
Subject: pkix.Name{
CommonName: hostname,
},
DNSNames: []string{hostname},
Duration: 365 * 24 * time.Hour,
})
}
server.RegisterFunc("localhost", stream)
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}
func stream(w *gemini.ResponseWriter, r *gemini.Request) {
for {
fmt.Fprintln(w, time.Now().UTC())
w.Flush()
time.Sleep(time.Second)
}
}

View File

@@ -58,6 +58,7 @@ var crlf = []byte("\r\n")
// Errors. // Errors.
var ( var (
ErrInvalidURL = errors.New("gemini: invalid URL") ErrInvalidURL = errors.New("gemini: invalid URL")
ErrInvalidRequest = errors.New("gemini: invalid request")
ErrInvalidResponse = errors.New("gemini: invalid response") ErrInvalidResponse = errors.New("gemini: invalid response")
ErrBodyNotAllowed = errors.New("gemini: response body not allowed") ErrBodyNotAllowed = errors.New("gemini: response body not allowed")
) )

View File

@@ -5,8 +5,9 @@ import (
"strings" "strings"
) )
// QueryEscape properly escapes a string for use in a Gemini URL query. // QueryEscape escapes a string for use in a Gemini URL query.
// It is like url.PathEscape except that it also replaces plus signs with their percent-encoded counterpart. // It is like url.PathEscape except that it also replaces plus signs
// with their percent-encoded counterpart.
func QueryEscape(query string) string { func QueryEscape(query string) string {
return strings.ReplaceAll(url.PathEscape(query), "+", "%2B") return strings.ReplaceAll(url.PathEscape(query), "+", "%2B")
} }

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"context" "context"
"crypto/tls" "crypto/tls"
"io"
"net" "net"
"net/url" "net/url"
) )
@@ -15,6 +16,7 @@ type Request struct {
// For client requests, Host specifies the host on which the URL is sought. // For client requests, Host specifies the host on which the URL is sought.
// Host must contain a port. // Host must contain a port.
//
// This field is ignored by the server. // This field is ignored by the server.
Host string Host string
@@ -27,16 +29,18 @@ type Request struct {
// RemoteAddr allows servers and other software to record the network // RemoteAddr allows servers and other software to record the network
// address that sent the request. // address that sent the request.
//
// This field is ignored by the client. // This field is ignored by the client.
RemoteAddr net.Addr RemoteAddr net.Addr
// TLS allows servers and other software to record information about the TLS // TLS allows servers and other software to record information about the TLS
// connection on which the request was received. // connection on which the request was received.
//
// This field is ignored by the client. // This field is ignored by the client.
TLS tls.ConnectionState TLS tls.ConnectionState
// Context specifies the context to use for client requests. // Context specifies the context to use for client requests.
// Context must not be nil. // If Context is nil, the background context will be used.
Context context.Context Context context.Context
} }
@@ -60,14 +64,44 @@ func NewRequestFromURL(url *url.URL) *Request {
host += ":1965" host += ":1965"
} }
return &Request{ return &Request{
URL: url, URL: url,
Host: host, Host: host,
Context: context.Background(),
} }
} }
// write writes the Gemini request to the provided buffered writer. // ReadRequest reads a Gemini request from the provided io.Reader
func (r *Request) write(w *bufio.Writer) error { func ReadRequest(r io.Reader) (*Request, error) {
// Read URL
br := bufio.NewReader(r)
rawurl, err := br.ReadString('\r')
if err != nil {
return nil, err
}
// Read terminating line feed
if b, err := br.ReadByte(); err != nil {
return nil, err
} else if b != '\n' {
return nil, ErrInvalidRequest
}
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1]
// Validate URL
if len(rawurl) > 1024 {
return nil, ErrInvalidRequest
}
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
if u.User != nil {
// User is not allowed
return nil, ErrInvalidURL
}
return &Request{URL: u}, nil
}
// Write writes the Gemini request to the provided buffered writer.
func (r *Request) Write(w *bufio.Writer) error {
url := r.URL.String() url := r.URL.String()
// User is invalid // User is invalid
if r.URL.User != nil || len(url) > 1024 { if r.URL.User != nil || len(url) > 1024 {

View File

@@ -21,25 +21,24 @@ type Response struct {
// Body contains the response body for successful responses. // Body contains the response body for successful responses.
Body io.ReadCloser Body io.ReadCloser
// Request is the request that was sent to obtain this response.
Request *Request
// TLS contains information about the TLS connection on which the response // TLS contains information about the TLS connection on which the response
// was received. // was received.
TLS tls.ConnectionState TLS tls.ConnectionState
} }
// read reads a Gemini response from the provided io.ReadCloser. // ReadResponse reads a Gemini response from the provided io.ReadCloser.
func (resp *Response) read(rc io.ReadCloser) error { func ReadResponse(rc io.ReadCloser) (*Response, error) {
resp := &Response{}
br := bufio.NewReader(rc) br := bufio.NewReader(rc)
// Read the status // Read the status
statusB := make([]byte, 2) statusB := make([]byte, 2)
if _, err := br.Read(statusB); err != nil { if _, err := br.Read(statusB); err != nil {
return err return nil, err
} }
status, err := strconv.Atoi(string(statusB)) status, err := strconv.Atoi(string(statusB))
if err != nil { if err != nil {
return err return nil, err
} }
resp.Status = Status(status) resp.Status = Status(status)
@@ -47,26 +46,26 @@ func (resp *Response) read(rc io.ReadCloser) error {
const minStatus, maxStatus = 1, 6 const minStatus, maxStatus = 1, 6
statusClass := resp.Status.Class() statusClass := resp.Status.Class()
if statusClass < minStatus || statusClass > maxStatus { if statusClass < minStatus || statusClass > maxStatus {
return ErrInvalidResponse return nil, ErrInvalidResponse
} }
// Read one space // Read one space
if b, err := br.ReadByte(); err != nil { if b, err := br.ReadByte(); err != nil {
return err return nil, err
} else if b != ' ' { } else if b != ' ' {
return ErrInvalidResponse return nil, ErrInvalidResponse
} }
// Read the meta // Read the meta
meta, err := br.ReadString('\r') meta, err := br.ReadString('\r')
if err != nil { if err != nil {
return err return nil, err
} }
// Trim carriage return // Trim carriage return
meta = meta[:len(meta)-1] meta = meta[:len(meta)-1]
// Ensure meta is less than or equal to 1024 bytes // Ensure meta is less than or equal to 1024 bytes
if len(meta) > 1024 { if len(meta) > 1024 {
return ErrInvalidResponse return nil, ErrInvalidResponse
} }
// Default mime type of text/gemini; charset=utf-8 // Default mime type of text/gemini; charset=utf-8
if statusClass == StatusClassSuccess && meta == "" { if statusClass == StatusClassSuccess && meta == "" {
@@ -76,15 +75,15 @@ func (resp *Response) read(rc io.ReadCloser) error {
// Read terminating newline // Read terminating newline
if b, err := br.ReadByte(); err != nil { if b, err := br.ReadByte(); err != nil {
return err return nil, err
} else if b != '\n' { } else if b != '\n' {
return ErrInvalidResponse return nil, ErrInvalidResponse
} }
if resp.Status.Class() == StatusClassSuccess { if resp.Status.Class() == StatusClassSuccess {
resp.Body = newReadCloserBody(br, rc) resp.Body = newReadCloserBody(br, rc)
} }
return nil return resp, nil
} }
type readCloserBody struct { type readCloserBody struct {

View File

@@ -4,9 +4,9 @@ import (
"bufio" "bufio"
"crypto/tls" "crypto/tls"
"errors" "errors"
"io"
"log" "log"
"net" "net"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -174,6 +174,7 @@ func (s *Server) getCertificateFor(hostname string) (*tls.Certificate, error) {
// respond responds to a connection. // respond responds to a connection.
func (s *Server) respond(conn net.Conn) { func (s *Server) respond(conn net.Conn) {
defer conn.Close()
if d := s.ReadTimeout; d != 0 { if d := s.ReadTimeout; d != 0 {
conn.SetReadDeadline(time.Now().Add(d)) conn.SetReadDeadline(time.Now().Add(d))
} }
@@ -181,55 +182,33 @@ func (s *Server) respond(conn net.Conn) {
conn.SetWriteDeadline(time.Now().Add(d)) conn.SetWriteDeadline(time.Now().Add(d))
} }
r := bufio.NewReader(conn) w := NewResponseWriter(conn)
w := newResponseWriter(conn) defer w.b.Flush()
// Read requested URL
rawurl, err := r.ReadString('\r') req, err := ReadRequest(conn)
if err != nil { if err != nil {
return
}
// Read terminating line feed
if b, err := r.ReadByte(); err != nil {
return
} else if b != '\n' {
w.WriteStatus(StatusBadRequest)
}
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1]
// Ensure URL is valid
if len(rawurl) > 1024 {
w.WriteStatus(StatusBadRequest)
} else if url, err := url.Parse(rawurl); err != nil || url.User != nil {
// Note that we return an error status if User is specified in the URL
w.WriteStatus(StatusBadRequest) w.WriteStatus(StatusBadRequest)
} else { } else {
// Store information about the TLS connection // Store information about the TLS connection
connState := conn.(*tls.Conn).ConnectionState() if tlsConn, ok := conn.(*tls.Conn); ok {
var cert *tls.Certificate req.TLS = tlsConn.ConnectionState()
if len(connState.PeerCertificates) > 0 { if len(req.TLS.PeerCertificates) > 0 {
peerCert := connState.PeerCertificates[0] peerCert := req.TLS.PeerCertificates[0]
// Store the TLS certificate // Store the TLS certificate
cert = &tls.Certificate{ req.Certificate = &tls.Certificate{
Certificate: [][]byte{peerCert.Raw}, Certificate: [][]byte{peerCert.Raw},
Leaf: peerCert, Leaf: peerCert,
}
} }
} }
req := &Request{
URL: url,
RemoteAddr: conn.RemoteAddr(),
TLS: connState,
Certificate: cert,
}
resp := s.responder(req)
if resp != nil {
resp.Respond(w, req)
} else {
w.WriteStatus(StatusNotFound)
}
} }
w.b.Flush()
conn.Close() resp := s.responder(req)
if resp != nil {
resp.Respond(w, req)
} else {
w.WriteStatus(StatusNotFound)
}
} }
func (s *Server) responder(r *Request) Responder { func (s *Server) responder(r *Request) Responder {
@@ -261,9 +240,10 @@ type ResponseWriter struct {
mediatype string mediatype string
} }
func newResponseWriter(conn net.Conn) *ResponseWriter { // NewResponseWriter returns a ResponseWriter that uses the provided io.Writer.
func NewResponseWriter(w io.Writer) *ResponseWriter {
return &ResponseWriter{ return &ResponseWriter{
b: bufio.NewWriter(conn), b: bufio.NewWriter(w),
} }
} }
@@ -303,7 +283,7 @@ func (w *ResponseWriter) SetMediaType(mediatype string) {
w.mediatype = mediatype w.mediatype = mediatype
} }
// Write writes the response body. // Write writes data to the connection as part of the response body.
// If the response status does not allow for a response body, Write returns // If the response status does not allow for a response body, Write returns
// ErrBodyNotAllowed. // ErrBodyNotAllowed.
// //
@@ -323,6 +303,11 @@ func (w *ResponseWriter) Write(b []byte) (int, error) {
return w.b.Write(b) return w.b.Write(b)
} }
// Flush writes any buffered data to the underlying io.Writer.
func (w *ResponseWriter) Flush() error {
return w.b.Flush()
}
// A Responder responds to a Gemini request. // A Responder responds to a Gemini request.
type Responder interface { type Responder interface {
// Respond accepts a Request and constructs a Response. // Respond accepts a Request and constructs a Response.
@@ -335,23 +320,3 @@ type ResponderFunc func(*ResponseWriter, *Request)
func (f ResponderFunc) Respond(w *ResponseWriter, r *Request) { func (f ResponderFunc) Respond(w *ResponseWriter, r *Request) {
f(w, r) f(w, r)
} }
// Input returns the request query.
// If the query is invalid or no query is provided, ok will be false.
//
// Example:
//
// input, ok := gemini.Input(req)
// if !ok {
// w.WriteHeader(gemini.StatusInput, "Prompt")
// return
// }
// // ...
//
func Input(r *Request) (query string, ok bool) {
if r.URL.ForceQuery || r.URL.RawQuery != "" {
query, err := url.QueryUnescape(r.URL.RawQuery)
return query, err == nil
}
return "", false
}

35
tofu.go
View File

@@ -8,35 +8,34 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
// Trust represents the trustworthiness of a certificate.
type Trust int
const (
TrustNone Trust = iota // The certificate is not trusted.
TrustOnce // The certificate is trusted once.
TrustAlways // The certificate is trusted always.
)
// KnownHosts maps hosts to fingerprints. // KnownHosts maps hosts to fingerprints.
type KnownHosts map[string]Fingerprint type KnownHosts map[string]Fingerprint
// KnownHostsFile represents a list of known hosts optionally loaded from a file. // KnownHostsFile represents a list of known hosts optionally loaded from a file.
// The zero value for KnownHostsFile represents an empty list ready to use. // The zero value for KnownHostsFile represents an empty list ready to use.
//
// KnownHostsFile is safe for concurrent use by multiple goroutines.
type KnownHostsFile struct { type KnownHostsFile struct {
KnownHosts KnownHosts
out io.Writer out io.Writer
mu sync.RWMutex
} }
// SetOutput sets the output to which new known hosts will be written to. // SetOutput sets the output to which new known hosts will be written to.
func (k *KnownHostsFile) SetOutput(w io.Writer) { func (k *KnownHostsFile) SetOutput(w io.Writer) {
k.mu.Lock()
defer k.mu.Unlock()
k.out = w k.out = w
} }
// Add adds a known host to the list of known hosts. // Add adds a known host to the list of known hosts.
func (k *KnownHostsFile) Add(hostname string, fingerprint Fingerprint) { func (k *KnownHostsFile) Add(hostname string, fingerprint Fingerprint) {
k.mu.Lock()
defer k.mu.Unlock()
if k.KnownHosts == nil { if k.KnownHosts == nil {
k.KnownHosts = KnownHosts{} k.KnownHosts = KnownHosts{}
} }
@@ -46,12 +45,16 @@ func (k *KnownHostsFile) Add(hostname string, fingerprint Fingerprint) {
// Lookup returns the fingerprint of the certificate corresponding to // Lookup returns the fingerprint of the certificate corresponding to
// the given hostname. // the given hostname.
func (k *KnownHostsFile) Lookup(hostname string) (Fingerprint, bool) { func (k *KnownHostsFile) Lookup(hostname string) (Fingerprint, bool) {
k.mu.RLock()
defer k.mu.RUnlock()
c, ok := k.KnownHosts[hostname] c, ok := k.KnownHosts[hostname]
return c, ok return c, ok
} }
// Write writes a known hosts entry to the configured output. // Write writes a known hosts entry to the configured output.
func (k *KnownHostsFile) Write(hostname string, fingerprint Fingerprint) { func (k *KnownHostsFile) Write(hostname string, fingerprint Fingerprint) {
k.mu.RLock()
defer k.mu.RUnlock()
if k.out != nil { if k.out != nil {
k.writeKnownHost(k.out, hostname, fingerprint) k.writeKnownHost(k.out, hostname, fingerprint)
} }
@@ -59,6 +62,8 @@ func (k *KnownHostsFile) Write(hostname string, fingerprint Fingerprint) {
// WriteAll writes all of the known hosts to the provided io.Writer. // WriteAll writes all of the known hosts to the provided io.Writer.
func (k *KnownHostsFile) WriteAll(w io.Writer) error { func (k *KnownHostsFile) WriteAll(w io.Writer) error {
k.mu.RLock()
defer k.mu.RUnlock()
for h, c := range k.KnownHosts { for h, c := range k.KnownHosts {
if _, err := k.writeKnownHost(w, h, c); err != nil { if _, err := k.writeKnownHost(w, h, c); err != nil {
return err return err
@@ -76,24 +81,20 @@ func (k *KnownHostsFile) writeKnownHost(w io.Writer, hostname string, f Fingerpr
// It creates the file if it does not exist. // It creates the file if it does not exist.
// New known hosts will be appended to the file. // New known hosts will be appended to the file.
func (k *KnownHostsFile) Load(path string) error { func (k *KnownHostsFile) Load(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0644) f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil { if err != nil {
return err return err
} }
k.Parse(f) k.Parse(f)
f.Close() k.SetOutput(f)
// Open the file for append-only use
f, err = os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
k.out = f
return nil return nil
} }
// Parse parses the provided reader and adds the parsed known hosts to the list. // Parse parses the provided reader and adds the parsed known hosts to the list.
// Invalid entries are ignored. // Invalid entries are ignored.
func (k *KnownHostsFile) Parse(r io.Reader) { func (k *KnownHostsFile) Parse(r io.Reader) {
k.mu.Lock()
defer k.mu.Unlock()
if k.KnownHosts == nil { if k.KnownHosts == nil {
k.KnownHosts = map[string]Fingerprint{} k.KnownHosts = map[string]Fingerprint{}
} }