118 Commits

Author SHA1 Message Date
Adnan Maolood
d78052ce08 Move tofu.go to a subpackage 2021-01-10 16:46:12 -05:00
Adnan Maolood
1f2888c54a Update documentation 2021-01-10 01:21:56 -05:00
Adnan Maolood
41d5f8d31b Move documentation back to doc.go 2021-01-10 01:16:50 -05:00
Adnan Maolood
24026422b2 Update examples/stream.go 2021-01-10 01:13:07 -05:00
Adnan Maolood
5e977250ec Update comments 2021-01-10 01:07:38 -05:00
Adnan Maolood
d8c5da1c7c Update link to documentation 2021-01-10 00:55:39 -05:00
Adnan Maolood
d01d50ff1a Simplify ResponseWriter implementation 2021-01-10 00:50:35 -05:00
Adnan Maolood
3ed39e62d8 Rename status.Message to status.Meta 2021-01-10 00:10:57 -05:00
Hugo Wetterberg
f2921a396f Add missing error handling
Error handling is currently missing is a couple of places. Most of
them are i/o related.

This change adds checks, an therefore sometimes also has to change
function signatures by adding an error return value. In the case of
the response writer the status and meta handling is changed and this
also breaks the API.

In some places where we don't have any reasonable I've added
assignment to a blank identifier to make it clear that we're ignoring
an error.

text: read the Err() that can be set by the scanner.

client: check if conn.SetDeadline() returns an error.

client: check if req.Write() returns an error.

fs: panic if mime type registration fails.

server: stop performing i/o in Header/Status functions

By deferring the actual header write to the first Write() or Flush()
call we don't have to do any error handling in Header() or Status().

As Server.respond() now defers a ResponseWriter.Flush() instead of
directly flushing the underlying bufio.Writer this has the added
benefit of ensuring that we always write a header
to the client, even if the responder is a complete NOOP.

tofu: return an error if we fail to write to the known hosts writer.
2021-01-09 23:53:07 -05:00
Hugo Wetterberg
efef44c2f9 server: abort request handling on bad requests
A request to a hostname that hasn't been registered with the server
currently results in a nil pointer deref panic in server.go:215 as
request handling continues even if ReadRequest() returns an error.

This change changes all if-else error handling in Server.respond() to
a WriteStatus-call and early return. This makes it clear when request
handling is aborted (and actually aborts when ReadRequest() fails).
2021-01-05 18:33:36 -05:00
Adnan Maolood
c8626bae17 client: Close connection for unsuccessful responses 2020-12-22 19:22:01 -05:00
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
Adnan Maolood
611a7d54c0 Revert to using hexadecimal to encode fingerprints 2020-12-16 23:58:02 -05:00
Adnan Maolood
16739d20d0 Fix escaping of queries 2020-11-27 22:27:52 -05:00
Adnan Maolood
24e488a4cb examples/server: Increase certificate duration 2020-11-27 17:54:26 -05:00
Adnan Maolood
e0ac1685d2 Fix server name in TLS connections 2020-11-27 17:45:15 -05:00
Adnan Maolood
82688746dd Add context to requests 2020-11-26 00:42:25 -05:00
Adnan Maolood
3b9cc7f168 Update examples/auth.go 2020-11-25 19:10:01 -05:00
Adnan Maolood
3c7940f153 Fix known hosts expiration timestamps 2020-11-25 14:24:49 -05:00
Adnan Maolood
8ee55ee009 Fix certificate fingerprint check 2020-11-25 14:20:31 -05:00
Adnan Maolood
7ee0ea8b7f Use base64 to encode fingerprints 2020-11-25 14:16:51 -05:00
Adnan Maolood
ab1db34f02 Fix client locking up on redirects 2020-11-24 21:49:24 -05:00
Adnan Maolood
35e984fbba Escape path character in certificate scopes 2020-11-24 20:24:38 -05:00
Adnan Maolood
cab23032c0 Don't assume a default scheme of gemini 2020-11-24 17:13:52 -05:00
Adnan Maolood
4b653032e4 Make Client safe for concurrent use 2020-11-24 16:28:58 -05:00
Adnan Maolood
0c75e5d5ad Expose KnownHosts and CertificateStore internals 2020-11-23 12:17:54 -05:00
Adnan Maolood
f6b0443a62 Update KnownHosts documentation 2020-11-09 13:57:30 -05:00
Adnan Maolood
3dee6dcff3 Add (*CertificateStore).Write function 2020-11-09 13:54:15 -05:00
Adnan Maolood
85f8e84bd5 Rename (*ResponseWriter).SetMimetype to SetMediaType 2020-11-09 13:44:42 -05:00
Adnan Maolood
9338681256 Add (*KnownHosts).SetOutput function 2020-11-09 12:26:08 -05:00
Adnan Maolood
f2a1510375 Move documentation to gemini.go 2020-11-09 12:07:49 -05:00
Adnan Maolood
46cbcfcaa4 Remove top-level Get and Do functions 2020-11-09 12:04:53 -05:00
Adnan Maolood
76dfe257f1 Remove (*KnownHosts).LoadDefault function 2020-11-09 09:28:44 -05:00
Adnan Maolood
5332dc6280 Don't guarantee that (*Response).Body is always non-nil 2020-11-08 18:38:08 -05:00
Adnan Maolood
6b3cf1314b Fix relative redirects 2020-11-07 23:43:07 -05:00
Adnan Maolood
fe92db1e9c Allow redirects to non-gemini schemes 2020-11-06 11:18:58 -05:00
Adnan Maolood
ff6c95930b Fix TOFU 2020-11-05 22:30:13 -05:00
Adnan Maolood
a5712c7705 Don't check if certificate is expired 2020-11-05 18:35:25 -05:00
Adnan Maolood
520d0a7fb1 Don't redirect by default 2020-11-05 15:44:01 -05:00
Adnan Maolood
bf185e4091 update examples/cert.go 2020-11-05 15:38:41 -05:00
Adnan Maolood
8101fbe473 Update examples/auth.go 2020-11-05 15:37:46 -05:00
Adnan Maolood
b76080c863 Refactor KnownHosts 2020-11-05 15:27:12 -05:00
Adnan Maolood
53390dad6b Document CertificateOptions 2020-11-05 00:04:58 -05:00
Adnan Maolood
cec1f118fb Remove some unnecessary errors 2020-11-04 23:46:05 -05:00
Adnan Maolood
95716296b4 Use ECDSA keys by default 2020-11-03 19:43:04 -05:00
Adnan Maolood
1490bf6a75 Update examples/auth.go 2020-11-03 16:29:39 -05:00
Adnan Maolood
610c6fc533 Add ErrorLog field to Server 2020-11-03 16:11:31 -05:00
Adnan Maolood
01670647d2 Add Subject option in CertificateOptions 2020-11-02 23:11:46 -05:00
Adnan Maolood
5b3194695f Store request certificate to prevent infinite loop 2020-11-02 13:47:07 -05:00
Adnan Maolood
b6475aa7d9 server: Populate (*Request).Certificate field 2020-11-01 16:25:59 -05:00
Adnan Maolood
cc372e8768 Prevent infinite loop in client requests 2020-11-01 15:14:56 -05:00
adnano
8e442146c3 Update examples/auth.go 2020-11-01 14:47:26 -05:00
adnano
e4dea6f2c8 Refactor Certificate and Input functions 2020-11-01 14:35:03 -05:00
adnano
b57ea57fec Don't expose DefaultClient 2020-11-01 14:27:49 -05:00
adnano
c3fc9a4e9f examples: Tweak client and server timeouts 2020-11-01 14:20:24 -05:00
adnano
22d57dfc9e Update examples/cert.go 2020-11-01 14:19:18 -05:00
Adnan Maolood
12bdb2f997 Update examples/html.go 2020-11-01 00:58:34 -04:00
Adnan Maolood
7fb1b6c6a4 Update documentation 2020-11-01 00:10:30 -04:00
Adnan Maolood
0d3230a7d5 Rename InsecureTrustAlways to InsecureSkipTrust 2020-10-31 23:41:30 -04:00
Adnan Maolood
79b3b22e69 Update documentation 2020-10-31 23:05:31 -04:00
Adnan Maolood
33c1dc435d Guarantee that (*Response).Body is non-nil 2020-10-31 23:04:47 -04:00
Adnan Maolood
dad8f38bfb Fix examples/client.go 2020-10-31 22:50:42 -04:00
Adnan Maolood
8181b86759 Add option to skip trust checks 2020-10-31 22:45:21 -04:00
Adnan Maolood
65a5065250 Refactor client.TrustCertificate workflow 2020-10-31 22:34:51 -04:00
Adnan Maolood
b9cb7fe71d Update log.Printf calls 2020-10-31 21:33:59 -04:00
Adnan Maolood
7d470c5fb1 Implement Server read and write timeouts 2020-10-31 21:07:02 -04:00
Adnan Maolood
42c95f8c8d Implement Client connection timeout 2020-10-31 20:55:56 -04:00
Adnan Maolood
a2fc1772bf Set default mimetype if META is empty 2020-10-31 20:32:38 -04:00
Adnan Maolood
63b9b484d1 Remove Redirect and PermanentRedirect functions
Use (*ResponseWriter).WriteHeader instead.
2020-10-31 16:51:10 -04:00
Adnan Maolood
ca8e0166fc Add ErrCertificateNotFound 2020-10-31 16:45:38 -04:00
Adnan Maolood
14ef3be6fe server: Automatically write new certificates to disk 2020-10-31 16:33:56 -04:00
Adnan Maolood
3aa254870a Call CreateCertificate for missing certificates 2020-10-31 15:38:39 -04:00
Adnan Maolood
a89065babb Fix handling of wildcard hostnames 2020-10-31 15:11:05 -04:00
Adnan Maolood
eb466ad02f Add ParseLines function 2020-10-29 09:42:53 -04:00
Adnan Maolood
66e4dc86d5 Add optional host argument in examples/client.go 2020-10-28 16:50:17 -04:00
Adnan Maolood
5e4a38dccb Fix documentation 2020-10-28 16:04:14 -04:00
Adnan Maolood
b5fbd197a1 Update documentation 2020-10-28 16:02:04 -04:00
Adnan Maolood
34ae2a9066 Use strings.Builder in Fingerprint 2020-10-28 15:14:24 -04:00
Adnan Maolood
7f0b1fa8a1 Refactor server certificates 2020-10-28 15:03:54 -04:00
Adnan Maolood
32f22a3e2c Fix examples/cert.go 2020-10-28 13:47:52 -04:00
Adnan Maolood
fbd97a62de Refactor client certificates 2020-10-28 13:41:24 -04:00
Adnan Maolood
768664e0c5 Add ErrInputRequired and ErrCertificateRequired 2020-10-28 01:06:08 -04:00
Adnan Maolood
7a1a33513a Store a reference to the Request in Response 2020-10-28 00:21:27 -04:00
Adnan Maolood
e6072d8bbc Ensure absolute paths in client certificate store 2020-10-27 23:47:13 -04:00
Adnan Maolood
4c5167f590 Add Client.GetInput field 2020-10-27 23:35:22 -04:00
Adnan Maolood
d1dcf070ff Restrict client certificates to certain paths 2020-10-27 23:34:06 -04:00
Adnan Maolood
fc72224ce9 client: Follow redirects 2020-10-27 22:12:10 -04:00
Adnan Maolood
b84811668c Reject schemes other than gemini:// in NewRequest 2020-10-27 21:18:05 -04:00
Adnan Maolood
239ec885f7 Add (*Client).Get function 2020-10-27 19:22:34 -04:00
Adnan Maolood
12a9deb1a6 Make (*Response).Body an io.ReadCloser 2020-10-27 19:16:55 -04:00
Adnan Maolood
860a33f5a2 Fix examples 2020-10-27 14:17:14 -04:00
21 changed files with 1417 additions and 1422 deletions

View File

