Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48fa6a724e | ||
|
|
80ffa72863 | ||
|
|
61b417a5c4 | ||
|
|
a912ef996a | ||
|
|
d9a690a98f | ||
|
|
04bd0f4520 | ||
|
|
d34d5df89e | ||
|
|
decd72cc23 | ||
|
|
c329a2487e | ||
|
|
df1794c803 | ||
|
|
5af1acbd54 | ||
|
|
36c2086c82 | ||
|
|
d52d0af783 | ||
|
|
35836f2ff7 | ||
|
|
824887eab9 | ||
|
|
e2c907a7f6 | ||
|
|
a09cb5a23c | ||
|
|
7ca7053f66 | ||
|
|
ca35aadaea | ||
|
|
805a80dddf | ||
|
|
28c5c857dc | ||
|
|
176b260468 | ||
|
|
a1dd8de337 | ||
|
|
7be0715d39 | ||
|
|
4704b8fbcf | ||
|
|
aeafd57956 | ||
|
|
e687a05170 | ||
|
|
846fa2ac41 |
15
cert.go
15
cert.go
@@ -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
|
||||
}
|
||||
|
||||
193
client.go
193
client.go
@@ -2,26 +2,23 @@ package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"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 +27,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 +40,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 +52,49 @@ 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 {
|
||||
conn.SetDeadline(time.Now().Add(c.Timeout))
|
||||
}
|
||||
|
||||
// Write the request
|
||||
w := bufio.NewWriter(conn)
|
||||
req.write(w)
|
||||
req.Write(w)
|
||||
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 +107,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 c.TrustCertificate(hostname, cert)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("gemini: certificate not trusted")
|
||||
}
|
||||
|
||||
fingerprint := NewFingerprint(cert.Raw, cert.NotAfter)
|
||||
if knownHost.Hex == fingerprint.Hex {
|
||||
return nil
|
||||
}
|
||||
return errors.New("gemini: fingerprint does not match")
|
||||
}
|
||||
|
||||
@@ -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, "/")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// +build ignore
|
||||
|
||||
// This example illustrates a certificate generation tool.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// +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"
|
||||
@@ -17,6 +20,22 @@ import (
|
||||
"git.sr.ht/~adnano/go-xdg"
|
||||
)
|
||||
|
||||
var (
|
||||
hosts gemini.KnownHostsFile
|
||||
scanner *bufio.Scanner
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Load known hosts file
|
||||
path := filepath.Join(xdg.DataHome(), "gemini", "known_hosts")
|
||||
err := hosts.Load(path)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
scanner = bufio.NewScanner(os.Stdin)
|
||||
}
|
||||
|
||||
const trustPrompt = `The certificate offered by %s is of unknown trust. Its fingerprint is:
|
||||
%s
|
||||
|
||||
@@ -26,49 +45,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 {
|
||||
func trustCertificate(hostname string, cert *x509.Certificate) error {
|
||||
fingerprint := gemini.NewFingerprint(cert.Raw, cert.NotAfter)
|
||||
knownHost, ok := hosts.Lookup(hostname)
|
||||
if ok && time.Now().Before(knownHost.Expires) {
|
||||
// Check fingerprint
|
||||
if knownHost.Hex == fingerprint.Hex {
|
||||
return nil
|
||||
}
|
||||
return errors.New("error: fingerprint does not match!")
|
||||
}
|
||||
|
||||
fmt.Printf(trustPrompt, hostname, fingerprint.Hex)
|
||||
scanner.Scan()
|
||||
switch scanner.Text() {
|
||||
case "t":
|
||||
return gemini.TrustAlways
|
||||
hosts.Add(hostname, fingerprint)
|
||||
hosts.Write(hostname, fingerprint)
|
||||
return nil
|
||||
case "o":
|
||||
return gemini.TrustOnce
|
||||
hosts.Add(hostname, fingerprint)
|
||||
return nil
|
||||
default:
|
||||
return gemini.TrustNone
|
||||
return errors.New("certificate not trusted")
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
client.GetInput = func(prompt string, sensitive bool) (string, bool) {
|
||||
fmt.Printf("%s: ", prompt)
|
||||
|
||||
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 +131,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 +146,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: %s\n", resp.Status, resp.Status.Message(), resp.Meta)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
gemini.ParseLines(os.Stdin, hw.Handle)
|
||||
hw.Finish()
|
||||
}
|
||||
|
||||
html := textToHTML(text)
|
||||
fmt.Print(html)
|
||||
type HTMLWriter struct {
|
||||
out io.Writer
|
||||
pre bool
|
||||
list bool
|
||||
}
|
||||
|
||||
// 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")
|
||||
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 list {
|
||||
list = false
|
||||
fmt.Fprint(&b, "</ul>\n")
|
||||
} else if h.list {
|
||||
h.list = false
|
||||
fmt.Fprint(h.out, "</ul>\n")
|
||||
}
|
||||
switch l := l.(type) {
|
||||
switch line := line.(type) {
|
||||
case gemini.LineLink:
|
||||
url := html.EscapeString(l.URL)
|
||||
name := html.EscapeString(l.Name)
|
||||
url := html.EscapeString(line.URL)
|
||||
name := html.EscapeString(line.Name)
|
||||
if name == "" {
|
||||
name = url
|
||||
}
|
||||
fmt.Fprintf(&b, "<p><a href='%s'>%s</a></p>\n", url, name)
|
||||
fmt.Fprintf(h.out, "<p><a href='%s'>%s</a></p>\n", url, name)
|
||||
case gemini.LinePreformattingToggle:
|
||||
pre = !pre
|
||||
if pre {
|
||||
fmt.Fprint(&b, "<pre>\n")
|
||||
h.pre = !h.pre
|
||||
if h.pre {
|
||||
fmt.Fprint(h.out, "<pre>\n")
|
||||
} else {
|
||||
fmt.Fprint(&b, "</pre>\n")
|
||||
fmt.Fprint(h.out, "</pre>\n")
|
||||
}
|
||||
case gemini.LinePreformattedText:
|
||||
fmt.Fprintf(&b, "%s\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "%s\n", html.EscapeString(string(line)))
|
||||
case gemini.LineHeading1:
|
||||
fmt.Fprintf(&b, "<h1>%s</h1>\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "<h1>%s</h1>\n", html.EscapeString(string(line)))
|
||||
case gemini.LineHeading2:
|
||||
fmt.Fprintf(&b, "<h2>%s</h2>\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "<h2>%s</h2>\n", html.EscapeString(string(line)))
|
||||
case gemini.LineHeading3:
|
||||
fmt.Fprintf(&b, "<h3>%s</h3>\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "<h3>%s</h3>\n", html.EscapeString(string(line)))
|
||||
case gemini.LineListItem:
|
||||
fmt.Fprintf(&b, "<li>%s</li>\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "<li>%s</li>\n", html.EscapeString(string(line)))
|
||||
case gemini.LineQuote:
|
||||
fmt.Fprintf(&b, "<blockquote>%s</blockquote>\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "<blockquote>%s</blockquote>\n", html.EscapeString(string(line)))
|
||||
case gemini.LineText:
|
||||
if l == "" {
|
||||
fmt.Fprint(&b, "<br>\n")
|
||||
if line == "" {
|
||||
fmt.Fprint(h.out, "<br>\n")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "<p>%s</p>\n", html.EscapeString(string(l)))
|
||||
fmt.Fprintf(h.out, "<p>%s</p>\n", html.EscapeString(string(line)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if pre {
|
||||
fmt.Fprint(&b, "</pre>\n")
|
||||
|
||||
func (h *HTMLWriter) Finish() {
|
||||
if h.pre {
|
||||
fmt.Fprint(h.out, "</pre>\n")
|
||||
}
|
||||
if list {
|
||||
fmt.Fprint(&b, "</ul>\n")
|
||||
if h.list {
|
||||
fmt.Fprint(h.out, "</ul>\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// +build ignore
|
||||
|
||||
// This example illustrates a Gemini server.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
44
examples/stream.go
Normal file
44
examples/stream.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// +build ignore
|
||||
|
||||
// This example illustrates a streaming Gemini server.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~adnano/go-gemini"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var server gemini.Server
|
||||
if err := server.Certificates.Load("/var/lib/gemini/certs"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server.CreateCertificate = func(hostname string) (tls.Certificate, error) {
|
||||
return gemini.CreateCertificate(gemini.CertificateOptions{
|
||||
Subject: pkix.Name{
|
||||
CommonName: hostname,
|
||||
},
|
||||
DNSNames: []string{hostname},
|
||||
Duration: 365 * 24 * time.Hour,
|
||||
})
|
||||
}
|
||||
|
||||
server.RegisterFunc("localhost", stream)
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stream(w *gemini.ResponseWriter, r *gemini.Request) {
|
||||
for {
|
||||
fmt.Fprintln(w, time.Now().UTC())
|
||||
w.Flush()
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,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")
|
||||
)
|
||||
|
||||
5
query.go
5
query.go
@@ -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")
|
||||
}
|
||||
|
||||
42
request.go
42
request.go
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
)
|
||||
@@ -15,6 +16,7 @@ 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
|
||||
|
||||
@@ -27,16 +29,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
|
||||
}
|
||||
|
||||
@@ -62,12 +66,42 @@ func NewRequestFromURL(url *url.URL) *Request {
|
||||
return &Request{
|
||||
URL: url,
|
||||
Host: host,
|
||||
Context: context.Background(),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
29
response.go
29
response.go
@@ -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,15 @@ 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)
|
||||
}
|
||||
return nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type readCloserBody struct {
|
||||
|
||||
79
server.go
79
server.go
@@ -4,9 +4,9 @@ import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -174,6 +174,7 @@ 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))
|
||||
}
|
||||
@@ -181,46 +182,27 @@ func (s *Server) respond(conn net.Conn) {
|
||||
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 w.b.Flush()
|
||||
|
||||
req, err := ReadRequest(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Read terminating line feed
|
||||
if b, err := r.ReadByte(); err != nil {
|
||||
return
|
||||
} else if b != '\n' {
|
||||
w.WriteStatus(StatusBadRequest)
|
||||
}
|
||||
// Trim carriage return
|
||||
rawurl = rawurl[:len(rawurl)-1]
|
||||
// Ensure URL is valid
|
||||
if len(rawurl) > 1024 {
|
||||
w.WriteStatus(StatusBadRequest)
|
||||
} else if url, err := url.Parse(rawurl); err != nil || url.User != nil {
|
||||
// Note that we return an error status if User is specified in the URL
|
||||
w.WriteStatus(StatusBadRequest)
|
||||
} 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]
|
||||
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)
|
||||
@@ -228,9 +210,6 @@ func (s *Server) respond(conn net.Conn) {
|
||||
w.WriteStatus(StatusNotFound)
|
||||
}
|
||||
}
|
||||
w.b.Flush()
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func (s *Server) responder(r *Request) Responder {
|
||||
if h, ok := s.responders[responderKey{r.URL.Scheme, r.URL.Hostname()}]; ok {
|
||||
@@ -261,9 +240,10 @@ type ResponseWriter struct {
|
||||
mediatype string
|
||||
}
|
||||
|
||||
func newResponseWriter(conn net.Conn) *ResponseWriter {
|
||||
// NewResponseWriter returns a ResponseWriter that uses the provided io.Writer.
|
||||
func NewResponseWriter(w io.Writer) *ResponseWriter {
|
||||
return &ResponseWriter{
|
||||
b: bufio.NewWriter(conn),
|
||||
b: bufio.NewWriter(w),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +283,7 @@ func (w *ResponseWriter) SetMediaType(mediatype string) {
|
||||
w.mediatype = mediatype
|
||||
}
|
||||
|
||||
// Write writes the response body.
|
||||
// Write writes data to the connection as part of the response body.
|
||||
// If the response status does not allow for a response body, Write returns
|
||||
// ErrBodyNotAllowed.
|
||||
//
|
||||
@@ -323,6 +303,11 @@ func (w *ResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.b.Write(b)
|
||||
}
|
||||
|
||||
// Flush writes any buffered data to the underlying io.Writer.
|
||||
func (w *ResponseWriter) Flush() error {
|
||||
return w.b.Flush()
|
||||
}
|
||||
|
||||
// A Responder responds to a Gemini request.
|
||||
type Responder interface {
|
||||
// Respond accepts a Request and constructs a Response.
|
||||
@@ -335,23 +320,3 @@ type ResponderFunc func(*ResponseWriter, *Request)
|
||||
func (f ResponderFunc) Respond(w *ResponseWriter, r *Request) {
|
||||
f(w, r)
|
||||
}
|
||||
|
||||
// Input returns the request query.
|
||||
// If the query is invalid or no query is provided, ok will be false.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// input, ok := gemini.Input(req)
|
||||
// if !ok {
|
||||
// w.WriteHeader(gemini.StatusInput, "Prompt")
|
||||
// return
|
||||
// }
|
||||
// // ...
|
||||
//
|
||||
func Input(r *Request) (query string, ok bool) {
|
||||
if r.URL.ForceQuery || r.URL.RawQuery != "" {
|
||||
query, err := url.QueryUnescape(r.URL.RawQuery)
|
||||
return query, err == nil
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
35
tofu.go
35
tofu.go
@@ -8,35 +8,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,12 +45,16 @@ 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) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
if k.out != nil {
|
||||
k.writeKnownHost(k.out, hostname, fingerprint)
|
||||
}
|
||||
@@ -59,6 +62,8 @@ func (k *KnownHostsFile) Write(hostname string, fingerprint Fingerprint) {
|
||||
|
||||
// WriteAll writes all of the known hosts to the provided io.Writer.
|
||||
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 +81,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{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user