53 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
21 changed files with 776 additions and 754 deletions

View File

@@ -1,6 +1,6 @@
# 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.

54
cert.go
View File

@@ -15,22 +15,31 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// CertificateStore maps certificate scopes to certificates.
// The zero value of CertificateStore is an empty store ready to use.
type CertificateStore struct {
store map[string]tls.Certificate
dir bool
path string
// CertificateDir maps certificate scopes to certificates.
type CertificateStore 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 scope to the store.
// It tries to parse the certificate if it is not already parsed.
func (c *CertificateStore) Add(scope string, cert tls.Certificate) {
if c.store == nil {
c.store = map[string]tls.Certificate{}
func (c *CertificateDir) Add(scope string, cert tls.Certificate) {
c.mu.Lock()
defer c.mu.Unlock()
if c.CertificateStore == nil {
c.CertificateStore = CertificateStore{}
}
// Parse certificate if not already parsed
if cert.Leaf == nil {
@@ -39,12 +48,16 @@ func (c *CertificateStore) Add(scope string, cert tls.Certificate) {
cert.Leaf = parsed
}
}
c.store[scope] = cert
c.CertificateStore[scope] = cert
}
// Write writes the provided certificate to the certificate directory.
func (c *CertificateStore) Write(scope string, cert tls.Certificate) error {
func (c *CertificateDir) Write(scope string, cert tls.Certificate) error {
c.mu.RLock()
defer c.mu.RUnlock()
if c.dir {
// 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 {
@@ -55,8 +68,10 @@ func (c *CertificateStore) Write(scope string, cert tls.Certificate) error {
}
// Lookup returns the certificate for the given scope.
func (c *CertificateStore) Lookup(scope string) (tls.Certificate, bool) {
cert, ok := c.store[scope]
func (c *CertificateDir) Lookup(scope string) (tls.Certificate, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
cert, ok := c.CertificateStore[scope]
return cert, ok
}
@@ -66,7 +81,7 @@ func (c *CertificateStore) Lookup(scope string) (tls.Certificate, bool) {
// For example, the hostname "localhost" would have the corresponding files
// localhost.crt (certificate) and localhost.key (private key).
// New certificates will be written to this directory.
func (c *CertificateStore) Load(path string) error {
func (c *CertificateDir) Load(path string) error {
matches, err := filepath.Glob(filepath.Join(path, "*.crt"))
if err != nil {
return err
@@ -78,15 +93,18 @@ func (c *CertificateStore) Load(path string) error {
continue
}
scope := strings.TrimSuffix(filepath.Base(crtPath), ".crt")
// Unescape slash character
scope = strings.ReplaceAll(scope, ":", "/")
c.Add(scope, cert)
}
c.dir = true
c.path = path
c.SetDir(path)
return nil
}
// SetOutput sets the directory that new certificates will be written to.
func (c *CertificateStore) SetOutput(path string) {
// SetDir sets the directory that new certificates will be written to.
func (c *CertificateDir) SetDir(path string) {
c.mu.Lock()
defer c.mu.Unlock()
c.dir = true
c.path = path
}

205
client.go
View File

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

50
doc.go Normal file
View File

@@ -0,0 +1,50 @@
/*
Package gemini implements the Gemini protocol.
Client is a Gemini client.
client := &gemini.Client{}
resp, err := client.Get("gemini://example.com")
if err != nil {
// handle error
}
if resp.Body != nil {
defer resp.Body.Close()
// ...
}
// ...
Server is a Gemini server.
server := &gemini.Server{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
Servers should be configured with certificates:
err := server.Certificates.Load("/var/lib/gemini/certs")
if err != nil {
// handle error
}
Servers can accept requests for multiple hosts and schemes:
server.RegisterFunc("example.com", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.com")
})
server.RegisterFunc("example.org", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.org")
})
server.RegisterFunc("http://example.net", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Proxied content from http://example.net")
})
To start the server, call ListenAndServe:
err := server.ListenAndServe()
if err != nil {
// handle error
}
*/
package gemini

View File

@@ -3,6 +3,7 @@
package main
import (
"crypto/sha512"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
@@ -13,35 +14,19 @@ import (
"git.sr.ht/~adnano/go-gemini"
)
type user struct {
password string // TODO: use hashes
admin bool
}
type session struct {
username string
authorized bool // whether or not the password was supplied
type User struct {
Name string
}
var (
// Map of usernames to user data
logins = 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{}
// Map of certificate hashes to users
users = map[string]*User{}
)
func main() {
var mux gemini.ServeMux
mux.HandleFunc("/", login)
mux.HandleFunc("/password", loginPassword)
mux.HandleFunc("/profile", profile)
mux.HandleFunc("/admin", admin)
mux.HandleFunc("/logout", logout)
mux.HandleFunc("/", profile)
mux.HandleFunc("/username", changeUsername)
var server gemini.Server
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
@@ -63,65 +48,9 @@ func main() {
}
}
func getSession(cert *x509.Certificate) (*session, bool) {
fingerprint := gemini.NewFingerprint(cert.Raw, cert.NotAfter)
session, ok := sessions[fingerprint.Hex]
return session, ok
}
func login(w *gemini.ResponseWriter, r *gemini.Request) {
if r.Certificate == nil {
w.WriteStatus(gemini.StatusCertificateRequired)
return
}
username, ok := gemini.Input(r)
if !ok {
w.WriteHeader(gemini.StatusInput, "Username")
return
}
cert := r.Certificate.Leaf
fingerprint := gemini.NewFingerprint(cert.Raw, cert.NotAfter)
sessions[fingerprint.Hex] = &session{
username: username,
}
w.WriteHeader(gemini.StatusRedirect, "/password")
}
func loginPassword(w *gemini.ResponseWriter, r *gemini.Request) {
if r.Certificate == nil {
w.WriteStatus(gemini.StatusCertificateRequired)
return
}
session, ok := getSession(r.Certificate.Leaf)
if !ok {
w.WriteStatus(gemini.StatusCertificateNotAuthorized)
return
}
password, ok := gemini.Input(r)
if !ok {
w.WriteHeader(gemini.StatusSensitiveInput, "Password")
return
}
expected := logins[session.username].password
if password == expected {
session.authorized = true
w.WriteHeader(gemini.StatusRedirect, "/profile")
} else {
w.WriteHeader(gemini.StatusSensitiveInput, "Password")
}
}
func logout(w *gemini.ResponseWriter, r *gemini.Request) {
if r.Certificate == nil {
w.WriteStatus(gemini.StatusCertificateRequired)
return
}
cert := r.Certificate.Leaf
fingerprint := gemini.NewFingerprint(cert.Raw, cert.NotAfter)
delete(sessions, fingerprint.Hex)
fmt.Fprintln(w, "Successfully logged out.")
fmt.Fprintln(w, "=> / Index")
func fingerprint(cert *x509.Certificate) string {
b := sha512.Sum512(cert.Raw)
return string(b[:])
}
func profile(w *gemini.ResponseWriter, r *gemini.Request) {
@@ -129,31 +58,33 @@ func profile(w *gemini.ResponseWriter, r *gemini.Request) {
w.WriteStatus(gemini.StatusCertificateRequired)
return
}
session, ok := getSession(r.Certificate.Leaf)
fingerprint := fingerprint(r.Certificate.Leaf)
user, ok := users[fingerprint]
if !ok {
w.WriteStatus(gemini.StatusCertificateNotAuthorized)
return
user = &User{}
users[fingerprint] = user
}
user := logins[session.username]
fmt.Fprintln(w, "Username:", session.username)
fmt.Fprintln(w, "Admin:", user.admin)
fmt.Fprintln(w, "=> /logout Logout")
fmt.Fprintln(w, "Username:", user.Name)
fmt.Fprintln(w, "=> /username Change username")
}
func admin(w *gemini.ResponseWriter, r *gemini.Request) {
func changeUsername(w *gemini.ResponseWriter, r *gemini.Request) {
if r.Certificate == nil {
w.WriteStatus(gemini.StatusCertificateRequired)
return
}
session, ok := getSession(r.Certificate.Leaf)
username, err := gemini.QueryUnescape(r.URL.RawQuery)
if err != nil || username == "" {
w.WriteHeader(gemini.StatusInput, "Username")
return
}
fingerprint := fingerprint(r.Certificate.Leaf)
user, ok := users[fingerprint]
if !ok {
w.WriteStatus(gemini.StatusCertificateNotAuthorized)
return
user = &User{}
users[fingerprint] = user
}
user := logins[session.username]
if !user.admin {
w.WriteStatus(gemini.StatusCertificateNotAuthorized)
return
}
fmt.Fprintln(w, "Welcome to the admin portal.")
user.Name = username
w.WriteHeader(gemini.StatusRedirect, "/")
}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
// +build ignore
// This example illustrates a Gemini server.
package main
import (
@@ -24,7 +26,7 @@ func main() {
CommonName: hostname,
},
DNSNames: []string{hostname},
Duration: time.Minute, // for testing purposes
Duration: 365 * 24 * time.Hour,
})
}

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
}
}
}

22
fs.go
View File

@@ -1,6 +1,7 @@
package gemini
import (
"fmt"
"io"
"mime"
"os"
@@ -9,8 +10,13 @@ import (
func init() {
// Add Gemini mime types
mime.AddExtensionType(".gmi", "text/gemini")
mime.AddExtensionType(".gemini", "text/gemini")
if err := mime.AddExtensionType(".gmi", "text/gemini"); err != nil {
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.
@@ -27,15 +33,15 @@ func (fsh fsHandler) Respond(w *ResponseWriter, r *Request) {
p := path.Clean(r.URL.Path)
f, err := fsh.Open(p)
if err != nil {
w.WriteStatus(StatusNotFound)
w.Status(StatusNotFound)
return
}
// Detect mimetype
ext := path.Ext(p)
mimetype := mime.TypeByExtension(ext)
w.SetMediaType(mimetype)
w.Meta(mimetype)
// Copy file to response writer
io.Copy(w, f)
_, _ = io.Copy(w, f)
}
// TODO: replace with io/fs.FS when available
@@ -66,15 +72,15 @@ func (d Dir) Open(name string) (File, error) {
func ServeFile(w *ResponseWriter, fs FS, name string) {
f, err := fs.Open(name)
if err != nil {
w.WriteStatus(StatusNotFound)
w.Status(StatusNotFound)
return
}
// Detect mimetype
ext := path.Ext(name)
mimetype := mime.TypeByExtension(ext)
w.SetMediaType(mimetype)
w.Meta(mimetype)
// Copy file to response writer
io.Copy(w, f)
_, _ = io.Copy(w, f)
}
func openFile(p string) (File, error) {

View File

@@ -1,52 +1,3 @@
/*
Package gemini implements the Gemini protocol.
Client is a Gemini client.
client := &gemini.Client{}
resp, err := client.Get("gemini://example.com")
if err != nil {
// handle error
}
if resp.Status.Class() == gemini.StatusClassSucess {
defer resp.Body.Close()
// ...
}
// ...
Server is a Gemini server.
server := &gemini.Server{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
Servers should be configured with certificates:
err := server.Certificates.Load("/var/lib/gemini/certs")
if err != nil {
// handle error
}
Servers can accept requests for multiple hosts and schemes:
server.RegisterFunc("example.com", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.com")
})
server.RegisterFunc("example.org", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Welcome to example.org")
})
server.RegisterFunc("http://example.net", func(w *gemini.ResponseWriter, r *gemini.Request) {
fmt.Fprint(w, "Proxied content from http://example.net")
})
To start the server, call ListenAndServe:
err := server.ListenAndServe()
if err != nil {
// handle error
}
*/
package gemini
import (
@@ -58,6 +9,7 @@ var crlf = []byte("\r\n")
// Errors.
var (
ErrInvalidURL = errors.New("gemini: invalid URL")
ErrInvalidRequest = errors.New("gemini: invalid request")
ErrInvalidResponse = errors.New("gemini: invalid response")
ErrBodyNotAllowed = errors.New("gemini: response body not allowed")
)

6
mux.go
View File

@@ -138,14 +138,14 @@ func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
// 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.WriteHeader(StatusRedirect, u.String())
w.Header(StatusRedirect, u.String())
return
}
if path != r.URL.Path {
u := *r.URL
u.Path = path
w.WriteHeader(StatusRedirect, u.String())
w.Header(StatusRedirect, u.String())
return
}
@@ -154,7 +154,7 @@ func (mux *ServeMux) Respond(w *ResponseWriter, r *Request) {
resp := mux.match(path)
if resp == nil {
w.WriteStatus(StatusNotFound)
w.Status(StatusNotFound)
return
}
resp.Respond(w, r)

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 (
"bufio"
"context"
"crypto/tls"
"io"
"net"
"net/url"
)
@@ -14,11 +16,11 @@ type Request struct {
// For client requests, Host specifies the host on which the URL is sought.
// Host must contain a port.
//
// This field is ignored by the server.
Host string
// Certificate specifies the TLS certificate to use for the request.
// Request certificates take precedence over client certificates.
//
// On the server side, if the client provided a certificate then
// Certificate.Leaf is guaranteed to be non-nil.
@@ -26,13 +28,19 @@ type Request struct {
// RemoteAddr allows servers and other software to record the network
// address that sent the request.
//
// This field is ignored by the client.
RemoteAddr net.Addr
// TLS allows servers and other software to record information about the TLS
// connection on which the request was received.
//
// This field is ignored by the client.
TLS tls.ConnectionState
// Context specifies the context to use for client requests.
// If Context is nil, the background context will be used.
Context context.Context
}
// NewRequest returns a new request. The host is inferred from the URL.
@@ -46,6 +54,9 @@ func NewRequest(rawurl string) (*Request, error) {
// NewRequestFromURL returns a new request for the given URL.
// The host is inferred from the URL.
//
// 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
if url.Port() == "" {
@@ -57,8 +68,39 @@ func NewRequestFromURL(url *url.URL) *Request {
}
}
// write writes the Gemini request to the provided buffered writer.
func (r *Request) write(w *bufio.Writer) error {
// ReadRequest reads a Gemini request from the provided io.Reader
func ReadRequest(r io.Reader) (*Request, error) {
// Read URL
br := bufio.NewReader(r)
rawurl, err := br.ReadString('\r')
if err != nil {
return nil, err
}
// Read terminating line feed
if b, err := br.ReadByte(); err != nil {
return nil, err
} else if b != '\n' {
return nil, ErrInvalidRequest
}
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1]
// Validate URL
if len(rawurl) > 1024 {
return nil, ErrInvalidRequest
}
u, err := url.Parse(rawurl)
if err != nil {
return nil, err
}
if u.User != nil {
// User is not allowed
return nil, ErrInvalidURL
}
return &Request{URL: u}, nil
}
// Write writes the Gemini request to the provided buffered writer.
func (r *Request) Write(w *bufio.Writer) error {
url := r.URL.String()
// User is invalid
if r.URL.User != nil || len(url) > 1024 {

View File

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

165
server.go
View File

@@ -1,13 +1,10 @@
package gemini
import (
"bufio"
"crypto/tls"
"errors"
"log"
"net"
"net/url"
"strconv"
"strings"
"time"
)
@@ -26,7 +23,7 @@ type Server struct {
WriteTimeout time.Duration
// Certificates contains the certificates used by the server.
Certificates CertificateStore
Certificates CertificateDir
// CreateCertificate, if not nil, will be called to create a new certificate
// if the current one is expired or missing.
@@ -174,67 +171,45 @@ func (s *Server) getCertificateFor(hostname string) (*tls.Certificate, error) {
// respond responds to a connection.
func (s *Server) respond(conn net.Conn) {
defer conn.Close()
if d := s.ReadTimeout; d != 0 {
conn.SetReadDeadline(time.Now().Add(d))
_ = conn.SetReadDeadline(time.Now().Add(d))
}
if d := s.WriteTimeout; d != 0 {
conn.SetWriteDeadline(time.Now().Add(d))
_ = conn.SetWriteDeadline(time.Now().Add(d))
}
r := bufio.NewReader(conn)
w := newResponseWriter(conn)
// Read requested URL
rawurl, err := r.ReadString('\r')
w := NewResponseWriter(conn)
defer func() {
_ = w.Flush()
}()
req, err := ReadRequest(conn)
if err != nil {
w.Status(StatusBadRequest)
return
}
// Read terminating line feed
if b, err := r.ReadByte(); err != nil {
return
} else if b != '\n' {
w.WriteStatus(StatusBadRequest)
}
// Trim carriage return
rawurl = rawurl[:len(rawurl)-1]
// Ensure URL is valid
if len(rawurl) > 1024 {
w.WriteStatus(StatusBadRequest)
} else if url, err := url.Parse(rawurl); err != nil || url.User != nil {
// Note that we return an error status if User is specified in the URL
w.WriteStatus(StatusBadRequest)
} else {
// If no scheme is specified, assume a default scheme of gemini://
if url.Scheme == "" {
url.Scheme = "gemini"
}
// Store information about the TLS connection
connState := conn.(*tls.Conn).ConnectionState()
var cert *tls.Certificate
if len(connState.PeerCertificates) > 0 {
peerCert := connState.PeerCertificates[0]
// 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
cert = &tls.Certificate{
req.Certificate = &tls.Certificate{
Certificate: [][]byte{peerCert.Raw},
Leaf: peerCert,
}
}
req := &Request{
URL: url,
RemoteAddr: conn.RemoteAddr(),
TLS: connState,
Certificate: cert,
}
resp := s.responder(req)
if resp != nil {
resp.Respond(w, req)
} else {
w.WriteStatus(StatusNotFound)
}
}
w.b.Flush()
conn.Close()
resp := s.responder(req)
if resp == nil {
w.Status(StatusNotFound)
return
}
resp.Respond(w, req)
}
func (s *Server) responder(r *Request) Responder {
@@ -258,76 +233,6 @@ func (s *Server) logf(format string, args ...interface{}) {
}
}
// ResponseWriter is used by a Gemini handler to construct a Gemini response.
type ResponseWriter struct {
b *bufio.Writer
bodyAllowed bool
wroteHeader bool
mediatype 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.
//
// WriteStatus is equivalent to WriteHeader(status, status.Message())
func (w *ResponseWriter) WriteStatus(status Status) {
w.WriteHeader(status, status.Message())
}
// SetMediaType sets the media type that will be written for a successful response.
// If the mimetype is not set, it will default to "text/gemini".
func (w *ResponseWriter) SetMediaType(mediatype string) {
w.mediatype = mediatype
}
// Write writes the response body.
// If the response status does not allow for a response body, Write returns
// ErrBodyNotAllowed.
//
// If the response header has not yet been written, Write calls WriteHeader
// with StatusSuccess and the mimetype set in SetMimetype.
func (w *ResponseWriter) Write(b []byte) (int, error) {
if !w.wroteHeader {
mediatype := w.mediatype
if mediatype == "" {
mediatype = "text/gemini"
}
w.WriteHeader(StatusSuccess, mediatype)
}
if !w.bodyAllowed {
return 0, ErrBodyNotAllowed
}
return w.b.Write(b)
}
// A Responder responds to a Gemini request.
type Responder interface {
// Respond accepts a Request and constructs a Response.
@@ -340,23 +245,3 @@ type ResponderFunc func(*ResponseWriter, *Request)
func (f ResponderFunc) Respond(w *ResponseWriter, r *Request) {
f(w, r)
}
// Input returns the request query.
// If the query is invalid or no query is provided, ok will be false.
//
// Example:
//
// input, ok := gemini.Input(req)
// if !ok {
// w.WriteHeader(gemini.StatusInput, "Prompt")
// return
// }
// // ...
//
func Input(r *Request) (query string, ok bool) {
if r.URL.ForceQuery || r.URL.RawQuery != "" {
query, err := url.QueryUnescape(r.URL.RawQuery)
return query, err == nil
}
return "", false
}

View File

@@ -41,19 +41,11 @@ func (s Status) Class() StatusClass {
return StatusClass(s / 10)
}
// Message returns a status message corresponding to this status code.
func (s Status) Message() string {
// 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 StatusInput:
return "Input"
case StatusSensitiveInput:
return "Sensitive input"
case StatusSuccess:
return "Success"
case StatusRedirect:
return "Redirect"
case StatusPermanentRedirect:
return "Permanent redirect"
case StatusTemporaryFailure:
return "Temporary failure"
case StatusServerUnavailable:

10
text.go
View File

@@ -88,17 +88,17 @@ func (l LineText) line() {}
type Text []Line
// ParseText parses Gemini text from the provided io.Reader.
func ParseText(r io.Reader) Text {
func ParseText(r io.Reader) (Text, error) {
var t Text
ParseLines(r, func(line Line) {
err := ParseLines(r, func(line Line) {
t = append(t, line)
})
return t
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)) {
func ParseLines(r io.Reader, handler func(Line)) error {
const spacetab = " \t"
var pre bool
scanner := bufio.NewScanner(r)
@@ -149,6 +149,8 @@ func ParseLines(r io.Reader, handler func(Line)) {
}
handler(line)
}
return scanner.Err()
}
// String writes the Gemini text response to a string and returns it.

147
tofu.go
View File

@@ -1,147 +0,0 @@
package gemini
import (
"bufio"
"crypto/sha512"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
// Trust represents the trustworthiness of a certificate.
type Trust int
const (
TrustNone Trust = iota // The certificate is not trusted.
TrustOnce // The certificate is trusted once.
TrustAlways // The certificate is trusted always.
)
// KnownHosts represents a list of known hosts.
// The zero value for KnownHosts is an empty list ready to use.
type KnownHosts struct {
hosts map[string]Fingerprint
out io.Writer
}
// SetOutput sets the output to which new known hosts will be written to.
func (k *KnownHosts) SetOutput(w io.Writer) {
k.out = w
}
// Add adds a known host to the list of known hosts.
func (k *KnownHosts) Add(hostname string, fingerprint Fingerprint) {
if k.hosts == nil {
k.hosts = map[string]Fingerprint{}
}
k.hosts[hostname] = fingerprint
}
// Lookup returns the fingerprint of the certificate corresponding to
// the given hostname.
func (k *KnownHosts) Lookup(hostname string) (Fingerprint, bool) {
c, ok := k.hosts[hostname]
return c, ok
}
// Write writes a known hosts entry to the configured output.
func (k *KnownHosts) Write(hostname string, fingerprint Fingerprint) {
if k.out != nil {
k.writeKnownHost(k.out, hostname, fingerprint)
}
}
// WriteAll writes all of the known hosts to the provided io.Writer.
func (k *KnownHosts) WriteAll(w io.Writer) error {
for h, c := range k.hosts {
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 *KnownHosts) 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)
}
// 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 *KnownHosts) Load(path string) error {
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.out = f
return nil
}
// Parse parses the provided reader and adds the parsed known hosts to the list.
// Invalid entries are ignored.
func (k *KnownHosts) Parse(r io.Reader) {
if k.hosts == nil {
k.hosts = 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
}
fingerprint := parts[2]
expires, err := strconv.ParseInt(parts[3], 10, 0)
if err != nil {
continue
}
k.hosts[hostname] = Fingerprint{
Algorithm: algorithm,
Hex: fingerprint,
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 int64 // 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.Unix(),
}
}

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,
}
}