@@ -1,6 +1,6 @@
# go-gemini # go-gemini
[![GoDoc](https://godoc.org/git.sr.ht/~adnano/go-gemini?status.svg)](https://godoc.org/git.sr.ht/~adnano/go-gemini) [![godocs.io](https://godocs.io/git.sr.ht/~adnano/go-gemini?status.svg)](https://godocs.io/git.sr.ht/~adnano/go-gemini)
Package gemini implements the [Gemini protocol](https://gemini.circumlunar.space) in Go. Package gemini implements the [Gemini protocol](https://gemini.circumlunar.space) in Go.

189
cert.go
View File

@@ -2,28 +2,44 @@ package gemini
import ( import (
"crypto" "crypto"
"crypto/ecdsa"
"crypto/ed25519" "crypto/ed25519"
"crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big" "math/big"
"net" "net"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time" "time"
) )
// CertificateStore maps hostnames to certificates. // CertificateDir maps certificate scopes to certificates.
// The zero value of CertificateStore is an empty store ready to use. type CertificateStore map[string]tls.Certificate
type CertificateStore struct {
store map[string]tls.Certificate // CertificateDir represents a certificate store optionally loaded from a directory.
// The zero value of CertificateDir is an empty store ready to use.
//
// CertificateDir is safe for concurrent use by multiple goroutines.
type CertificateDir struct {
CertificateStore
dir bool
path string
mu sync.RWMutex
} }
// Add adds a certificate for the given hostname 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 *CertificateStore) Add(hostname string, cert tls.Certificate) { func (c *CertificateDir) Add(scope string, cert tls.Certificate) {
if c.store == nil { c.mu.Lock()
c.store = map[string]tls.Certificate{} defer c.mu.Unlock()
if c.CertificateStore == nil {
c.CertificateStore = CertificateStore{}
} }
// Parse certificate if not already parsed // Parse certificate if not already parsed
if cert.Leaf == nil { if cert.Leaf == nil {
@@ -32,28 +48,40 @@ func (c *CertificateStore) Add(hostname string, cert tls.Certificate) {
cert.Leaf = parsed cert.Leaf = parsed
} }
} }
c.store[hostname] = cert c.CertificateStore[scope] = cert
} }
// Lookup returns the certificate for the given hostname. // Write writes the provided certificate to the certificate directory.
func (c *CertificateStore) Lookup(hostname string) (*tls.Certificate, error) { func (c *CertificateDir) Write(scope string, cert tls.Certificate) error {
cert, ok := c.store[hostname] c.mu.RLock()
if !ok { defer c.mu.RUnlock()
return nil, ErrCertificateUnknown if c.dir {
// Escape slash character
scope = strings.ReplaceAll(scope, "/", ":")
certPath := filepath.Join(c.path, scope+".crt")
keyPath := filepath.Join(c.path, scope+".key")
if err := WriteCertificate(cert, certPath, keyPath); err != nil {
return err
}
} }
// Ensure that the certificate is not expired return nil
if cert.Leaf != nil && cert.Leaf.NotAfter.Before(time.Now()) { }
return &cert, ErrCertificateExpired
} // Lookup returns the certificate for the given scope.
return &cert, nil func (c *CertificateDir) Lookup(scope string) (tls.Certificate, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
cert, ok := c.CertificateStore[scope]
return cert, ok
} }
// Load loads certificates from the given path. // Load loads certificates from the given path.
// The path should lead to a directory containing certificates and private keys // The path should lead to a directory containing certificates and private keys
// in the form hostname.crt and hostname.key. // in the form scope.crt and scope.key.
// For example, the hostname "localhost" would have the corresponding files // For example, the hostname "localhost" would have the corresponding files
// localhost.crt (certificate) and localhost.key (private key). // localhost.crt (certificate) and localhost.key (private key).
func (c *CertificateStore) Load(path string) error { // New certificates will be written to this directory.
func (c *CertificateDir) Load(path string) error {
matches, err := filepath.Glob(filepath.Join(path, "*.crt")) matches, err := filepath.Glob(filepath.Join(path, "*.crt"))
if err != nil { if err != nil {
return err return err
@@ -64,15 +92,53 @@ func (c *CertificateStore) Load(path string) error {
if err != nil { if err != nil {
continue continue
} }
hostname := strings.TrimSuffix(filepath.Base(crtPath), ".crt") scope := strings.TrimSuffix(filepath.Base(crtPath), ".crt")
c.Add(hostname, cert) // Unescape slash character
scope = strings.ReplaceAll(scope, ":", "/")
c.Add(scope, cert)
} }
c.SetDir(path)
return nil return nil
} }
// NewCertificate creates and returns a new parsed certificate. // SetDir sets the directory that new certificates will be written to.
func NewCertificate(host string, duration time.Duration) (tls.Certificate, error) { func (c *CertificateDir) SetDir(path string) {
crt, priv, err := newX509KeyPair(host, duration) c.mu.Lock()
defer c.mu.Unlock()
c.dir = true
c.path = path
}
// CertificateOptions configures the creation of a certificate.
type CertificateOptions struct {
// Subject Alternate Name values.
// Should contain the IP addresses that the certificate is valid for.
IPAddresses []net.IP
// Subject Alternate Name values.
// Should contain the DNS names that this certificate is valid for.
// E.g. example.com, *.example.com
DNSNames []string
// Subject specifies the certificate Subject.
//
// Subject.CommonName can contain the DNS name that this certificate
// is valid for. Server certificates should specify both a Subject
// and a Subject Alternate Name.
Subject pkix.Name
// Duration specifies the amount of time that the certificate is valid for.
Duration time.Duration
// Ed25519 specifies whether to generate an Ed25519 key pair.
// If false, an ECDSA key will be generated instead.
// Ed25519 is not as widely supported as ECDSA.
Ed25519 bool
}
// CreateCertificate creates a new TLS certificate.
func CreateCertificate(options CertificateOptions) (tls.Certificate, error) {
crt, priv, err := newX509KeyPair(options)
if err != nil { if err != nil {
return tls.Certificate{}, err return tls.Certificate{}, err
} }
@@ -84,16 +150,28 @@ func NewCertificate(host string, duration time.Duration) (tls.Certificate, error
} }
// newX509KeyPair creates and returns a new certificate and private key. // newX509KeyPair creates and returns a new certificate and private key.
func newX509KeyPair(host string, duration time.Duration) (*x509.Certificate, crypto.PrivateKey, error) { func newX509KeyPair(options CertificateOptions) (*x509.Certificate, crypto.PrivateKey, error) {
// Generate an ED25519 private key var pub crypto.PublicKey
_, priv, err := ed25519.GenerateKey(rand.Reader) var priv crypto.PrivateKey
if err != nil { if options.Ed25519 {
return nil, nil, err // Generate an Ed25519 private key
var err error
pub, priv, err = ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, err
}
} else {
// Generate an ECDSA private key
private, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, err
}
priv = private
pub = &private.PublicKey
} }
public := priv.Public()
// ED25519 keys should have the DigitalSignature KeyUsage bits set // ECDSA and Ed25519 keys should have the DigitalSignature KeyUsage bits
// in the x509.Certificate template // set in the x509.Certificate template
keyUsage := x509.KeyUsageDigitalSignature keyUsage := x509.KeyUsageDigitalSignature
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
@@ -103,7 +181,7 @@ func newX509KeyPair(host string, duration time.Duration) (*x509.Certificate, cry
} }
notBefore := time.Now() notBefore := time.Now()
notAfter := notBefore.Add(duration) notAfter := notBefore.Add(options.Duration)
template := x509.Certificate{ template := x509.Certificate{
SerialNumber: serialNumber, SerialNumber: serialNumber,
@@ -112,18 +190,12 @@ func newX509KeyPair(host string, duration time.Duration) (*x509.Certificate, cry
KeyUsage: keyUsage, KeyUsage: keyUsage,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true, BasicConstraintsValid: true,
IPAddresses: options.IPAddresses,
DNSNames: options.DNSNames,
Subject: options.Subject,
} }
hosts := strings.Split(host, ",") crt, err := x509.CreateCertificate(rand.Reader, &template, &template, pub, priv)
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}
crt, err := x509.CreateCertificate(rand.Reader, &template, &template, public, priv)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -133,3 +205,30 @@ func newX509KeyPair(host string, duration time.Duration) (*x509.Certificate, cry
} }
return cert, priv, nil return cert, priv, nil
} }
// WriteCertificate writes the provided certificate and private key
// to certPath and keyPath respectively.
func WriteCertificate(cert tls.Certificate, certPath, keyPath string) error {
certOut, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer certOut.Close()
if err := pem.Encode(certOut, &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Leaf.Raw,
}); err != nil {
return err
}
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer keyOut.Close()
privBytes, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey)
if err != nil {
return err
}
return pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes})
}

151
client.go
View File

@@ -2,101 +2,130 @@ package gemini
import ( import (
"bufio" "bufio"
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors"
"fmt"
"net"
"strings"
"time"
) )
// Client represents a Gemini client. // Client is a Gemini client.
type Client struct { type Client struct {
// KnownHosts is a list of known hosts that the client trusts. // TrustCertificate is called to determine whether the client
KnownHosts KnownHosts // should trust the certificate provided by the server.
// If TrustCertificate is nil, the client will accept any certificate.
// If the returned error is not nil, the certificate will not be trusted
// and the request will be aborted.
TrustCertificate func(hostname string, cert *x509.Certificate) error
// CertificateStore maps hostnames to certificates. // Timeout specifies a time limit for requests made by this
// It is used to determine which certificate to use when the server requests // Client. The timeout includes connection time and reading
// a certificate. // the response body. The timer remains running after
CertificateStore CertificateStore // Get and Do return and will interrupt reading of the Response.Body.
//
// GetCertificate, if not nil, will be called when a server requests a certificate. // A Timeout of zero means no timeout.
// The returned certificate will be used when sending the request again. Timeout time.Duration
// If the certificate is nil, the request will not be sent again and
// the response will be returned.
GetCertificate func(hostname string, store *CertificateStore) *tls.Certificate
// TrustCertificate, if not nil, will be called to determine whether the
// client should trust the given certificate.
// If error is not nil, the connection will be aborted.
TrustCertificate func(hostname string, cert *x509.Certificate, knownHosts *KnownHosts) error
} }
// Send sends a Gemini request and returns a Gemini response. // Get performs a Gemini request for the given URL.
func (c *Client) Send(req *Request) (*Response, error) { func (c *Client) Get(url string) (*Response, error) {
req, err := NewRequest(url)
if err != nil {
return nil, err
}
return c.Do(req)
}
// Do performs a Gemini request and returns a Gemini response.
func (c *Client) Do(req *Request) (*Response, error) {
// Extract hostname
colonPos := strings.LastIndex(req.Host, ":")
if colonPos == -1 {
colonPos = len(req.Host)
}
hostname := req.Host[:colonPos]
// Connect to the host // Connect to the host
config := &tls.Config{ config := &tls.Config{
InsecureSkipVerify: true, InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
GetClientCertificate: func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) { GetClientCertificate: func(_ *tls.CertificateRequestInfo) (*tls.Certificate, error) {
// Request certificates take precedence over client certificates
if req.Certificate != nil { if req.Certificate != nil {
return req.Certificate, nil return req.Certificate, nil
} }
// If we have already stored the certificate, return it
if cert, err := c.CertificateStore.Lookup(hostname(req.Host)); err == nil {
return cert, nil
}
return &tls.Certificate{}, nil return &tls.Certificate{}, nil
}, },
VerifyConnection: func(cs tls.ConnectionState) error { VerifyConnection: func(cs tls.ConnectionState) error {
cert := cs.PeerCertificates[0] return c.verifyConnection(req, cs)
// Verify the hostname
if err := verifyHostname(cert, hostname(req.Host)); err != nil {
return err
}
// Check that the client trusts the certificate
if c.TrustCertificate == nil {
if err := c.KnownHosts.Lookup(hostname(req.Host), cert); err != nil {
return err
}
} else if err := c.TrustCertificate(hostname(req.Host), cert, &c.KnownHosts); err != nil {
return err
}
return nil
}, },
ServerName: hostname,
} }
conn, err := tls.Dial("tcp", req.Host, config) // 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
} }
defer conn.Close() conn := tls.Client(netConn, config)
// Set connection deadline
if c.Timeout != 0 {
err := conn.SetDeadline(time.Now().Add(c.Timeout))
if err != nil {
return nil, fmt.Errorf(
"failed to set connection deadline: %w", err)
}
}
// Write the request // Write the request
w := bufio.NewWriter(conn) w := bufio.NewWriter(conn)
req.write(w)
err = req.Write(w)
if err != nil {
return nil, fmt.Errorf(
"failed to write request data: %w", err)
}
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)
r := bufio.NewReader(conn) if err != nil {
if err := resp.read(r); err != nil {
return nil, err return nil, err
} }
// Store connection information // Store connection state
resp.TLS = conn.ConnectionState() resp.TLS = conn.ConnectionState()
// Resend the request with a certificate if the server responded
// with CertificateRequired
if resp.Status == StatusCertificateRequired {
// Check to see if a certificate was already provided to prevent an infinite loop
if req.Certificate != nil {
return resp, nil
}
if c.GetCertificate != nil {
if cert := c.GetCertificate(hostname(req.Host), &c.CertificateStore); cert != nil {
req.Certificate = cert
return c.Send(req)
}
}
}
return resp, nil return resp, nil
} }
func (c *Client) verifyConnection(req *Request, cs tls.ConnectionState) error {
// Verify the hostname
var hostname string
if host, _, err := net.SplitHostPort(req.Host); err == nil {
hostname = host
} else {
hostname = req.Host
}
cert := cs.PeerCertificates[0]
if err := verifyHostname(cert, hostname); err != nil {
return err
}
// Check expiration date
if !time.Now().Before(cert.NotAfter) {
return errors.New("gemini: certificate expired")
}
// See if the client trusts the certificate
if c.TrustCertificate != nil {
return c.TrustCertificate(hostname, cert)
}
return nil
}

