39 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
20 changed files with 573 additions and 527 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.

15
cert.go
View File

@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -23,15 +24,20 @@ 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 *CertificateDir) Add(scope string, cert tls.Certificate) {
c.mu.Lock()
defer c.mu.Unlock()
if c.CertificateStore == nil {
c.CertificateStore = CertificateStore{}
}
@@ -47,6 +53,8 @@ func (c *CertificateDir) Add(scope string, cert tls.Certificate) {
// Write writes the provided certificate to the certificate directory.
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, "/", ":")
@@ -61,6 +69,8 @@ func (c *CertificateDir) Write(scope string, cert tls.Certificate) error {
// Lookup returns the certificate for the given 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
}
@@ -87,13 +97,14 @@ func (c *CertificateDir) Load(path string) error {
scope = strings.ReplaceAll(scope, ":", "/")
c.Add(scope, cert)
}
c.dir = true
c.path = path
c.SetDir(path)
return nil
}
// 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
}

208
client.go
View File

@@ -2,26 +2,24 @@ package gemini
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/url"
"path"
"strings"
"sync"
"time"
)
// Client is a Gemini client.
//
// Clients are safe for concurrent use by multiple goroutines.
type Client struct {
// KnownHosts is a list of known hosts.
KnownHosts KnownHostsFile
// Certificates stores client-side certificates.
Certificates CertificateDir
// 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
@@ -30,43 +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
mu sync.Mutex
}
// Get performs a Gemini request for the given url.
// Get performs a Gemini request for the given URL.
func (c *Client) Get(url string) (*Response, error) {
req, err := NewRequest(url)
if err != nil {
@@ -77,13 +41,6 @@ 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) {
c.mu.Lock()
defer c.mu.Unlock()
return c.do(req, nil)
}
func (c *Client) do(req *Request, via []*Request) (*Response, error) {
// Extract hostname
colonPos := strings.LastIndex(req.Host, ":")
if colonPos == -1 {
@@ -96,123 +53,59 @@ func (c *Client) do(req *Request, via []*Request) (*Response, error) {
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,
}
netConn, err := (&net.Dialer{}).DialContext(req.Context, "tcp", req.Host)
// Set connection context
ctx := req.Context
if ctx == nil {
ctx = context.Background()
}
netConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", req.Host)
if err != nil {
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)
c.Certificates.Write(hostname+path, cert)
req.Certificate = &cert
return c.do(req, via)
}
return resp, nil
case resp.Status.Class() == StatusClassInput:
if c.GetInput != nil {
input, ok := c.GetInput(resp.Meta, resp.Status == StatusSensitiveInput)
if ok {
req.URL.ForceQuery = true
req.URL.RawQuery = QueryEscape(input)
return c.do(req, via)
}
}
return resp, nil
case resp.Status.Class() == StatusClassRedirect:
if via == nil {
via = []*Request{}
}
via = append(via, req)
target, err := url.Parse(resp.Meta)
if err != nil {
return resp, err
}
target = req.URL.ResolveReference(target)
redirect := NewRequestFromURL(target)
redirect.Context = req.Context
if c.CheckRedirect != nil {
if err := c.CheckRedirect(redirect, via); err != nil {
return resp, err
}
return c.do(redirect, via)
}
}
return resp, nil
}
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
@@ -225,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().Before(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

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

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 (

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)

View File

@@ -5,8 +5,9 @@ import (
"strings"
)
// QueryEscape properly 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.
// 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")
}

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"crypto/tls"
"io"
"net"
"net/url"
)
@@ -15,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.
@@ -27,16 +28,18 @@ 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.
// Context must not be nil.
// If Context is nil, the background context will be used.
Context context.Context
}
@@ -60,14 +63,44 @@ func NewRequestFromURL(url *url.URL) *Request {
host += ":1965"
}
return &Request{
URL: url,
Host: host,
Context: context.Background(),
URL: url,
Host: host,
}
}
// 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()
}

160
server.go
View File

@@ -1,13 +1,10 @@
package gemini
import (
"bufio"
"crypto/tls"
"errors"
"log"
"net"
"net/url"
"strconv"
"strings"
"time"
)
@@ -174,62 +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 {
// 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 {
@@ -253,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.
@@ -335,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.

View File

@@ -1,4 +1,5 @@
package gemini
// Package tofu implements trust on first use using hosts and fingerprints.
package tofu
import (
"bufio"
@@ -8,35 +9,34 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
)
// Trust represents the trustworthiness of a certificate.
type Trust int
const (
TrustNone Trust = iota // The certificate is not trusted.
TrustOnce // The certificate is trusted once.
TrustAlways // The certificate is trusted always.
)
// KnownHosts maps hosts to fingerprints.
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{}
}
@@ -46,19 +46,30 @@ func (k *KnownHostsFile) Add(hostname string, fingerprint 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) {
func (k *KnownHostsFile) Write(hostname string, fingerprint Fingerprint) error {
k.mu.RLock()
defer k.mu.RUnlock()
if k.out != nil {
k.writeKnownHost(k.out, hostname, fingerprint)
_, 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
@@ -76,24 +87,20 @@ func (k *KnownHostsFile) writeKnownHost(w io.Writer, hostname string, f Fingerpr
// 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_RDONLY, 0644)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 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
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{}
}