60
doc.go
View File

@@ -1,63 +1,29 @@
/* /*
Package gemini implements the Gemini protocol. Package gemini implements the Gemini protocol.
Send makes a Gemini request with the default client: Client is a Gemini client.
req := gemini.NewRequest("gemini://example.com") client := &gemini.Client{}
resp, err := gemini.Send(req) resp, err := client.Get("gemini://example.com")
if err != nil { if err != nil {
// handle error // handle error
} }
if resp.Body != nil {
defer resp.Body.Close()
// ...
}
// ... // ...
For control over client behavior, create a custom Client:
var client gemini.Client
resp, err := client.Send(req)
if err != nil {
// handle error
}
// ...
The default client loads known hosts from "$XDG_DATA_HOME/gemini/known_hosts".
Custom clients can load their own list of known hosts:
err := client.KnownHosts.Load("path/to/my/known_hosts")
if err != nil {
// handle error
}
Clients can control when to trust certificates with TrustCertificate:
client.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *gemini.KnownHosts) error {
return knownHosts.Lookup(hostname, cert)
}
If a server responds with StatusCertificateRequired, the default client will generate a certificate and resend the request with it. Custom clients can do so in GetCertificate:
client.GetCertificate = func(hostname string, store *gemini.CertificateStore) *tls.Certificate {
// If the certificate is in the store, return it
if cert, err := store.Lookup(hostname); err == nil {
return &cert
}
// Otherwise, generate a certificate
duration := time.Hour
cert, err := gemini.NewCertificate(hostname, duration)
if err != nil {
return nil
}
// Store and return the certificate
store.Add(hostname, cert)
return &cert
}
Server is a Gemini server. Server is a Gemini server.
var server gemini.Server server := &gemini.Server{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
Servers must be configured with certificates: Servers should be configured with certificates:
err := server.CertificateStore.Load("/var/lib/gemini/certs") err := server.Certificates.Load("/var/lib/gemini/certs")
if err != nil { if err != nil {
// handle error // handle error
} }

View File

@@ -3,48 +3,44 @@
package main package main
import ( import (
"crypto/sha512"
"crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix"
"fmt" "fmt"
"log" "log"
"time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
) )
type user struct { type User struct {
password string // TODO: use hashes Name string
admin bool
}
type session struct {
username string
authorized bool // whether or not the password was supplied
} }
var ( var (
// Map of usernames to user data // Map of certificate hashes to users
logins = map[string]user{ users = map[string]*User{}
"admin": {"p@ssw0rd", true}, // NOTE: These are bad passwords!
"user1": {"password1", false},
"user2": {"password2", false},
}
// Map of certificate fingerprints to sessions
sessions = map[string]*session{}
) )
func main() { func main() {
var mux gmi.ServeMux var mux gemini.ServeMux
mux.HandleFunc("/", welcome) mux.HandleFunc("/", profile)
mux.HandleFunc("/login", login) mux.HandleFunc("/username", changeUsername)
mux.HandleFunc("/login/password", loginPassword)
mux.HandleFunc("/profile", profile)
mux.HandleFunc("/admin", admin)
mux.HandleFunc("/logout", logout)
var server gmi.Server var server gemini.Server
if err := server.CertificateStore.Load("/var/lib/gemini/certs"); err != nil { if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err) 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: time.Hour,
})
}
server.Register("localhost", &mux) server.Register("localhost", &mux)
if err := server.ListenAndServe(); err != nil { if err := server.ListenAndServe(); err != nil {
@@ -52,97 +48,43 @@ func main() {
} }
} }
func getSession(crt *x509.Certificate) (*session, bool) { func fingerprint(cert *x509.Certificate) string {
fingerprint := gmi.Fingerprint(crt) b := sha512.Sum512(cert.Raw)
session, ok := sessions[fingerprint] return string(b[:])
return session, ok
} }
func welcome(w *gmi.ResponseWriter, r *gmi.Request) { func profile(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprintln(w, "Welcome to this example.") if r.Certificate == nil {
fmt.Fprintln(w, "=> /login Login") w.WriteStatus(gemini.StatusCertificateRequired)
return
}
fingerprint := fingerprint(r.Certificate.Leaf)
user, ok := users[fingerprint]
if !ok {
user = &User{}
users[fingerprint] = user
}
fmt.Fprintln(w, "Username:", user.Name)
fmt.Fprintln(w, "=> /username Change username")
} }
func login(w *gmi.ResponseWriter, r *gmi.Request) { func changeUsername(w *gemini.ResponseWriter, r *gemini.Request) {
cert, ok := gmi.Certificate(w, r) if r.Certificate == nil {
if !ok { w.WriteStatus(gemini.StatusCertificateRequired)
return
}
username, ok := gmi.Input(w, r, "Username")
if !ok {
return
}
fingerprint := gmi.Fingerprint(cert)
sessions[fingerprint] = &session{
username: username,
}
gmi.Redirect(w, r, "/login/password")
}
func loginPassword(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
session, ok := getSession(cert)
if !ok {
gmi.CertificateNotAuthorized(w, r)
return return
} }
password, ok := gmi.SensitiveInput(w, r, "Password") username, err := gemini.QueryUnescape(r.URL.RawQuery)
if !ok { if err != nil || username == "" {
w.WriteHeader(gemini.StatusInput, "Username")
return return
} }
expected := logins[session.username].password fingerprint := fingerprint(r.Certificate.Leaf)
if password == expected { user, ok := users[fingerprint]
session.authorized = true if !ok {
gmi.Redirect(w, r, "/profile") user = &User{}
} else { users[fingerprint] = user
gmi.SensitiveInput(w, r, "Wrong password. Try again")
} }
} user.Name = username
w.WriteHeader(gemini.StatusRedirect, "/")
func logout(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
fingerprint := gmi.Fingerprint(cert)
delete(sessions, fingerprint)
fmt.Fprintln(w, "Successfully logged out.")
}
func profile(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
session, ok := getSession(cert)
if !ok {
gmi.CertificateNotAuthorized(w, r)
return
}
user := logins[session.username]
fmt.Fprintln(w, "Username:", session.username)
fmt.Fprintln(w, "Admin:", user.admin)
fmt.Fprintln(w, "=> /logout Logout")
}
func admin(w *gmi.ResponseWriter, r *gmi.Request) {
cert, ok := gmi.Certificate(w, r)
if !ok {
return
}
session, ok := getSession(cert)
if !ok {
gmi.CertificateNotAuthorized(w, r)
return
}
user := logins[session.username]
if !user.admin {
gmi.CertificateNotAuthorized(w, r)
return
}
fmt.Fprintln(w, "Welcome to the admin portal.")
} }

View File

@@ -1,83 +1,43 @@
// +build ignore // +build ignore
// This example illustrates a certificate generation tool.
package main package main
import ( import (
"bytes" "crypto/x509/pkix"
"crypto/tls" "fmt"
"crypto/x509"
"encoding/pem"
"log" "log"
"os" "os"
"time" "time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
) )
func main() { func main() {
host := "localhost" if len(os.Args) < 3 {
duration := 365 * 24 * time.Hour fmt.Printf("usage: %s [hostname] [duration]\n", os.Args[0])
cert, err := gmi.NewCertificate(host, duration) os.Exit(1)
}
host := os.Args[1]
duration, err := time.ParseDuration(os.Args[2])
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if err := writeCertificate(host, cert); err != nil { options := gemini.CertificateOptions{
Subject: pkix.Name{
CommonName: host,
},
DNSNames: []string{host},
Duration: duration,
}
cert, err := gemini.CreateCertificate(options)
if err != nil {
log.Fatal(err)
}
certPath := host + ".crt"
keyPath := host + ".key"
if err := gemini.WriteCertificate(cert, certPath, keyPath); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
// writeCertificate writes the provided certificate and private key
// to path.crt and path.key respectively.
func writeCertificate(path string, cert tls.Certificate) error {
crt, err := marshalX509Certificate(cert.Leaf.Raw)
if err != nil {
return err
}
key, err := marshalPrivateKey(cert.PrivateKey)
if err != nil {
return err
}
// Write the certificate
crtPath := path + ".crt"
crtOut, err := os.OpenFile(crtPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := crtOut.Write(crt); err != nil {
return err
}
// Write the private key
keyPath := path + ".key"
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := keyOut.Write(key); err != nil {
return err
}
return nil
}
// marshalX509Certificate returns a PEM-encoded version of the given raw certificate.
func marshalX509Certificate(cert []byte) ([]byte, error) {
var b bytes.Buffer
if err := pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert}); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// marshalPrivateKey returns PEM encoded versions of the given certificate and private key.
func marshalPrivateKey(priv interface{}) ([]byte, error) {
var b bytes.Buffer
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, err
}
return b.Bytes(), nil
}

View File

@@ -1,121 +1,43 @@
// +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"
"log"
"net/url" "net/url"
"os" "os"
"path/filepath"
"time" "time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
"git.sr.ht/~adnano/go-gemini/tofu"
"git.sr.ht/~adnano/go-xdg"
) )
var ( var (
scanner = bufio.NewScanner(os.Stdin) hosts tofu.KnownHostsFile
client = &gmi.Client{} scanner *bufio.Scanner
) )
func init() { func init() {
// Initialize the client // Load known hosts file
client.KnownHosts.LoadDefault() // Load known hosts path := filepath.Join(xdg.DataHome(), "gemini", "known_hosts")
client.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *gmi.KnownHosts) error { err := hosts.Load(path)
err := knownHosts.Lookup(hostname, cert)
if err != nil {
switch err {
case gmi.ErrCertificateNotTrusted:
// Alert the user that the certificate is not trusted
fmt.Printf("Warning: Certificate for %s is not trusted!\n", hostname)
fmt.Println("This could indicate a Man-in-the-Middle attack.")
case gmi.ErrCertificateUnknown:
// Prompt the user to trust the certificate
trust := trustCertificate(cert)
switch trust {
case trustOnce:
// Temporarily trust the certificate
knownHosts.AddTemporary(hostname, cert)
return nil
case trustAlways:
// Add the certificate to the known hosts file
knownHosts.Add(hostname, cert)
return nil
}
}
}
return err
}
client.GetCertificate = func(hostname string, store *gmi.CertificateStore) *tls.Certificate {
// If the certificate is in the store, return it
if cert, err := store.Lookup(hostname); err == nil {
return cert
}
// Otherwise, generate a certificate
fmt.Println("Generating client certificate for", hostname)
duration := time.Hour
cert, err := gmi.NewCertificate(hostname, duration)
if err != nil {
return nil
}
// Store and return the certificate
store.Add(hostname, cert)
return &cert
}
}
// sendRequest sends a request to the given URL.
func sendRequest(req *gmi.Request) error {
resp, err := client.Send(req)
if err != nil { if err != nil {
return err log.Println(err)
} }
// TODO: More fine-grained analysis of the status code. scanner = bufio.NewScanner(os.Stdin)
switch resp.Status / 10 {
case gmi.StatusClassInput:
fmt.Printf("%s: ", resp.Meta)
scanner.Scan()
req.URL.RawQuery = url.QueryEscape(scanner.Text())
return sendRequest(req)
case gmi.StatusClassSuccess:
fmt.Print(string(resp.Body))
return nil
case gmi.StatusClassRedirect:
fmt.Println("Redirecting to", resp.Meta)
target, err := url.Parse(resp.Meta)
if err != nil {
return err
}
// TODO: Prompt the user if the redirect is to another domain.
redirect, err := gmi.NewRequestFromURL(req.URL.ResolveReference(target))
if err != nil {
return err
}
return sendRequest(redirect)
case gmi.StatusClassTemporaryFailure:
return fmt.Errorf("Temporary failure: %s", resp.Meta)
case gmi.StatusClassPermanentFailure:
return fmt.Errorf("Permanent failure: %s", resp.Meta)
case gmi.StatusClassCertificateRequired:
// Note that this should not happen unless the server responds with
// CertificateRequired even after we send a certificate.
// CertificateNotAuthorized and CertificateNotValid are handled here.
return fmt.Errorf("Certificate required: %s", resp.Meta)
}
panic("unreachable")
} }
type trust int const trustPrompt = `The certificate offered by %s is of unknown trust. Its fingerprint is:
const (
trustAbort trust = iota
trustOnce
trustAlways
)
const trustPrompt = `The certificate offered by this server is of unknown trust. Its fingerprint is:
%s %s
If you knew the fingerprint to expect in advance, verify that this matches. If you knew the fingerprint to expect in advance, verify that this matches.
@@ -124,45 +46,108 @@ Otherwise, this should be safe to trust.
[t]rust always; trust [o]nce; [a]bort [t]rust always; trust [o]nce; [a]bort
=> ` => `
func trustCertificate(cert *x509.Certificate) trust { func trustCertificate(hostname string, cert *x509.Certificate) error {
fmt.Printf(trustPrompt, gmi.Fingerprint(cert)) fingerprint := tofu.NewFingerprint(cert.Raw, cert.NotAfter)
knownHost, ok := hosts.Lookup(hostname)
if ok && time.Now().Before(knownHost.Expires) {
// Check fingerprint
if knownHost.Hex == fingerprint.Hex {
return nil
}
return errors.New("error: fingerprint does not match!")
}
fmt.Printf(trustPrompt, hostname, fingerprint.Hex)
scanner.Scan() scanner.Scan()
switch scanner.Text() { switch scanner.Text() {
case "t": case "t":
return trustAlways hosts.Add(hostname, fingerprint)
hosts.Write(hostname, fingerprint)
return nil
case "o": case "o":
return trustOnce hosts.Add(hostname, fingerprint)
return nil
default: default:
return trustAbort return errors.New("certificate not trusted")
} }
} }
func getInput(prompt string, sensitive bool) (input string, ok bool) {
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://...", os.Args[0]) fmt.Printf("usage: %s <url> [host]\n", os.Args[0])
os.Exit(1) os.Exit(1)
} }
var host string // Do the request
if len(os.Args) >= 3 {
host = os.Args[2]
}
url := os.Args[1] url := os.Args[1]
var req *gmi.Request req, err := gemini.NewRequest(url)
var err error if err != nil {
if host != "" { fmt.Println(err)
req, err = gmi.NewRequestTo(url, host) os.Exit(1)
} else {
req, err = gmi.NewRequest(url)
} }
if len(os.Args) == 3 {
req.Host = os.Args[2]
}
resp, err := do(req, nil)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
if err := sendRequest(req); err != nil { // Handle response
fmt.Println(err) if resp.Status.Class() == gemini.StatusClassSuccess {
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(body))
} else {
fmt.Printf("%d %s\n", resp.Status, resp.Meta)
os.Exit(1) os.Exit(1)
} }
} }

View File

@@ -7,84 +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.(type) { }
case gemini.LineLink: switch line := line.(type) {
link := l.(gemini.LineLink) case gemini.LineLink:
url := html.EscapeString(link.URL) url := html.EscapeString(line.URL)
name := html.EscapeString(link.Name) name := html.EscapeString(line.Name)
if name == "" { if name == "" {
name = url name = url
} }
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name) fmt.Fprintf(h.out, "<p><a href='%s'>%s</a></p>\n", url, name)
case gemini.LinePreformattingToggle: case gemini.LinePreformattingToggle:
pre = !pre h.pre = !h.pre
if pre { if h.pre {
fmt.Fprint(&b, "<pre>\n") fmt.Fprint(h.out, "<pre>\n")
} else { } else {
fmt.Fprint(&b, "</pre>\n") fmt.Fprint(h.out, "</pre>\n")
} }
case gemini.LinePreformattedText: case gemini.LinePreformattedText:
text := string(l.(gemini.LinePreformattedText)) fmt.Fprintf(h.out, "%s\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "%s\n", html.EscapeString(text)) case gemini.LineHeading1:
case gemini.LineHeading1: fmt.Fprintf(h.out, "<h1>%s</h1>\n", html.EscapeString(string(line)))
text := string(l.(gemini.LineHeading1)) case gemini.LineHeading2:
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(text)) fmt.Fprintf(h.out, "<h2>%s</h2>\n", html.EscapeString(string(line)))
case gemini.LineHeading2: case gemini.LineHeading3:
text := string(l.(gemini.LineHeading2)) fmt.Fprintf(h.out, "<h3>%s</h3>\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(text)) case gemini.LineListItem:
case gemini.LineHeading3: fmt.Fprintf(h.out, "<li>%s</li>\n", html.EscapeString(string(line)))
text := string(l.(gemini.LineHeading3)) case gemini.LineQuote:
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(text)) fmt.Fprintf(h.out, "<blockquote>%s</blockquote>\n", html.EscapeString(string(line)))
case gemini.LineListItem: case gemini.LineText:
text := string(l.(gemini.LineListItem)) if line == "" {
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(text)) fmt.Fprint(h.out, "<br>\n")
case gemini.LineQuote: } else {
text := string(l.(gemini.LineQuote)) fmt.Fprintf(h.out, "<p>%s</p>\n", html.EscapeString(string(line)))
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(text)) }
case gemini.LineText: }
text := string(l.(gemini.LineText)) }
if text == "" {
fmt.Fprint(&b, "<br>\n") func (h *HTMLWriter) Finish() {
} else { if h.pre {
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(text)) fmt.Fprint(h.out, "</pre>\n")
} }
} if h.list {
} fmt.Fprint(h.out, "</ul>\n")
if pre { }
fmt.Fprint(&b, "</pre>\n")
}
if list {
fmt.Fprint(&b, "</ul>\n")
}
return b.String()
} }

View File

@@ -1,112 +1,40 @@
// +build ignore // +build ignore
// This example illustrates a Gemini server.
package main package main
import ( import (
"bytes"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509/pkix"
"encoding/pem"
"log" "log"
"os"
"time" "time"
gmi "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini"
) )
func main() { func main() {
var server gmi.Server var server gemini.Server
if err := server.CertificateStore.Load("/var/lib/gemini/certs"); err != nil { server.ReadTimeout = 30 * time.Second
server.WriteTimeout = 1 * time.Minute
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
log.Fatal(err) log.Fatal(err)
} }
server.GetCertificate = func(hostname string, store *gmi.CertificateStore) *tls.Certificate { server.CreateCertificate = func(hostname string) (tls.Certificate, error) {
cert, err := store.Lookup(hostname) return gemini.CreateCertificate(gemini.CertificateOptions{
if err != nil { Subject: pkix.Name{
switch err { CommonName: hostname,
case gmi.ErrCertificateExpired: },
// Generate a new certificate if the current one is expired. DNSNames: []string{hostname},
log.Print("Old certificate expired, creating new one") Duration: 365 * 24 * time.Hour,
fallthrough })
case gmi.ErrCertificateUnknown:
// Generate a certificate if one does not exist.
cert, err := gmi.NewCertificate(hostname, time.Minute)
if err != nil {
// Failed to generate new certificate, abort
return nil
}
// Store and return the new certificate
err = writeCertificate("/var/lib/gemini/certs/"+hostname, cert)
if err != nil {
return nil
}
store.Add(hostname, cert)
return &cert
}
}
return cert
} }
var mux gmi.ServeMux var mux gemini.ServeMux
mux.Handle("/", gmi.FileServer(gmi.Dir("/var/www"))) mux.Handle("/", gemini.FileServer(gemini.Dir("/var/www")))
server.Register("localhost", &mux) server.Register("localhost", &mux)
if err := server.ListenAndServe(); err != nil { if err := server.ListenAndServe(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
// writeCertificate writes the provided certificate and private key
// to path.crt and path.key respectively.
func writeCertificate(path string, cert tls.Certificate) error {
crt, err := marshalX509Certificate(cert.Leaf.Raw)
if err != nil {
return err
}
key, err := marshalPrivateKey(cert.PrivateKey)
if err != nil {
return err
}
// Write the certificate
crtPath := path + ".crt"
crtOut, err := os.OpenFile(crtPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := crtOut.Write(crt); err != nil {
return err
}
// Write the private key
keyPath := path + ".key"
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if _, err := keyOut.Write(key); err != nil {
return err
}
return nil
}
// marshalX509Certificate returns a PEM-encoded version of the given raw certificate.
func marshalX509Certificate(cert []byte) ([]byte, error) {
var b bytes.Buffer
if err := pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: cert}); err != nil {
return nil, err
}
return b.Bytes(), nil
}
// marshalPrivateKey returns PEM encoded versions of the given certificate and private key.
func marshalPrivateKey(priv interface{}) ([]byte, error) {
var b bytes.Buffer
privBytes, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, err
}
if err := pem.Encode(&b, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
return nil, err
}
return b.Bytes(), nil
}

70
examples/stream.go Normal file
View File

@@ -0,0 +1,70 @@
// +build ignore
// This example illustrates a streaming Gemini server.
package main
import (
"context"
"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)
}
}
// stream writes an infinite stream to w.
func stream(w *gemini.ResponseWriter, r *gemini.Request) {
ch := make(chan string)
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
ch <- fmt.Sprint(time.Now().UTC())
}
time.Sleep(time.Second)
}
// Close channel when finished.
// In this example this will never be reached.
close(ch)
}(ctx)
for {
s, ok := <-ch
if !ok {
break
}
fmt.Fprintln(w, s)
if err := w.Flush(); err != nil {
cancel()
return
}
}
}

35
fs.go
View File

@@ -1,17 +1,22 @@
package gemini package gemini
import ( import (
"fmt"
"io" "io"
"mime" "mime"
"os" "os"
"path" "path"
"path/filepath"
) )
func init() { func init() {
// Add Gemini mime types // Add Gemini mime types
mime.AddExtensionType(".gmi", "text/gemini") if err := mime.AddExtensionType(".gmi", "text/gemini"); err != nil {
mime.AddExtensionType(".gemini", "text/gemini") panic(fmt.Errorf("failed to register .gmi extension mimetype: %w", err))
}
if err := mime.AddExtensionType(".gemini", "text/gemini"); err != nil {
panic(fmt.Errorf("failed to register .gemini extension mimetype: %w", err))
}
} }
// FileServer takes a filesystem and returns a Responder which uses that filesystem. // FileServer takes a filesystem and returns a Responder which uses that filesystem.
@@ -25,18 +30,18 @@ type fsHandler struct {
} }
func (fsh fsHandler) Respond(w *ResponseWriter, r *Request) { func (fsh fsHandler) Respond(w *ResponseWriter, r *Request) {
path := path.Clean(r.URL.Path) p := path.Clean(r.URL.Path)
f, err := fsh.Open(path) f, err := fsh.Open(p)
if err != nil { if err != nil {
w.WriteStatus(StatusNotFound) w.Status(StatusNotFound)
return return
} }
// Detect mimetype // Detect mimetype
ext := filepath.Ext(path) ext := path.Ext(p)
mimetype := mime.TypeByExtension(ext) mimetype := mime.TypeByExtension(ext)
w.SetMimetype(mimetype) w.Meta(mimetype)
// Copy file to response writer // Copy file to response writer
io.Copy(w, f) _, _ = io.Copy(w, f)
} }
// TODO: replace with io/fs.FS when available // TODO: replace with io/fs.FS when available
@@ -67,15 +72,15 @@ func (d Dir) Open(name string) (File, error) {
func ServeFile(w *ResponseWriter, fs FS, name string) { func ServeFile(w *ResponseWriter, fs FS, name string) {
f, err := fs.Open(name) f, err := fs.Open(name)
if err != nil { if err != nil {
w.WriteStatus(StatusNotFound) w.Status(StatusNotFound)
return return
} }
// Detect mimetype // Detect mimetype
ext := filepath.Ext(name) ext := path.Ext(name)
mimetype := mime.TypeByExtension(ext) mimetype := mime.TypeByExtension(ext)
w.SetMimetype(mimetype) w.Meta(mimetype)
// Copy file to response writer // Copy file to response writer
io.Copy(w, f) _, _ = io.Copy(w, f)
} }
func openFile(p string) (File, error) { func openFile(p string) (File, error) {
@@ -97,9 +102,9 @@ func openFile(p string) (File, error) {
if stat.Mode().IsRegular() { if stat.Mode().IsRegular() {
return f, nil return f, nil
} }
return nil, ErrNotAFile return nil, os.ErrNotExist
} else if !stat.Mode().IsRegular() { } else if !stat.Mode().IsRegular() {
return nil, ErrNotAFile return nil, os.ErrNotExist
} }
} }
return f, nil return f, nil

138
gemini.go
View File

@@ -1,143 +1,15 @@
package gemini package gemini
import ( import (
"crypto/tls"
"crypto/x509"
"errors" "errors"
"sync"
"time"
) )
// Status codes. var crlf = []byte("\r\n")
type Status int
const (
StatusInput Status = 10
StatusSensitiveInput Status = 11
StatusSuccess Status = 20
StatusRedirect Status = 30
StatusRedirectPermanent Status = 31
StatusTemporaryFailure Status = 40
StatusServerUnavailable Status = 41
StatusCGIError Status = 42
StatusProxyError Status = 43
StatusSlowDown Status = 44
StatusPermanentFailure Status = 50
StatusNotFound Status = 51
StatusGone Status = 52
StatusProxyRequestRefused Status = 53
StatusBadRequest Status = 59
StatusCertificateRequired Status = 60
StatusCertificateNotAuthorized Status = 61
StatusCertificateNotValid Status = 62
)
// Class returns the status class for this status code.
func (s Status) Class() StatusClass {
return StatusClass(s / 10)
}
// StatusMessage returns the status message corresponding to the provided
// status code.
// StatusMessage returns an empty string for input, successs, and redirect
// status codes.
func (s Status) Message() string {
switch s {
case StatusTemporaryFailure:
return "TemporaryFailure"
case StatusServerUnavailable:
return "Server unavailable"
case StatusCGIError:
return "CGI error"
case StatusProxyError:
return "Proxy error"
case StatusSlowDown:
return "Slow down"
case StatusPermanentFailure:
return "PermanentFailure"
case StatusNotFound:
return "Not found"
case StatusGone:
return "Gone"
case StatusProxyRequestRefused:
return "Proxy request refused"
case StatusBadRequest:
return "Bad request"
case StatusCertificateRequired:
return "Certificate required"
case StatusCertificateNotAuthorized:
return "Certificate not authorized"
case StatusCertificateNotValid:
return "Certificate not valid"
}
return ""
}
// Status code categories.
type StatusClass int
const (
StatusClassInput StatusClass = 1
StatusClassSuccess StatusClass = 2
StatusClassRedirect StatusClass = 3
StatusClassTemporaryFailure StatusClass = 4
StatusClassPermanentFailure StatusClass = 5
StatusClassCertificateRequired StatusClass = 6
)
// Errors. // Errors.
var ( var (
ErrInvalidURL = errors.New("gemini: invalid URL") ErrInvalidURL = errors.New("gemini: invalid URL")
ErrInvalidResponse = errors.New("gemini: invalid response") ErrInvalidRequest = errors.New("gemini: invalid request")
ErrCertificateUnknown = errors.New("gemini: unknown certificate") ErrInvalidResponse = errors.New("gemini: invalid response")
ErrCertificateExpired = errors.New("gemini: certificate expired") ErrBodyNotAllowed = errors.New("gemini: response body not allowed")
ErrCertificateNotTrusted = errors.New("gemini: certificate is not trusted")
ErrNotAFile = errors.New("gemini: not a file")
ErrBodyNotAllowed = errors.New("gemini: response status code does not allow for body")
) )
// DefaultClient is the default client. It is used by Send.
//
// On the first request, DefaultClient will load the default list of known hosts.
var DefaultClient Client
var (
crlf = []byte("\r\n")
)
func init() {
DefaultClient.TrustCertificate = func(hostname string, cert *x509.Certificate, knownHosts *KnownHosts) error {
// Load the hosts only once. This is so that the hosts don't have to be loaded
// for those using their own clients.
setupDefaultClientOnce.Do(setupDefaultClient)
return knownHosts.Lookup(hostname, cert)
}
DefaultClient.GetCertificate = func(hostname string, store *CertificateStore) *tls.Certificate {
// If the certificate is in the store, return it
if cert, err := store.Lookup(hostname); err == nil {
return cert
}
// Otherwise, generate a certificate
duration := time.Hour
cert, err := NewCertificate(hostname, duration)
if err != nil {
return nil
}
// Store and return the certificate
store.Add(hostname, cert)
return &cert
}
}
var setupDefaultClientOnce sync.Once
func setupDefaultClient() {
DefaultClient.KnownHosts.LoadDefault()
}
// Send sends a Gemini request and returns a Gemini response.
//
// Send is a wrapper around DefaultClient.Send.
func Send(req *Request) (*Response, error) {
return DefaultClient.Send(req)
}

210
mux.go Normal file
View File

@@ -0,0 +1,210 @@
package gemini
import (
"net/url"
"path"
"sort"
"strings"
"sync"
)
// The following code is modified from the net/http package.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// ServeMux is a Gemini request multiplexer.
// It matches the URL of each incoming request against a list of registered
// patterns and calls the handler for the pattern that
// most closely matches the URL.
//
// Patterns name fixed, rooted paths, like "/favicon.ico",
// or rooted subtrees, like "/images/" (note the trailing slash).
// Longer patterns take precedence over shorter ones, so that
// if there are handlers registered for both "/images/"
// and "/images/thumbnails/", the latter handler will be
// called for paths beginning "/images/thumbnails/" and the
// former will receive requests for any other paths in the
// "/images/" subtree.
//
// Note that since a pattern ending in a slash names a rooted subtree,
// the pattern "/" matches all paths not matched by other registered
// patterns, not just the URL with Path == "/".
//
// If a subtree has been registered and a request is received naming the
// subtree root without its trailing slash, ServeMux redirects that
// request to the subtree root (adding the trailing slash). This behavior can
// be overridden with a separate registration for the path without
// the trailing slash. For example, registering "/images/" causes ServeMux
// to redirect a request for "/images" to "/images/", unless "/images" has
// been registered separately.
//
// ServeMux also takes care of sanitizing the URL request path and
// redirecting any request containing . or .. elements or repeated slashes
// to an equivalent, cleaner URL.
type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
}
type muxEntry struct {
r Responder
pattern string
}
// cleanPath returns the canonical path for p, eliminating . and .. elements.
func cleanPath(p string) string {
if p == "" {
return "/"
}
if p[0] != '/' {
p = "/" + p
}
np := path.Clean(p)
// path.Clean removes trailing slash except for root;
// put the trailing slash back if necessary.
if p[len(p)-1] == '/' && np != "/" {
// Fast path for common case of p being the string we want:
if len(p) == len(np)+1 && strings.HasPrefix(p, np) {
np = p
} else {
np += "/"
}
}
return np
}
// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) Responder {
// Check for exact match first.
v, ok := mux.m[path]
if ok {
return v.r
}
// Check for longest valid match. mux.es contains all patterns
// that end in / sorted from longest to shortest.
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.r
}
}
return nil
}
// redirectToPathSlash determines if the given path needs appending "/" to it.
// This occurs when a handler for path + "/" was already registered, but
// not for path itself. If the path needs appending to, it creates a new
// URL, setting the path to u.Path + "/" and returning true to indicate so.
func (mux *ServeMux) redirectToPathSlash(path string, u *url.URL) (*url.URL, bool) {
mux.mu.RLock()
shouldRedirect := mux.shouldRedirectRLocked(path)
mux.mu.RUnlock()
if !shouldRedirect {
return u, false
}
path = path + "/"
u = &url.URL{Path: path, RawQuery: u.RawQuery}
return u, true
}
// shouldRedirectRLocked reports whether the given path and host should be redirected to
// path+"/". This should happen if a handler is registered for path+"/" but
// not path -- see comments at ServeMux.
func (mux *ServeMux) shouldRedirectRLocked(path string) bool {
if _, exist := mux.m[path]; exist {
return false
}
n := len(path)
if n == 0 {
return false
}
if _, exist := mux.m[path+"/"]; exist {
return path[n-1] != '/'
}
return false
}
// Respond dispatches the request to the responder whose
// pattern most closely matches the request URL.
func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
path := cleanPath(r.URL.Path)
// If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(path, r.URL); ok {
w.Header(StatusRedirect, u.String())
return
}
if path != r.URL.Path {
u := *r.URL
u.Path = path
w.Header(StatusRedirect, u.String())
return
}
mux.mu.RLock()
defer mux.mu.RUnlock()
resp := mux.match(path)
if resp == nil {
w.Status(StatusNotFound)
return
}
resp.Respond(w, r)
}
// Handle registers the responder for the given pattern.
// If a responder already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, responder Responder) {
mux.mu.Lock()
defer mux.mu.Unlock()
if pattern == "" {
panic("gemini: invalid pattern")
}
if responder == nil {
panic("gemini: nil responder")
}
if _, exist := mux.m[pattern]; exist {
panic("gemini: multiple registrations for " + pattern)
}
if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{responder, pattern}
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
}
func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].pattern) < len(e.pattern)
})
if i == n {
return append(es, e)
}
// we now know that i points at where we want to insert
es = append(es, muxEntry{}) // try to grow the slice in place, any entry works.
copy(es[i+1:], es[i:]) // move shorter entries down
es[i] = e
return es
}
// HandleFunc registers the responder function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, responder func(*ResponseWriter, *Request)) {
if responder == nil {
panic("gemini: nil responder")
}
mux.Handle(pattern, ResponderFunc(responder))
}

18
query.go Normal file
View File

@@ -0,0 +1,18 @@
package gemini
import (
"net/url"
"strings"
)
// 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.
func QueryEscape(query string) string {
return strings.ReplaceAll(url.PathEscape(query), "+", "%2B")
}
// QueryUnescape is identical to url.PathUnescape.
func QueryUnescape(query string) (string, error) {
return url.PathUnescape(query)
}

View File

@@ -2,7 +2,9 @@ package gemini
import ( import (
"bufio" "bufio"
"context"
"crypto/tls" "crypto/tls"
"io"
"net" "net"
"net/url" "net/url"
) )
@@ -14,32 +16,31 @@ 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
// Certificate specifies the TLS certificate to use for the request. // Certificate specifies the TLS certificate to use for the request.
// Request certificates take precedence over client certificates. //
// This field is ignored by the server. // On the server side, if the client provided a certificate then
// Certificate.Leaf is guaranteed to be non-nil.
Certificate *tls.Certificate Certificate *tls.Certificate
// 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
}
// hostname returns the host without the port. // Context specifies the context to use for client requests.
func hostname(host string) string { // If Context is nil, the background context will be used.
hostname, _, err := net.SplitHostPort(host) Context context.Context
if err != nil {
return host
}
return hostname
} }
// NewRequest returns a new request. The host is inferred from the URL. // NewRequest returns a new request. The host is inferred from the URL.
@@ -48,40 +49,58 @@ func NewRequest(rawurl string) (*Request, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return NewRequestFromURL(u) return NewRequestFromURL(u), nil
} }
// NewRequestFromURL returns a new request for the given URL. // NewRequestFromURL returns a new request for the given URL.
// The host is inferred from the URL. // The host is inferred from the URL.
func NewRequestFromURL(url *url.URL) (*Request, error) { //
// If there is no port, use the default port of 1965 // Callers should be careful that the URL query is properly escaped.
// See the documentation for QueryEscape for more information.
func NewRequestFromURL(url *url.URL) *Request {
host := url.Host host := url.Host
if url.Port() == "" { if url.Port() == "" {
host += ":1965" host += ":1965"
} }
return &Request{ return &Request{
Host: host,
URL: url, URL: url,
}, nil Host: host,
}
} }
// NewRequestTo returns a new request for the provided URL to the provided host. // ReadRequest reads a Gemini request from the provided io.Reader
// The host must contain a port. func ReadRequest(r io.Reader) (*Request, error) {
func NewRequestTo(rawurl, host string) (*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) u, err := url.Parse(rawurl)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if u.User != nil {
return &Request{ // User is not allowed
Host: host, return nil, ErrInvalidURL
URL: u, }
}, nil return &Request{URL: u}, nil
} }
// write writes the Gemini request to the provided buffered writer. // Write writes the Gemini request to the provided buffered writer.
func (r *Request) write(w *bufio.Writer) error { 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

@@ -3,39 +3,42 @@ package gemini
import ( import (
"bufio" "bufio"
"crypto/tls" "crypto/tls"
"io/ioutil" "io"
"strconv" "strconv"
) )
// Response is a Gemini response. // Response is a Gemini response.
type Response struct { type Response struct {
// Status represents the response status. // Status contains the response status code.
Status Status Status Status
// Meta contains more information related to the response status. // Meta contains more information related to the response status.
// For successful responses, Meta should contain the mimetype of the response. // For successful responses, Meta should contain the media type of the response.
// For failure responses, Meta should contain a short description of the failure. // For failure responses, Meta should contain a short description of the failure.
// Meta should not be longer than 1024 bytes. // Meta should not be longer than 1024 bytes.
Meta string Meta string
// Body contains the response body. // Body contains the response body for successful responses.
Body []byte Body io.ReadCloser
// 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 buffered reader. // ReadResponse reads a Gemini response from the provided io.ReadCloser.
func (resp *Response) read(r *bufio.Reader) error { func ReadResponse(rc io.ReadCloser) (*Response, error) {
resp := &Response{}
br := bufio.NewReader(rc)
// Read the status // Read the status
statusB := make([]byte, 2) statusB := make([]byte, 2)
if _, err := r.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)
@@ -43,43 +46,160 @@ func (resp *Response) read(r *bufio.Reader) 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 := r.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 := r.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
if statusClass == StatusClassSuccess && meta == "" {
meta = "text/gemini; charset=utf-8"
} }
resp.Meta = meta resp.Meta = meta
// Read terminating newline // Read terminating newline
if b, err := r.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
} }
// Read response body
if resp.Status.Class() == StatusClassSuccess { if resp.Status.Class() == StatusClassSuccess {
var err error resp.Body = newReadCloserBody(br, rc)
resp.Body, err = ioutil.ReadAll(r) } else {
if err != nil { rc.Close()
return err }
return resp, nil
}
type readCloserBody struct {
br *bufio.Reader // used until empty
io.ReadCloser
}
func newReadCloserBody(br *bufio.Reader, rc io.ReadCloser) io.ReadCloser {
body := &readCloserBody{ReadCloser: rc}
if br.Buffered() != 0 {
body.br = br
}
return body
}
func (b *readCloserBody) Read(p []byte) (n int, err error) {
if b.br != nil {
if n := b.br.Buffered(); len(p) > n {
p = p[:n]
}
n, err = b.br.Read(p)
if b.br.Buffered() == 0 {
b.br = nil
}
return n, err
}
return b.ReadCloser.Read(p)
}
// ResponseWriter is used to construct a Gemini response.
type ResponseWriter struct {
b *bufio.Writer
status Status
meta string
setHeader bool
wroteHeader bool
bodyAllowed bool
}
// NewResponseWriter returns a ResponseWriter that uses the provided io.Writer.
func NewResponseWriter(w io.Writer) *ResponseWriter {
return &ResponseWriter{
b: bufio.NewWriter(w),
}
}
// Header sets the response header.
func (w *ResponseWriter) Header(status Status, meta string) {
w.status = status
w.meta = meta
}
// Status sets the response status code.
// It also sets the response meta to status.Meta().
func (w *ResponseWriter) Status(status Status) {
w.status = status
w.meta = status.Meta()
}
// Meta sets the response meta.
//
// For successful responses, meta should contain the media type of the response.
// For failure responses, meta should contain a short description of the failure.
// The response meta should not be greater than 1024 bytes.
func (w *ResponseWriter) Meta(meta string) {
w.meta = meta
}
// 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
// ErrBodyNotAllowed.
//
// Write writes the response header if it has not already been written.
// It writes a successful status code if one is not set.
func (w *ResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
w.writeHeader(StatusSuccess)
}
if !w.bodyAllowed {
return 0, ErrBodyNotAllowed
}
return w.b.Write(b)
}
func (w *ResponseWriter) writeHeader(defaultStatus Status) {
status := w.status
if status == 0 {
status = defaultStatus
}
meta := w.meta
if status.Class() == StatusClassSuccess {
w.bodyAllowed = true
if meta == "" {
meta = "text/gemini"
} }
} }
return nil
w.b.WriteString(strconv.Itoa(int(status)))
w.b.WriteByte(' ')
w.b.WriteString(meta)
w.b.Write(crlf)
w.wroteHeader = true
}
// Flush writes any buffered data to the underlying io.Writer.
//
// Flush writes the response header if it has not already been written.
// It writes a failure status code if one is not set.
func (w *ResponseWriter) Flush() error {
if !w.wroteHeader {
w.writeHeader(StatusTemporaryFailure)
}
// Write errors from writeHeader will be returned here.
return w.b.Flush()
} }

491
server.go
View File

@@ -1,17 +1,11 @@
package gemini package gemini
import ( import (
"bufio"
"crypto/tls" "crypto/tls"
"crypto/x509" "errors"
"log" "log"
"net" "net"
"net/url"
"path"
"sort"
"strconv"
"strings" "strings"
"sync"
"time" "time"
) )
@@ -21,28 +15,40 @@ type Server struct {
// If Addr is empty, the server will listen on the address ":1965". // If Addr is empty, the server will listen on the address ":1965".
Addr string Addr string
// CertificateStore contains the certificates used by the server. // ReadTimeout is the maximum duration for reading a request.
CertificateStore CertificateStore ReadTimeout time.Duration
// GetCertificate, if not nil, will be called to retrieve the certificate // WriteTimeout is the maximum duration before timing out
// to use for a given hostname. // writes of the response.
// If the certificate is nil, the connection will be aborted. WriteTimeout time.Duration
GetCertificate func(hostname string, store *CertificateStore) *tls.Certificate
// Certificates contains the certificates used by the server.
Certificates CertificateDir
// CreateCertificate, if not nil, will be called to create a new certificate
// if the current one is expired or missing.
CreateCertificate func(hostname string) (tls.Certificate, error)
// ErrorLog specifies an optional logger for errors accepting connections
// and file system errors.
// If nil, logging is done via the log package's standard logger.
ErrorLog *log.Logger
// registered responders // registered responders
responders map[responderKey]Responder responders map[responderKey]Responder
hosts map[string]bool
} }
type responderKey struct { type responderKey struct {
scheme string scheme string
hostname string hostname string
wildcard bool
} }
// Register registers a responder for the given pattern. // Register registers a responder for the given pattern.
// Patterns must be in the form of scheme://hostname (e.g. gemini://example.com). //
// If no scheme is specified, a default scheme of gemini:// is assumed. // Patterns must be in the form of "hostname" or "scheme://hostname".
// Wildcard patterns are supported (e.g. *.example.com). // If no scheme is specified, a scheme of "gemini://" is implied.
// Wildcard patterns are supported (e.g. "*.example.com").
func (s *Server) Register(pattern string, responder Responder) { func (s *Server) Register(pattern string, responder Responder) {
if pattern == "" { if pattern == "" {
panic("gemini: invalid pattern") panic("gemini: invalid pattern")
@@ -52,6 +58,7 @@ func (s *Server) Register(pattern string, responder Responder) {
} }
if s.responders == nil { if s.responders == nil {
s.responders = map[responderKey]Responder{} s.responders = map[responderKey]Responder{}
s.hosts = map[string]bool{}
} }
split := strings.SplitN(pattern, "://", 2) split := strings.SplitN(pattern, "://", 2)
@@ -63,13 +70,12 @@ func (s *Server) Register(pattern string, responder Responder) {
key.scheme = "gemini" key.scheme = "gemini"
key.hostname = split[0] key.hostname = split[0]
} }
split = strings.SplitN(key.hostname, ".", 2)
if len(split) == 2 && split[0] == "*" {
key.hostname = split[1]
key.wildcard = true
}
if _, ok := s.responders[key]; ok {
panic("gemini: multiple registrations for " + pattern)
}
s.responders[key] = responder s.responders[key] = responder
s.hosts[key.hostname] = true
} }
// RegisterFunc registers a responder function for the given pattern. // RegisterFunc registers a responder function for the given pattern.
@@ -90,18 +96,11 @@ func (s *Server) ListenAndServe() error {
} }
defer ln.Close() defer ln.Close()
config := &tls.Config{ return s.Serve(tls.NewListener(ln, &tls.Config{
ClientAuth: tls.RequestClientCert, ClientAuth: tls.RequestClientCert,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
GetCertificate: func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { GetCertificate: s.getCertificate,
if s.GetCertificate != nil { }))
return s.GetCertificate(h.ServerName, &s.CertificateStore), nil
}
return s.CertificateStore.Lookup(h.ServerName)
},
}
tlsListener := tls.NewListener(ln, config)
return s.Serve(tlsListener)
} }
// Serve listens for requests on the provided listener. // Serve listens for requests on the provided listener.
@@ -121,7 +120,7 @@ func (s *Server) Serve(l net.Listener) error {
if max := 1 * time.Second; tempDelay > max { if max := 1 * time.Second; tempDelay > max {
tempDelay = max tempDelay = max
} }
log.Printf("gemini: Accept error: %v; retrying in %v", err, tempDelay) s.logf("gemini: Accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay) time.Sleep(tempDelay)
continue continue
} }
@@ -135,384 +134,114 @@ func (s *Server) Serve(l net.Listener) error {
} }
} }
func (s *Server) getCertificate(h *tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := s.getCertificateFor(h.ServerName)
if err != nil {
// Try wildcard
wildcard := strings.SplitN(h.ServerName, ".", 2)
if len(wildcard) == 2 {
cert, err = s.getCertificateFor("*." + wildcard[1])
}
}
return cert, err
}
func (s *Server) getCertificateFor(hostname string) (*tls.Certificate, error) {
if _, ok := s.hosts[hostname]; !ok {
return nil, errors.New("hostname not registered")
}
// Generate a new certificate if it is missing or expired
cert, ok := s.Certificates.Lookup(hostname)
if !ok || cert.Leaf != nil && cert.Leaf.NotAfter.Before(time.Now()) {
if s.CreateCertificate != nil {
cert, err := s.CreateCertificate(hostname)
if err == nil {
s.Certificates.Add(hostname, cert)
if err := s.Certificates.Write(hostname, cert); err != nil {
s.logf("gemini: Failed to write new certificate for %s: %s", hostname, err)
}
}
return &cert, err
}
return nil, errors.New("no certificate")
}
return &cert, nil
}
// respond responds to a connection. // respond responds to a connection.
func (s *Server) respond(conn net.Conn) { func (s *Server) respond(conn net.Conn) {
r := bufio.NewReader(conn) defer conn.Close()
w := newResponseWriter(conn) if d := s.ReadTimeout; d != 0 {
// Read requested URL _ = conn.SetReadDeadline(time.Now().Add(d))
rawurl, err := r.ReadString('\r') }
if d := s.WriteTimeout; d != 0 {
_ = conn.SetWriteDeadline(time.Now().Add(d))
}
w := NewResponseWriter(conn)
defer func() {
_ = w.Flush()
}()
req, err := ReadRequest(conn)
if err != nil { if err != nil {
w.Status(StatusBadRequest)
return return
} }
// Read terminating line feed
if b, err := r.ReadByte(); err != nil { // Store information about the TLS connection
if tlsConn, ok := conn.(*tls.Conn); ok {
req.TLS = tlsConn.ConnectionState()
if len(req.TLS.PeerCertificates) > 0 {
peerCert := req.TLS.PeerCertificates[0]
// Store the TLS certificate
req.Certificate = &tls.Certificate{
Certificate: [][]byte{peerCert.Raw},
Leaf: peerCert,
}
}
}
resp := s.responder(req)
if resp == nil {
w.Status(StatusNotFound)
return return
} else if b != '\n' {
w.WriteHeader(StatusBadRequest, "Bad request")
} }
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1] resp.Respond(w, req)
// Ensure URL is valid
if len(rawurl) > 1024 {
w.WriteHeader(StatusBadRequest, "Bad request")
} 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.WriteHeader(StatusBadRequest, "Bad request")
} else {
// If no scheme is specified, assume a default scheme of gemini://
if url.Scheme == "" {
url.Scheme = "gemini"
}
req := &Request{
URL: url,
RemoteAddr: conn.RemoteAddr(),
TLS: conn.(*tls.Conn).ConnectionState(),
}
resp := s.responder(req)
if resp != nil {
resp.Respond(w, req)
} else {
w.WriteStatus(StatusNotFound)
}
}
w.b.Flush()
conn.Close()
} }
func (s *Server) responder(r *Request) Responder { func (s *Server) responder(r *Request) Responder {
if h, ok := s.responders[responderKey{r.URL.Scheme, r.URL.Hostname(), false}]; ok { if h, ok := s.responders[responderKey{r.URL.Scheme, r.URL.Hostname()}]; ok {
return h return h
} }
wildcard := strings.SplitN(r.URL.Hostname(), ".", 2) wildcard := strings.SplitN(r.URL.Hostname(), ".", 2)
if len(wildcard) == 2 { if len(wildcard) == 2 {
if h, ok := s.responders[responderKey{r.URL.Scheme, wildcard[1], true}]; ok { if h, ok := s.responders[responderKey{r.URL.Scheme, "*." + wildcard[1]}]; ok {
return h return h
} }
} }
return nil return nil
} }
// ResponseWriter is used by a Gemini handler to construct a Gemini response. func (s *Server) logf(format string, args ...interface{}) {
type ResponseWriter struct { if s.ErrorLog != nil {
b *bufio.Writer s.ErrorLog.Printf(format, args...)
bodyAllowed bool } else {
wroteHeader bool log.Printf(format, args...)
mimetype string
}
func newResponseWriter(conn net.Conn) *ResponseWriter {
return &ResponseWriter{
b: bufio.NewWriter(conn),
} }
} }
// WriteHeader writes the response header.
// If the header has already been written, WriteHeader does nothing.
//
// Meta contains more information related to the response status.
// For successful responses, Meta should contain the mimetype of the response.
// For failure responses, Meta should contain a short description of the failure.
// Meta should not be longer than 1024 bytes.
func (w *ResponseWriter) WriteHeader(status Status, meta string) {
if w.wroteHeader {
return
}
w.b.WriteString(strconv.Itoa(int(status)))
w.b.WriteByte(' ')
w.b.WriteString(meta)
w.b.Write(crlf)
// Only allow body to be written on successful status codes.
if status.Class() == StatusClassSuccess {
w.bodyAllowed = true
}
w.wroteHeader = true
}
// WriteStatus writes the response header with the given status code.
func (w *ResponseWriter) WriteStatus(status Status) {
w.WriteHeader(status, status.Message())
}
// SetMimetype sets the mimetype that will be written for a successful response.
// The provided mimetype will only be used if Write is called without calling
// WriteHeader.
// If the mimetype is not set, it will default to "text/gemini".
func (w *ResponseWriter) SetMimetype(mimetype string) {
w.mimetype = mimetype
}
// Write writes the response body.
// If the response status does not allow for a response body, Write returns
// ErrBodyNotAllowed.
//
// If WriteHeader has not yet been called, Write calls
// WriteHeader(StatusSuccess, mimetype) where mimetype is the mimetype set in
// SetMimetype. If no mimetype is set, a default of "text/gemini" will be used.
func (w *ResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
mimetype := w.mimetype
if mimetype == "" {
mimetype = "text/gemini"
}
w.WriteHeader(StatusSuccess, mimetype)
}
if !w.bodyAllowed {
return 0, ErrBodyNotAllowed
}
return w.b.Write(b)
}
// 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.
Respond(*ResponseWriter, *Request) Respond(*ResponseWriter, *Request)
} }
// Input returns the request query.
// If no input is provided, it responds with StatusInput.
func Input(w *ResponseWriter, r *Request, prompt string) (string, bool) {
if r.URL.ForceQuery || r.URL.RawQuery != "" {
return r.URL.RawQuery, true
}
w.WriteHeader(StatusInput, prompt)
return "", false
}
// SensitiveInput returns the request query.
// If no input is provided, it responds with StatusSensitiveInput.
func SensitiveInput(w *ResponseWriter, r *Request, prompt string) (string, bool) {
if r.URL.ForceQuery || r.URL.RawQuery != "" {
return r.URL.RawQuery, true
}
w.WriteHeader(StatusSensitiveInput, prompt)
return "", false
}
// Redirect replies to the request with a redirect to the given URL.
func Redirect(w *ResponseWriter, url string) {
w.WriteHeader(StatusRedirect, url)
}
// PermanentRedirect replies to the request with a permanent redirect to the given URL.
func PermanentRedirect(w *ResponseWriter, url string) {
w.WriteHeader(StatusRedirectPermanent, url)
}
// Certificate returns the request certificate. If one is not provided,
// it returns nil and responds with StatusCertificateRequired.
func Certificate(w *ResponseWriter, r *Request) (*x509.Certificate, bool) {
if len(r.TLS.PeerCertificates) == 0 {
w.WriteStatus(StatusCertificateRequired)
return nil, false
}
return r.TLS.PeerCertificates[0], true
}
// ResponderFunc is a wrapper around a bare function that implements Responder. // ResponderFunc is a wrapper around a bare function that implements Responder.
type ResponderFunc func(*ResponseWriter, *Request) 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)
} }
// The following code is modified from the net/http package.
// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// ServeMux is a Gemini request multiplexer.
// It matches the URL of each incoming request against a list of registered
// patterns and calls the handler for the pattern that
// most closely matches the URL.
//
// Patterns name fixed, rooted paths, like "/favicon.ico",
// or rooted subtrees, like "/images/" (note the trailing slash).
// Longer patterns take precedence over shorter ones, so that
// if there are handlers registered for both "/images/"
// and "/images/thumbnails/", the latter handler will be
// called for paths beginning "/images/thumbnails/" and the
// former will receive requests for any other paths in the
// "/images/" subtree.
//
// Note that since a pattern ending in a slash names a rooted subtree,
// the pattern "/" matches all paths not matched by other registered
// patterns, not just the URL with Path == "/".
//
// If a subtree has been registered and a request is received naming the
// subtree root without its trailing slash, ServeMux redirects that
// request to the subtree root (adding the trailing slash). This behavior can
// be overridden with a separate registration for the path without
// the trailing slash. For example, registering "/images/" causes ServeMux
// to redirect a request for "/images" to "/images/", unless "/images" has
// been registered separately.
//
// ServeMux also takes care of sanitizing the URL request path and
// redirecting any request containing . or .. elements or repeated slashes
// to an equivalent, cleaner URL.
type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
}
type muxEntry struct {
r Responder
pattern string
}
// cleanPath returns the canonical path for p, eliminating . and .. elements.
func cleanPath(p string) string {
if p == "" {
return "/"
}
if p[0] != '/' {
p = "/" + p
}
np := path.Clean(p)
// path.Clean removes trailing slash except for root;
// put the trailing slash back if necessary.
if p[len(p)-1] == '/' && np != "/" {
// Fast path for common case of p being the string we want:
if len(p) == len(np)+1 && strings.HasPrefix(p, np) {
np = p
} else {
np += "/"
}
}
return np
}
// Find a handler on a handler map given a path string.
// Most-specific (longest) pattern wins.
func (mux *ServeMux) match(path string) Responder {
// Check for exact match first.
v, ok := mux.m[path]
if ok {
return v.r
}
// Check for longest valid match. mux.es contains all patterns
// that end in / sorted from longest to shortest.
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.r
}
}
return nil
}
// redirectToPathSlash determines if the given path needs appending "/" to it.
// This occurs when a handler for path + "/" was already registered, but
// not for path itself. If the path needs appending to, it creates a new
// URL, setting the path to u.Path + "/" and returning true to indicate so.
func (mux *ServeMux) redirectToPathSlash(path string, u *url.URL) (*url.URL, bool) {
mux.mu.RLock()
shouldRedirect := mux.shouldRedirectRLocked(path)
mux.mu.RUnlock()
if !shouldRedirect {
return u, false
}
path = path + "/"
u = &url.URL{Path: path, RawQuery: u.RawQuery}
return u, true
}
// shouldRedirectRLocked reports whether the given path and host should be redirected to
// path+"/". This should happen if a handler is registered for path+"/" but
// not path -- see comments at ServeMux.
func (mux *ServeMux) shouldRedirectRLocked(path string) bool {
if _, exist := mux.m[path]; exist {
return false
}
n := len(path)
if n == 0 {
return false
}
if _, exist := mux.m[path+"/"]; exist {
return path[n-1] != '/'
}
return false
}
// Respond dispatches the request to the responder whose
// pattern most closely matches the request URL.
func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
path := cleanPath(r.URL.Path)
// If the given path is /tree and its handler is not registered,
// redirect for /tree/.
if u, ok := mux.redirectToPathSlash(path, r.URL); ok {
Redirect(w, u.String())
return
}
if path != r.URL.Path {
u := *r.URL
u.Path = path
Redirect(w, u.String())
return
}
mux.mu.RLock()
defer mux.mu.RUnlock()
resp := mux.match(path)
if resp == nil {
w.WriteStatus(StatusNotFound)
return
}
resp.Respond(w, r)
}
// Handle registers the responder for the given pattern.
// If a responder already exists for pattern, Handle panics.
func (mux *ServeMux) Handle(pattern string, responder Responder) {
mux.mu.Lock()
defer mux.mu.Unlock()
if pattern == "" {
panic("gemini: invalid pattern")
}
if responder == nil {
panic("gemini: nil responder")
}
if _, exist := mux.m[pattern]; exist {
panic("gemini: multiple registrations for " + pattern)
}
if mux.m == nil {
mux.m = make(map[string]muxEntry)
}
e := muxEntry{responder, pattern}
mux.m[pattern] = e
if pattern[len(pattern)-1] == '/' {
mux.es = appendSorted(mux.es, e)
}
}
func appendSorted(es []muxEntry, e muxEntry) []muxEntry {
n := len(es)
i := sort.Search(n, func(i int) bool {
return len(es[i].pattern) < len(e.pattern)
})
if i == n {
return append(es, e)
}
// we now know that i points at where we want to insert
es = append(es, muxEntry{}) // try to grow the slice in place, any entry works.
copy(es[i+1:], es[i:]) // move shorter entries down
es[i] = e
return es
}
// HandleFunc registers the responder function for the given pattern.
func (mux *ServeMux) HandleFunc(pattern string, responder func(*ResponseWriter, *Request)) {
if responder == nil {
panic("gemini: nil responder")
}
mux.Handle(pattern, ResponderFunc(responder))
}

77
status.go Normal file
View File

@@ -0,0 +1,77 @@
package gemini
// Status codes.
type Status int
const (
StatusInput Status = 10
StatusSensitiveInput Status = 11
StatusSuccess Status = 20
StatusRedirect Status = 30
StatusPermanentRedirect Status = 31
StatusTemporaryFailure Status = 40
StatusServerUnavailable Status = 41
StatusCGIError Status = 42
StatusProxyError Status = 43
StatusSlowDown Status = 44
StatusPermanentFailure Status = 50
StatusNotFound Status = 51
StatusGone Status = 52
StatusProxyRequestRefused Status = 53
StatusBadRequest Status = 59
StatusCertificateRequired Status = 60
StatusCertificateNotAuthorized Status = 61
StatusCertificateNotValid Status = 62
)
// Status code categories.
type StatusClass int
const (
StatusClassInput StatusClass = 1
StatusClassSuccess StatusClass = 2
StatusClassRedirect StatusClass = 3
StatusClassTemporaryFailure StatusClass = 4
StatusClassPermanentFailure StatusClass = 5
StatusClassCertificateRequired StatusClass = 6
)
// Class returns the status class for this status code.
func (s Status) Class() StatusClass {
return StatusClass(s / 10)
}
// Meta returns a description of the status code appropriate for use in a response.
//
// Meta returns an empty string for input, success, and redirect status codes.
func (s Status) Meta() string {
switch s {
case StatusTemporaryFailure:
return "Temporary failure"
case StatusServerUnavailable:
return "Server unavailable"
case StatusCGIError:
return "CGI error"
case StatusProxyError:
return "Proxy error"
case StatusSlowDown:
return "Slow down"
case StatusPermanentFailure:
return "Permanent failure"
case StatusNotFound:
return "Not found"
case StatusGone:
return "Gone"
case StatusProxyRequestRefused:
return "Proxy request refused"
case StatusBadRequest:
return "Bad request"
case StatusCertificateRequired:
return "Certificate required"
case StatusCertificateNotAuthorized:
return "Certificate not authorized"
case StatusCertificateNotValid:
return "Certificate not valid"
}
return ""
}

90
text.go
View File

@@ -87,58 +87,70 @@ func (l LineText) line() {}
// Text represents a Gemini text response. // Text represents a Gemini text response.
type Text []Line type Text []Line
// Parse parses Gemini text from the provided io.Reader. // ParseText parses Gemini text from the provided io.Reader.
func Parse(r io.Reader) Text { func ParseText(r io.Reader) (Text, error) {
const spacetab = " \t"
var t Text var t Text
err := ParseLines(r, func(line Line) {
t = append(t, line)
})
return t, err
}
// ParseLines parses Gemini text from the provided io.Reader.
// It calls handler with each line that it parses.
func ParseLines(r io.Reader, handler func(Line)) error {
const spacetab = " \t"
var pre bool var pre bool
scanner := bufio.NewScanner(r) scanner := bufio.NewScanner(r)
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() var line Line
if strings.HasPrefix(line, "```") { text := scanner.Text()
if strings.HasPrefix(text, "```") {
pre = !pre pre = !pre
line = line[3:] text = text[3:]
t = append(t, LinePreformattingToggle(line)) line = LinePreformattingToggle(text)
} else if pre { } else if pre {
t = append(t, LinePreformattedText(line)) line = LinePreformattedText(text)
} else if strings.HasPrefix(line, "=>") { } else if strings.HasPrefix(text, "=>") {
line = line[2:] text = text[2:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
split := strings.IndexAny(line, spacetab) split := strings.IndexAny(text, spacetab)
if split == -1 { if split == -1 {
// line is a URL // text is a URL
t = append(t, LineLink{URL: line}) line = LineLink{URL: text}
} else { } else {
url := line[:split] url := text[:split]
name := line[split:] name := text[split:]
name = strings.TrimLeft(name, spacetab) name = strings.TrimLeft(name, spacetab)
t = append(t, LineLink{url, name}) line = LineLink{url, name}
} }
} else if strings.HasPrefix(line, "*") { } else if strings.HasPrefix(text, "*") {
line = line[1:] text = text[1:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineListItem(line)) line = LineListItem(text)
} else if strings.HasPrefix(line, "###") { } else if strings.HasPrefix(text, "###") {
line = line[3:] text = text[3:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineHeading3(line)) line = LineHeading3(text)
} else if strings.HasPrefix(line, "##") { } else if strings.HasPrefix(text, "##") {
line = line[2:] text = text[2:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineHeading2(line)) line = LineHeading2(text)
} else if strings.HasPrefix(line, "#") { } else if strings.HasPrefix(text, "#") {
line = line[1:] text = text[1:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineHeading1(line)) line = LineHeading1(text)
} else if strings.HasPrefix(line, ">") { } else if strings.HasPrefix(text, ">") {
line = line[1:] text = text[1:]
line = strings.TrimLeft(line, spacetab) text = strings.TrimLeft(text, spacetab)
t = append(t, LineQuote(line)) line = LineQuote(text)
} else { } else {
t = append(t, LineText(line)) line = LineText(text)
} }
handler(line)
} }
return t
return scanner.Err()
} }
// String writes the Gemini text response to a string and returns it. // String writes the Gemini text response to a string and returns it.

197
tofu.go
View File

@@ -1,197 +0,0 @@
package gemini
import (
"bufio"
"bytes"
"crypto/sha512"
"crypto/x509"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// KnownHosts represents a list of known hosts.
// The zero value for KnownHosts is an empty list ready to use.
type KnownHosts struct {
hosts map[string]certInfo
file *os.File
}
// LoadDefault loads the known hosts from the default known hosts path, which is
// $XDG_DATA_HOME/gemini/known_hosts.
// It creates the path and any of its parent directories if they do not exist.
// KnownHosts will append to the file whenever a certificate is added.
func (k *KnownHosts) LoadDefault() error {
path, err := defaultKnownHostsPath()
if err != nil {
return err
}
return k.Load(path)
}
// Load loads the known hosts from the provided path.
// It creates the path and any of its parent directories if they do not exist.
// KnownHosts will append to the file whenever a certificate is added.
func (k *KnownHosts) Load(path string) error {
if dir := filepath.Dir(path); dir != "." {
err := os.MkdirAll(dir, 0755)
if err != nil {
return err
}
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0644)
if err != nil {
return err
}
k.Parse(f)
f.Close()
// 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.file = f
return nil
}
// Add adds a certificate to the list of known hosts.
// If KnownHosts was loaded from a file, Add will append to the file.
func (k *KnownHosts) Add(hostname string, cert *x509.Certificate) {
k.add(hostname, cert, true)
}
// AddTemporary adds a certificate to the list of known hosts
// without writing it to the known hosts file.
func (k *KnownHosts) AddTemporary(hostname string, cert *x509.Certificate) {
k.add(hostname, cert, false)
}
func (k *KnownHosts) add(hostname string, cert *x509.Certificate, write bool) {
if k.hosts == nil {
k.hosts = map[string]certInfo{}
}
info := certInfo{
Algorithm: "SHA-512",
Fingerprint: Fingerprint(cert),
Expires: cert.NotAfter.Unix(),
}
k.hosts[hostname] = info
// Append to the file
if write && k.file != nil {
appendKnownHost(k.file, hostname, info)
}
}
// Lookup looks for the provided certificate in the list of known hosts.
// If the hostname is in the list, but the fingerprint differs,
// Lookup returns ErrCertificateNotTrusted.
// If the hostname is not in the list, Lookup returns ErrCertificateUnknown.
// If the certificate is found and the fingerprint matches, error will be nil.
func (k *KnownHosts) Lookup(hostname string, cert *x509.Certificate) error {
now := time.Now().Unix()
fingerprint := Fingerprint(cert)
if c, ok := k.hosts[hostname]; ok {
if c.Expires <= now {
// Certificate is expired
return ErrCertificateUnknown
}
if c.Fingerprint != fingerprint {
// Fingerprint does not match
return ErrCertificateNotTrusted
}
// Certificate is trusted
return nil
}
return ErrCertificateUnknown
}
// Parse parses the provided reader and adds the parsed known hosts to the list.
// Invalid lines are ignored.
func (k *KnownHosts) Parse(r io.Reader) {
if k.hosts == nil {
k.hosts = map[string]certInfo{}
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
if len(parts) < 4 {
continue
}
hostname := parts[0]
algorithm := parts[1]
if algorithm != "SHA-512" {
continue
}
fingerprint := parts[2]
expires, err := strconv.ParseInt(parts[3], 10, 0)
if err != nil {
continue
}
k.hosts[hostname] = certInfo{
Algorithm: algorithm,
Fingerprint: fingerprint,
Expires: expires,
}
}
}
// Write writes the known hosts to the provided io.Writer.
func (k *KnownHosts) Write(w io.Writer) {
for h, c := range k.hosts {
appendKnownHost(w, h, c)
}
}
type certInfo struct {
Algorithm string // fingerprint algorithm e.g. SHA-512
Fingerprint string // fingerprint in hexadecimal, with ':' between each octet
Expires int64 // unix time of certificate notAfter date
}
func appendKnownHost(w io.Writer, hostname string, c certInfo) (int, error) {
return fmt.Fprintf(w, "%s %s %s %d\n", hostname, c.Algorithm, c.Fingerprint, c.Expires)
}
// Fingerprint returns the SHA-512 fingerprint of the provided certificate.
func Fingerprint(cert *x509.Certificate) string {
sum512 := sha512.Sum512(cert.Raw)
var buf bytes.Buffer
for i, f := range sum512 {
if i > 0 {
fmt.Fprintf(&buf, ":")
}
fmt.Fprintf(&buf, "%02X", f)
}
return buf.String()
}
// defaultKnownHostsPath returns the default known_hosts path.
// The default path is $XDG_DATA_HOME/gemini/known_hosts
func defaultKnownHostsPath() (string, error) {
dataDir, err := userDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "gemini", "known_hosts"), nil
}
// userDataDir returns the user data directory.
func userDataDir() (string, error) {
dataDir, ok := os.LookupEnv("XDG_DATA_HOME")
if ok {
return dataDir, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share"), nil
}

158
tofu/tofu.go Normal file
View File

@@ -0,0 +1,158 @@
// Package tofu implements trust on first use using hosts and fingerprints.
package tofu
import (
"bufio"
"crypto/sha512"
"fmt"
"io"
"os"
"strconv"
"strings"
"sync"
"time"
)
// KnownHosts maps hosts to fingerprints.
type KnownHosts map[string]Fingerprint
// KnownHostsFile represents a list of known hosts optionally loaded from a file.
// The zero value for KnownHostsFile represents an empty list ready to use.
//
// KnownHostsFile is safe for concurrent use by multiple goroutines.
type KnownHostsFile struct {
KnownHosts
out io.Writer
mu sync.RWMutex
}
// SetOutput sets the output to which new known hosts will be written to.
func (k *KnownHostsFile) SetOutput(w io.Writer) {
k.mu.Lock()
defer k.mu.Unlock()
k.out = w
}
// Add adds a known host to the list of known hosts.
func (k *KnownHostsFile) Add(hostname string, fingerprint Fingerprint) {
k.mu.Lock()
defer k.mu.Unlock()
if k.KnownHosts == nil {
k.KnownHosts = KnownHosts{}
}
k.KnownHosts[hostname] = fingerprint
}
// Lookup returns the fingerprint of the certificate corresponding to
// the given hostname.
func (k *KnownHostsFile) Lookup(hostname string) (Fingerprint, bool) {
k.mu.RLock()
defer k.mu.RUnlock()
c, ok := k.KnownHosts[hostname]
return c, ok
}
// Write writes a known hosts entry to the configured output.
func (k *KnownHostsFile) Write(hostname string, fingerprint Fingerprint) error {
k.mu.RLock()
defer k.mu.RUnlock()
if k.out != nil {
_, err := k.writeKnownHost(k.out, hostname, fingerprint)
if err != nil {
return fmt.Errorf("failed to write to known host file: %w", err)
}
}
return nil
}
// WriteAll writes all of the known hosts to the provided io.Writer.
func (k *KnownHostsFile) WriteAll(w io.Writer) error {
k.mu.RLock()
defer k.mu.RUnlock()
for h, c := range k.KnownHosts {
if _, err := k.writeKnownHost(w, h, c); err != nil {
return err
}
}
return nil
}
// writeKnownHost writes a known host to the provided io.Writer.
func (k *KnownHostsFile) writeKnownHost(w io.Writer, hostname string, f Fingerprint) (int, error) {
return fmt.Fprintf(w, "%s %s %s %d\n", hostname, f.Algorithm, f.Hex, f.Expires.Unix())
}
// Load loads the known hosts from the provided path.
// It creates the file if it does not exist.
// New known hosts will be appended to the file.
func (k *KnownHostsFile) Load(path string) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return err
}
k.Parse(f)
k.SetOutput(f)
return nil
}
// Parse parses the provided reader and adds the parsed known hosts to the list.
// Invalid entries are ignored.
func (k *KnownHostsFile) Parse(r io.Reader) {
k.mu.Lock()
defer k.mu.Unlock()
if k.KnownHosts == nil {
k.KnownHosts = map[string]Fingerprint{}
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
if len(parts) < 4 {
continue
}
hostname := parts[0]
algorithm := parts[1]
if algorithm != "SHA-512" {
continue
}
hex := parts[2]
unix, err := strconv.ParseInt(parts[3], 10, 0)
if err != nil {
continue
}
expires := time.Unix(unix, 0)
k.KnownHosts[hostname] = Fingerprint{
Algorithm: algorithm,
Hex: hex,
Expires: expires,
}
}
}
// Fingerprint represents a fingerprint using a certain algorithm.
type Fingerprint struct {
Algorithm string // fingerprint algorithm e.g. SHA-512
Hex string // fingerprint in hexadecimal, with ':' between each octet
Expires time.Time // unix time of the fingerprint expiration date
}
// NewFingerprint returns the SHA-512 fingerprint of the provided raw data.
func NewFingerprint(raw []byte, expires time.Time) Fingerprint {
sum512 := sha512.Sum512(raw)
var b strings.Builder
for i, f := range sum512 {
if i > 0 {
b.WriteByte(':')
}
fmt.Fprintf(&b, "%02X", f)
}
return Fingerprint{
Algorithm: "SHA-512",
Hex: b.String(),
Expires: expires,
}
}