summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.drone.yml9
-rw-r--r--README.md10
-rw-r--r--TODO.md13
-rw-r--r--contrib/fs/dir.go174
-rw-r--r--contrib/fs/file.go55
-rw-r--r--contrib/fs/stat.go28
-rw-r--r--contrib/log/log.go35
-rw-r--r--examples/cowsay/main.go99
-rw-r--r--examples/fileserver/main.go60
-rw-r--r--examples/inspectls/main.go95
-rw-r--r--gemini/handler.go29
-rw-r--r--gemini/request.go50
-rw-r--r--gemini/request_test.go86
-rw-r--r--gemini/response.go308
-rw-r--r--gemini/response_test.go151
-rw-r--r--gemini/serve.go89
-rw-r--r--gemini/tls.go16
-rw-r--r--go.mod7
-rw-r--r--go.sum4
19 files changed, 1318 insertions, 0 deletions
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..f2790c6
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,9 @@
+---
+kind: pipeline
+name: verify
+
+steps:
+ - name: test
+ image: golang
+ commands:
+ - go test -v ./...
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..710b299
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+Gus: The small web application framework
+========================================
+
+Gus is named after Virgil "Gus" Grissom, one of the pilots in the Gemini program and commander of Gemini 3.
+
+Gus is, to my knowledge, the first gemini server conceived as a _framework_. The goal is to provide the go-to set of Go libraries for interacting with the gemini protocol as a server or client, somewhat analagous to net/http in the standard library.
+
+Thus `gus/gemini` defines types such as `Request` and `Response`, useful interfaces such as a `Handler` abstraction, the concept of `Middleware`, and a `Server` which brings together a `net.Listener`, a `tls.Config`, and a `Handler` to actually serve the protocol. It *does not*, however, contain any logic for serving files from the filesystem or things of that nature.
+
+Many of the utilities needed to build an _actually useful_ server are in `gus/contrib` sub-packages, and there are examples of how to compose them in the `examples` directory.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..94f963a
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,13 @@
+- [x] server
+ - [x] TLS configuration from cert+key files
+- [ ] client
+- [x] contrib - filesystem handling
+ - [x] serving files
+ - [x] directory index files
+ - [x] directory listing
+ - [ ] reject symlinks pointing outside fs root
+- [ ] filtering middleware
+- [x] contrib - request logging
+- [ ] contrib - CGI
+- [ ] contrib - shared hosting helper
+- [ ] contrib - TLS auth
diff --git a/contrib/fs/dir.go b/contrib/fs/dir.go
new file mode 100644
index 0000000..b219e22
--- /dev/null
+++ b/contrib/fs/dir.go
@@ -0,0 +1,174 @@
+package fs
+
+import (
+ "bytes"
+ "context"
+ "io/fs"
+ "sort"
+ "strings"
+ "text/template"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+// DirectoryDefault handles directory path requests by looking for specific filenames.
+//
+// If any of the supported filenames are found, the contents of the file is returned
+// as the gemini response.
+//
+// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links into the directory's contents to function.
+//
+// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
+// it will also produce "51 Not Found" responses for directory paths.
+func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ path, dirFile, resp := handleDir(req, fileSystem)
+ if resp != nil {
+ return resp
+ }
+ defer dirFile.Close()
+
+ entries, err := dirFile.ReadDir(0)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ for _, fileName := range fileNames {
+ for _, entry := range entries {
+ if entry.Name() == fileName {
+ file, err := fileSystem.Open(path + "/" + fileName)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ return gemini.Success(mediaType(fileName), file)
+ }
+ }
+ }
+
+ return gemini.NotFound("Resource does not exist.")
+ }
+}
+
+// DirectoryListing produces a gemtext listing of the contents of any requested directories.
+//
+// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
+//
+// When it encounters a directory path which doesn't end in a trailing slash (/) it
+// redirects to a URL with the trailing slash appended. This is necessary for relative
+// links into the directory's contents to function.
+//
+// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
+// it will also produce "51 Not Found" responses for directory paths.
+//
+// The template is provided the following namespace:
+// - .FullPath: the complete path to the listed directory
+// - .DirName: the name of the directory itself
+// - .Entries: the []fs.DirEntry of the directory contents
+//
+// The template argument may be nil, in which case a simple default template is used.
+func DirectoryListing(fileSystem fs.FS, template *template.Template) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ path, dirFile, resp := handleDir(req, fileSystem)
+ if resp != nil {
+ return resp
+ }
+ defer dirFile.Close()
+
+ if template == nil {
+ template = defaultDirListTemplate
+ }
+
+ buf := &bytes.Buffer{}
+
+ environ, err := dirlistNamespace(path, dirFile)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ if err := template.Execute(buf, environ); err != nil {
+ gemini.Failure(err)
+ }
+
+ return gemini.Success("text/gemini", buf)
+ }
+}
+
+var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
+# {{ .DirName }}
+{{ range .Entries }}
+=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
+{{ end }}
+=> ../
+`[1:]))
+
+func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
+ entries, err := dirFile.ReadDir(0)
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Slice(entries, func(i, j int) bool {
+ return entries[i].Name() < entries[j].Name()
+ })
+
+ var dirname string
+ if path == "." {
+ dirname = "(root)"
+ } else {
+ dirname = path[strings.LastIndex(path, "/")+1:]
+ }
+
+ m := map[string]any{
+ "FullPath": path,
+ "DirName": dirname,
+ "Entries": entries,
+ }
+
+ return m, nil
+}
+
+func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gemini.Response) {
+ path := strings.Trim(req.Path, "/")
+ if path == "" {
+ path = "."
+ }
+
+ file, err := fileSystem.Open(path)
+ if isNotFound(err) {
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+ if err != nil {
+ return "", nil, gemini.Failure(err)
+ }
+
+ isDir, err := fileIsDir(file)
+ if err != nil {
+ file.Close()
+ return "", nil, gemini.Failure(err)
+ }
+
+ if !isDir {
+ file.Close()
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+
+ if !strings.HasSuffix(req.Path, "/") {
+ file.Close()
+ url := *req.URL
+ url.Path += "/"
+ return "", nil, gemini.Redirect(url.String())
+ }
+
+ dirFile, ok := file.(fs.ReadDirFile)
+ if !ok {
+ file.Close()
+ return "", nil, gemini.NotFound("Resource does not exist.")
+ }
+
+ return path, dirFile, nil
+}
diff --git a/contrib/fs/file.go b/contrib/fs/file.go
new file mode 100644
index 0000000..cdcd1a9
--- /dev/null
+++ b/contrib/fs/file.go
@@ -0,0 +1,55 @@
+package fs
+
+import (
+ "context"
+ "io/fs"
+ "mime"
+ "strings"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+// FileHandler builds a handler function which serves up a file system.
+func FileHandler(fileSystem fs.FS) gemini.Handler {
+ return func(ctx context.Context, req *gemini.Request) *gemini.Response {
+ file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
+ if isNotFound(err) {
+ return gemini.NotFound("Resource does not exist.")
+ }
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ isDir, err := fileIsDir(file)
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ if isDir {
+ return gemini.NotFound("Resource does not exist.")
+ }
+
+ return gemini.Success(mediaType(req.Path), file)
+ }
+}
+
+func mediaType(filePath string) string {
+ if strings.HasSuffix(filePath, ".gmi") {
+ // This may not be present in the listings searched by mime.TypeByExtension,
+ // so provide a dedicated fast path for it here.
+ return "text/gemini"
+ }
+
+ slashIdx := strings.LastIndex(filePath, "/")
+ dotIdx := strings.LastIndex(filePath[slashIdx+1:], ".")
+ if dotIdx == -1 {
+ return "application/octet-stream"
+ }
+ ext := filePath[slashIdx+dotIdx:]
+
+ mtype := mime.TypeByExtension(ext)
+ if mtype == "" {
+ return "application/octet-stream"
+ }
+ return mtype
+}
diff --git a/contrib/fs/stat.go b/contrib/fs/stat.go
new file mode 100644
index 0000000..4dd65d8
--- /dev/null
+++ b/contrib/fs/stat.go
@@ -0,0 +1,28 @@
+package fs
+
+import (
+ "errors"
+ "io/fs"
+)
+
+func isNotFound(err error) bool {
+ if err == nil {
+ return false
+ }
+
+ var pathErr *fs.PathError
+ if errors.As(err, &pathErr) {
+ e := pathErr.Err
+ return errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist)
+ }
+
+ return false
+}
+
+func fileIsDir(file fs.File) (bool, error) {
+ info, err := file.Stat()
+ if err != nil {
+ return false, err
+ }
+ return info.IsDir(), nil
+}
diff --git a/contrib/log/log.go b/contrib/log/log.go
new file mode 100644
index 0000000..2ccd3bc
--- /dev/null
+++ b/contrib/log/log.go
@@ -0,0 +1,35 @@
+package log
+
+import (
+ "context"
+ "io"
+ "time"
+
+ kitlog "github.com/go-kit/log"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func Requests(out io.Writer, logger kitlog.Logger) gemini.Middleware {
+ if logger == nil {
+ logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))
+ }
+
+ return func(next gemini.Handler) gemini.Handler {
+ return func(ctx context.Context, r *gemini.Request) (resp *gemini.Response) {
+ start := time.Now()
+ defer func() {
+ end := time.Now()
+ logger.Log(
+ "msg", "request",
+ "ts", end,
+ "dur", end.Sub(start),
+ "url", r.URL,
+ "status", resp.Status,
+ )
+ }()
+
+ return next(ctx, r)
+ }
+ }
+}
diff --git a/examples/cowsay/main.go b/examples/cowsay/main.go
new file mode 100644
index 0000000..e724421
--- /dev/null
+++ b/examples/cowsay/main.go
@@ -0,0 +1,99 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/exec"
+
+ guslog "tildegit.org/tjp/gus/contrib/log"
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func main() {
+ // Get TLS files from the environment
+ certfile, keyfile := envConfig()
+
+ // build a TLS configuration suitable for gemini
+ tlsconf, err := gemini.FileTLS(certfile, keyfile)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // set up the network listener
+ listener, err := net.Listen("tcp4", ":1965")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // add request logging to the request handler
+ handler := guslog.Requests(os.Stdout, nil)(cowsayHandler)
+
+ // run the server
+ gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve()
+}
+
+func cowsayHandler(ctx context.Context, req *gemini.Request) *gemini.Response {
+ // prompt for a query if there is none already
+ if req.RawQuery == "" {
+ return gemini.Input("enter a phrase")
+ }
+
+ // find the "cowsay" executable
+ binpath, err := exec.LookPath("cowsay")
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ // build the command and set the query to be passed to its stdin
+ cmd := exec.CommandContext(ctx, binpath)
+ cmd.Stdin = bytes.NewBufferString(req.UnescapedQuery())
+
+ // set up a pipe so we can read the command's stdout
+ rd, err := cmd.StdoutPipe()
+ if err != nil {
+ return gemini.Failure(err)
+ }
+
+ // start the command
+ if err := cmd.Start(); err != nil {
+ return gemini.Failure(err)
+ }
+
+ // read the complete stdout contents, clean up the process on error
+ buf, err := io.ReadAll(rd)
+ if err != nil {
+ cmd.Process.Kill()
+ cmd.Wait()
+ return gemini.Failure(err)
+ }
+
+ // wait for the process to close
+ cmd.Wait()
+
+ // pass the buffer to the response wrapped in ``` toggles,
+ // and include a link to start over
+ out := io.MultiReader(
+ bytes.NewBufferString("```\n"),
+ bytes.NewBuffer(buf),
+ bytes.NewBufferString("\n```\n=> . again"),
+ )
+ return gemini.Success("text/gemini", out)
+}
+
+func envConfig() (string, string) {
+ certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
+ if !ok {
+ log.Fatal("missing SERVER_CERTIFICATE environment variable")
+ }
+
+ keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
+ if !ok {
+ log.Fatal("missing SERVER_PRIVATEKEY environment variable")
+ }
+
+ return certfile, keyfile
+}
diff --git a/examples/fileserver/main.go b/examples/fileserver/main.go
new file mode 100644
index 0000000..01d22ee
--- /dev/null
+++ b/examples/fileserver/main.go
@@ -0,0 +1,60 @@
+package main
+
+import (
+ "context"
+ "log"
+ "net"
+ "os"
+
+ "tildegit.org/tjp/gus/contrib/fs"
+ guslog "tildegit.org/tjp/gus/contrib/log"
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func main() {
+ // Get TLS files from the environment
+ certfile, keyfile := envConfig()
+
+ // build a TLS configuration suitable for gemini
+ tlsconf, err := gemini.FileTLS(certfile, keyfile)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // set up the network listen
+ listener, err := net.Listen("tcp4", ":1965")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // build the request handler
+ fileSystem := os.DirFS(".")
+ // Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
+ handler := gemini.Fallthrough(
+ // first see if they're fetching a directory and we have <dir>/index.gmi
+ fs.DirectoryDefault(fileSystem, "index.gmi"),
+ // next (still if they requested a directory) build a directory listing response
+ fs.DirectoryListing(fileSystem, nil),
+ // finally, try to find a file at the request path and respond with that
+ fs.FileHandler(fileSystem),
+ )
+ // add request logging to stdout
+ handler = guslog.Requests(os.Stdout, nil)(handler)
+
+ // run the server
+ gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve()
+}
+
+func envConfig() (string, string) {
+ certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
+ if !ok {
+ log.Fatal("missing SERVER_CERTIFICATE environment variable")
+ }
+
+ keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
+ if !ok {
+ log.Fatal("missing SERVER_PRIVATEKEY environment variable")
+ }
+
+ return certfile, keyfile
+}
diff --git a/examples/inspectls/main.go b/examples/inspectls/main.go
new file mode 100644
index 0000000..a315e40
--- /dev/null
+++ b/examples/inspectls/main.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "strings"
+
+ "tildegit.org/tjp/gus/gemini"
+ guslog "tildegit.org/tjp/gus/contrib/log"
+)
+
+func main() {
+ // Get TLS files from the environment
+ certfile, keyfile := envConfig()
+
+ // build a TLS configuration suitable for gemini
+ tlsconf, err := gemini.FileTLS(certfile, keyfile)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // set up the network listener
+ listener, err := net.Listen("tcp4", ":1965")
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // add stdout logging to the request handler
+ handler := guslog.Requests(os.Stdout, nil)(inspectHandler)
+
+ // run the server
+ gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve()
+}
+
+func envConfig() (string, string) {
+ certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
+ if !ok {
+ log.Fatal("missing SERVER_CERTIFICATE environment variable")
+ }
+
+ keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
+ if !ok {
+ log.Fatal("missing SERVER_PRIVATEKEY environment variable")
+ }
+
+ return certfile, keyfile
+}
+
+func inspectHandler(ctx context.Context, req *gemini.Request) *gemini.Response {
+ // build and return a ```-wrapped description of the connection TLS state
+ body := "```\n" + displayTLSState(req.TLSState) + "\n```"
+ return gemini.Success("text/gemini", bytes.NewBufferString(body))
+}
+
+func displayTLSState(state *tls.ConnectionState) string {
+ builder := &strings.Builder{}
+
+ builder.WriteString("Version: ")
+ builder.WriteString(map[uint16]string{
+ tls.VersionTLS10: "TLSv1.0",
+ tls.VersionTLS11: "TLSv1.1",
+ tls.VersionTLS12: "TLSv1.2",
+ tls.VersionTLS13: "TLSv1.3",
+ tls.VersionSSL30: "SSLv3",
+ }[state.Version])
+ builder.WriteString("\n")
+
+ builder.WriteString(fmt.Sprintf("Handshake complete: %t\n", state.HandshakeComplete))
+ builder.WriteString(fmt.Sprintf("Did resume: %t\n", state.DidResume))
+ builder.WriteString(fmt.Sprintf("Cipher suite: %x\n", state.CipherSuite))
+ builder.WriteString(fmt.Sprintf("Negotiated protocol: %q\n", state.NegotiatedProtocol))
+ builder.WriteString(fmt.Sprintf("Server name: %s\n", state.ServerName))
+
+ builder.WriteString(fmt.Sprintf("Certificates (%d)\n", len(state.PeerCertificates)))
+ for i, cert := range state.PeerCertificates {
+ builder.WriteString(fmt.Sprintf(" #%d: %s\n", i+1, fingerprint(cert)))
+ }
+
+ return builder.String()
+}
+
+func fingerprint(cert *x509.Certificate) []byte {
+ raw := md5.Sum(cert.Raw)
+ dst := make([]byte, hex.EncodedLen(len(raw)))
+ hex.Encode(dst, raw[:])
+ return dst
+}
diff --git a/gemini/handler.go b/gemini/handler.go
new file mode 100644
index 0000000..ded77a5
--- /dev/null
+++ b/gemini/handler.go
@@ -0,0 +1,29 @@
+package gemini
+
+import "context"
+
+// Handler is a function which can turn a gemini request into a gemini response.
+//
+// A Handler MUST NOT return a nil response. Errors should be returned in the form
+// of error responses (4x, 5x, 6x response status). If the Handler should not be
+// responsible for the requested resource it can return a "51 Not Found" response.
+type Handler func(context.Context, *Request) *Response
+
+// Middleware is a handle decorator.
+//
+// It returns a handler which may call the passed-in handler or not, or may
+// transform the request or response in some way.
+type Middleware func(Handler) Handler
+
+func Fallthrough(handlers ...Handler) Handler {
+ return func(ctx context.Context, req *Request) *Response {
+ for _, handler := range handlers {
+ response := handler(ctx, req)
+ if response.Status != StatusNotFound {
+ return response
+ }
+ }
+
+ return NotFound("Resource does not exist.")
+ }
+}
diff --git a/gemini/request.go b/gemini/request.go
new file mode 100644
index 0000000..248ce67
--- /dev/null
+++ b/gemini/request.go
@@ -0,0 +1,50 @@
+package gemini
+
+import (
+ "bufio"
+ "crypto/tls"
+ "errors"
+ "io"
+ "net/url"
+)
+
+// InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n".
+var InvalidRequestLineEnding = errors.New("invalid request line ending")
+
+// Request represents a request over the gemini protocol.
+type Request struct {
+ *url.URL
+
+ TLSState *tls.ConnectionState
+}
+
+// ParseRequest parses a single gemini request from a reader.
+func ParseRequest(rdr io.Reader) (*Request, error) {
+ line, err := bufio.NewReader(rdr).ReadString('\n')
+ if err != io.EOF && err != nil {
+ return nil, err
+ }
+
+ if len(line) < 2 || line[len(line)-2:] != "\r\n" {
+ return nil, InvalidRequestLineEnding
+ }
+
+ u, err := url.Parse(line[:len(line)-2])
+ if err != nil {
+ return nil, err
+ }
+
+ if u.Scheme == "" {
+ u.Scheme = "gemini"
+ }
+
+ return &Request{URL: u}, nil
+}
+
+// UnescapedQuery performs %XX unescaping on the URL query segment.
+//
+// Like URL.Query(), it silently drops malformed %-encoded sequences.
+func (req Request) UnescapedQuery() string {
+ unescaped, _ := url.QueryUnescape(req.RawQuery)
+ return unescaped
+}
diff --git a/gemini/request_test.go b/gemini/request_test.go
new file mode 100644
index 0000000..1da24f7
--- /dev/null
+++ b/gemini/request_test.go
@@ -0,0 +1,86 @@
+package gemini_test
+
+import (
+ "bytes"
+ "testing"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func TestParseRequest(t *testing.T) {
+ table := []struct {
+ input string
+ scheme string
+ host string
+ path string
+ query string
+ fragment string
+ err error
+ }{
+ {
+ input: "gemini://foo.com/bar?baz#qux\r\n",
+ scheme: "gemini",
+ host: "foo.com",
+ path: "/bar",
+ query: "baz",
+ fragment: "qux",
+ err: nil,
+ },
+ {
+ input: "//foo.com/path\r\n",
+ scheme: "gemini",
+ host: "foo.com",
+ path: "/path",
+ query: "",
+ fragment: "",
+ err: nil,
+ },
+ {
+ input: "/path\r\n",
+ scheme: "gemini",
+ host: "",
+ path: "/path",
+ query: "",
+ fragment: "",
+ err: nil,
+ },
+ {
+ input: "gemini://invalid.com/line/ending",
+ scheme: "",
+ host: "",
+ path: "",
+ query: "",
+ fragment: "",
+ err: gemini.InvalidRequestLineEnding,
+ },
+ }
+
+ for _, test := range table {
+ t.Run(test.input, func(t *testing.T) {
+ req, err := gemini.ParseRequest(bytes.NewBufferString(test.input))
+ if err != test.err {
+ t.Fatalf("expected error %q, got %q", test.err, err)
+ }
+
+ if err != nil {
+ return
+ }
+
+ if req.Scheme != test.scheme {
+ t.Errorf("expected scheme %q, got %q", test.scheme, req.Scheme)
+ }
+ if req.Host != test.host {
+ t.Errorf("expected host %q, got %q", test.host, req.Host)
+ }
+ if req.Path != test.path {
+ t.Errorf("expected path %q, got %q", test.path, req.Path)
+ }
+ if req.RawQuery != test.query {
+ t.Errorf("expected query %q, got %q", test.query, req.RawQuery)
+ }
+ if req.Fragment != test.fragment {
+ t.Errorf("expected fragment %q, got %q", test.fragment, req.Fragment)
+ }
+ })
+ }
+}
diff --git a/gemini/response.go b/gemini/response.go
new file mode 100644
index 0000000..90340a5
--- /dev/null
+++ b/gemini/response.go
@@ -0,0 +1,308 @@
+package gemini
+
+import (
+ "bytes"
+ "io"
+ "strconv"
+)
+
+// StatusCategory represents the various types of responses.
+type StatusCategory int
+
+const (
+ // StatusCategoryInput is for responses which request additional input.
+ //
+ // The META line will be the prompt to display to the user.
+ StatusCategoryInput StatusCategory = iota*10 + 10
+ // StatusCategorySuccess is for successful responses.
+ //
+ // The META line will be the resource's mime type.
+ // This is the only response status which indicates the presence of a response body,
+ // and it will contain the resource itself.
+ StatusCategorySuccess
+ // StatusCategoryRedirect is for responses which direct the client to an alternative URL.
+ //
+ // The META line will contain the new URL the client should try.
+ StatusCategoryRedirect
+ // StatusCategoryTemporaryFailure is for responses which indicate a transient server-side failure.
+ //
+ // The META line may contain a line with more information about the error.
+ StatusCategoryTemporaryFailure
+ // StatusCategoryPermanentFailure is for permanent failure responses.
+ //
+ // The META line may contain a line with more information about the error.
+ StatusCategoryPermanentFailure
+ // StatusCategoryCertificateRequired indicates client certificate related issues.
+ //
+ // The META line may contain a line with more information about the error.
+ StatusCategoryCertificateRequired
+)
+
+// Status is the integer status code of a gemini response.
+type Status int
+
+const (
+ // StatusInput indicates a required query parameter at the requested URL.
+ StatusInput Status = Status(StatusCategoryInput) + iota
+ // StatusSensitiveInput indicates a sensitive query parameter is required.
+ StatusSensitiveInput
+)
+
+const (
+ // StatusSuccess is a successful response.
+ StatusSuccess = Status(StatusCategorySuccess) + iota
+)
+
+const (
+ // StatusTemporaryRedirect indicates a temporary redirect to another URL.
+ StatusTemporaryRedirect = Status(StatusCategoryRedirect) + iota
+ // StatusPermanentRedirect indicates that the resource should always be requested at the new URL.
+ StatusPermanentRedirect
+)
+
+const (
+ // StatusTemporaryFailure indicates that the request failed and there is no response body.
+ StatusTemporaryFailure = Status(StatusCategoryTemporaryFailure) + iota
+ // StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance.
+ StatusServerUnavailable
+ // StatusCGIError is the result of a failure of a CGI script.
+ StatusCGIError
+ // StatusProxyError indicates that the server is acting as a proxy and the outbound request failed.
+ StatusProxyError
+ // StatusSlowDown tells the client that rate limiting is in effect.
+ //
+ // Unlike other statuses in this category, the META line is an integer indicating how
+ // many more seconds the client must wait before sending another request.
+ StatusSlowDown
+)
+
+const (
+ // StatusPermanentFailure is a server failure which should be expected to continue indefinitely.
+ StatusPermanentFailure = Status(StatusCategoryPermanentFailure) + iota
+ // StatusNotFound means the resource doesn't exist but it may in the future.
+ StatusNotFound
+ // StatusGone occurs when a resource will not be available any longer.
+ StatusGone
+ // StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource.
+ StatusProxyRequestRefused
+ // StatusBadRequest indicates that the request was malformed somehow.
+ StatusBadRequest = Status(StatusCategoryPermanentFailure) + 9
+)
+
+const (
+ // StatusClientCertificateRequired is returned when a certificate was required but not provided.
+ StatusClientCertificateRequired = Status(StatusCategoryCertificateRequired) + iota
+ // StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource.
+ StatusCertificateNotAuthorized
+ // StatusCertificateNotValid means the provided client certificate is invalid.
+ StatusCertificateNotValid
+)
+
+// StatusCategory returns the category a specific status belongs to.
+func (s Status) Category() StatusCategory {
+ return StatusCategory(s / 10)
+}
+
+// Response contains everything in a gemini protocol response.
+type Response struct {
+ // Status is the status code of the response.
+ Status Status
+
+ // Meta is the status-specific line of additional information.
+ Meta string
+
+ // Body is the response body, if any.
+ Body io.Reader
+
+ reader io.Reader
+}
+
+// Input builds an input-prompting response.
+func Input(prompt string) *Response {
+ return &Response{
+ Status: StatusInput,
+ Meta: prompt,
+ }
+}
+
+// SensitiveInput builds a password-prompting response.
+func SensitiveInput(prompt string) *Response {
+ return &Response{
+ Status: StatusSensitiveInput,
+ Meta: prompt,
+ }
+}
+
+// Success builds a success response with resource body.
+func Success(mediatype string, body io.Reader) *Response {
+ return &Response{
+ Status: StatusSuccess,
+ Meta: mediatype,
+ Body: body,
+ }
+}
+
+// Redirect builds a redirect response.
+func Redirect(url string) *Response {
+ return &Response{
+ Status: StatusTemporaryRedirect,
+ Meta: url,
+ }
+}
+
+// PermanentRedirect builds a response with a permanent redirect.
+func PermanentRedirect(url string) *Response {
+ return &Response{
+ Status: StatusPermanentRedirect,
+ Meta: url,
+ }
+}
+
+// Failure builds a temporary failure response from an error.
+func Failure(err error) *Response {
+ return &Response{
+ Status: StatusTemporaryFailure,
+ Meta: err.Error(),
+ }
+}
+
+// Unavailable build a "server unavailable" response.
+func Unavailable(msg string) *Response {
+ return &Response{
+ Status: StatusServerUnavailable,
+ Meta: msg,
+ }
+}
+
+// CGIError builds a "cgi error" response.
+func CGIError(err string) *Response {
+ return &Response{
+ Status: StatusCGIError,
+ Meta: err,
+ }
+}
+
+// ProxyError builds a proxy error response.
+func ProxyError(msg string) *Response {
+ return &Response{
+ Status: StatusProxyError,
+ Meta: msg,
+ }
+}
+
+// SlowDown builds a "slow down" response with the number of seconds until the resource is available.
+func SlowDown(seconds int) *Response {
+ return &Response{
+ Status: StatusSlowDown,
+ Meta: strconv.Itoa(seconds),
+ }
+}
+
+// PermanentFailure builds a "permanent failure" from an error.
+func PermanentFailure(err error) *Response {
+ return &Response{
+ Status: StatusPermanentFailure,
+ Meta: err.Error(),
+ }
+}
+
+// NotFound builds a "resource not found" response.
+func NotFound(msg string) *Response {
+ return &Response{
+ Status: StatusNotFound,
+ Meta: msg,
+ }
+}
+
+// Gone builds a "resource gone" response.
+func Gone(msg string) *Response {
+ return &Response{
+ Status: StatusGone,
+ Meta: msg,
+ }
+}
+
+// RefuseProxy builds a "proxy request refused" response.
+func RefuseProxy(msg string) *Response {
+ return &Response{
+ Status: StatusProxyRequestRefused,
+ Meta: msg,
+ }
+}
+
+// BadRequest builds a "bad request" response.
+func BadRequest(msg string) *Response {
+ return &Response{
+ Status: StatusBadRequest,
+ Meta: msg,
+ }
+}
+
+// RequireCert builds a "client certificate required" response.
+func RequireCert(msg string) *Response {
+ return &Response{
+ Status: StatusClientCertificateRequired,
+ Meta: msg,
+ }
+}
+
+// CertAuthFailure builds a "certificate not authorized" response.
+func CertAuthFailure(msg string) *Response {
+ return &Response{
+ Status: StatusCertificateNotAuthorized,
+ Meta: msg,
+ }
+}
+
+// CertInvalid builds a "client certificate not valid" response.
+func CertInvalid(msg string) *Response {
+ return &Response{
+ Status: StatusCertificateNotValid,
+ Meta: msg,
+ }
+}
+
+// Read implements io.Reader for Response.
+func (r *Response) Read(b []byte) (int, error) {
+ r.ensureReader()
+ return r.reader.Read(b)
+}
+
+// WriteTo implements io.WriterTo for Response.
+func (r *Response) WriteTo(dst io.Writer) (int64, error) {
+ r.ensureReader()
+ return r.reader.(io.WriterTo).WriteTo(dst)
+}
+
+// Close implements io.Closer and ensures the body gets closed.
+func (r *Response) Close() error {
+ if r != nil {
+ if cl, ok := r.Body.(io.Closer); ok {
+ return cl.Close()
+ }
+ }
+ return nil
+}
+
+func (r *Response) ensureReader() {
+ if r.reader != nil {
+ return
+ }
+
+ hdr := bytes.NewBuffer(r.headerLine())
+ if r.Body != nil {
+ r.reader = io.MultiReader(hdr, r.Body)
+ } else {
+ r.reader = hdr
+ }
+}
+
+func (r Response) headerLine() []byte {
+ buf := make([]byte, len(r.Meta)+5)
+ _ = strconv.AppendInt(buf[:0], int64(r.Status), 10)
+ buf[2] = ' '
+ copy(buf[3:], r.Meta)
+ buf[len(buf)-2] = '\r'
+ buf[len(buf)-1] = '\n'
+ return buf
+}
diff --git a/gemini/response_test.go b/gemini/response_test.go
new file mode 100644
index 0000000..3e1f41f
--- /dev/null
+++ b/gemini/response_test.go
@@ -0,0 +1,151 @@
+package gemini_test
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "testing"
+
+ "tildegit.org/tjp/gus/gemini"
+)
+
+func TestBuildResponses(t *testing.T) {
+ table := []struct {
+ name string
+ response *gemini.Response
+ status gemini.Status
+ meta string
+ body string
+ }{
+ {
+ name: "input response",
+ response: gemini.Input("prompt here"),
+ status: gemini.StatusInput,
+ meta: "prompt here",
+ },
+ {
+ name: "sensitive input response",
+ response: gemini.SensitiveInput("password please"),
+ status: gemini.StatusSensitiveInput,
+ meta: "password please",
+ },
+ {
+ name: "success response",
+ response: gemini.Success("text/gemini", bytes.NewBufferString("body text here")),
+ status: gemini.StatusSuccess,
+ meta: "text/gemini",
+ body: "body text here",
+ },
+ {
+ name: "temporary redirect",
+ response: gemini.Redirect("/foo/bar"),
+ status: gemini.StatusTemporaryRedirect,
+ meta: "/foo/bar",
+ },
+ {
+ name: "permanent redirect",
+ response: gemini.PermanentRedirect("/baz/qux"),
+ status: gemini.StatusPermanentRedirect,
+ meta: "/baz/qux",
+ },
+ {
+ name: "fail response",
+ response: gemini.Failure(errors.New("a failure")),
+ status: gemini.StatusTemporaryFailure,
+ meta: "a failure",
+ },
+ {
+ name: "server unavailable",
+ response: gemini.Unavailable("server unavailable"),
+ status: gemini.StatusServerUnavailable,
+ meta: "server unavailable",
+ },
+ {
+ name: "cgi error",
+ response: gemini.CGIError("some cgi error msg"),
+ status: gemini.StatusCGIError,
+ meta: "some cgi error msg",
+ },
+ {
+ name: "proxy error",
+ response: gemini.ProxyError("upstream's full"),
+ status: gemini.StatusProxyError,
+ meta: "upstream's full",
+ },
+ {
+ name: "rate limiting",
+ response: gemini.SlowDown(15),
+ status: gemini.StatusSlowDown,
+ meta: "15",
+ },
+ {
+ name: "permanent failure",
+ response: gemini.PermanentFailure(errors.New("wut r u doin")),
+ status: gemini.StatusPermanentFailure,
+ meta: "wut r u doin",
+ },
+ {
+ name: "not found",
+ response: gemini.NotFound("nope"),
+ status: gemini.StatusNotFound,
+ meta: "nope",
+ },
+ {
+ name: "gone",
+ response: gemini.Gone("all out of that"),
+ status: gemini.StatusGone,
+ meta: "all out of that",
+ },
+ {
+ name: "refuse proxy",
+ response: gemini.RefuseProxy("no I don't think I will"),
+ status: gemini.StatusProxyRequestRefused,
+ meta: "no I don't think I will",
+ },
+ {
+ name: "bad request",
+ response: gemini.BadRequest("that don't make no sense"),
+ status: gemini.StatusBadRequest,
+ meta: "that don't make no sense",
+ },
+ {
+ name: "require cert",
+ response: gemini.RequireCert("cert required"),
+ status: gemini.StatusClientCertificateRequired,
+ meta: "cert required",
+ },
+ {
+ name: "cert auth failure",
+ response: gemini.CertAuthFailure("you can't see that"),
+ status: gemini.StatusCertificateNotAuthorized,
+ meta: "you can't see that",
+ },
+ {
+ name: "invalid cert",
+ response: gemini.CertInvalid("bad cert dude"),
+ status: gemini.StatusCertificateNotValid,
+ meta: "bad cert dude",
+ },
+ }
+
+ for _, test := range table {
+ t.Run(test.name, func(t *testing.T) {
+ if test.response.Status != test.status {
+ t.Errorf("expected status %d, got %d", test.status, test.response.Status)
+ }
+ if test.response.Meta != test.meta {
+ t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta)
+ }
+
+ responseBytes, err := io.ReadAll(test.response)
+ if err != nil {
+ t.Fatalf("error reading response body: %q", err.Error())
+ }
+
+ body := string(bytes.SplitN(responseBytes, []byte("\r\n"), 2)[1])
+ if body != test.body {
+ t.Errorf("expected body %q, got %q", test.body, body)
+ }
+ })
+ }
+}
diff --git a/gemini/serve.go b/gemini/serve.go
new file mode 100644
index 0000000..d439472
--- /dev/null
+++ b/gemini/serve.go
@@ -0,0 +1,89 @@
+package gemini
+
+import (
+ "context"
+ "crypto/tls"
+ "io"
+ "net"
+ "sync"
+)
+
+type Server struct {
+ ctx context.Context
+ cancel context.CancelFunc
+ wg *sync.WaitGroup
+ listener net.Listener
+ handler Handler
+}
+
+func NewServer(ctx context.Context, tlsConfig *tls.Config, listener net.Listener, handler Handler) *Server {
+ ctx, cancel := context.WithCancel(ctx)
+
+ s := &Server{
+ ctx: ctx,
+ cancel: cancel,
+ wg: &sync.WaitGroup{},
+ listener: tls.NewListener(listener, tlsConfig),
+ handler: handler,
+ }
+ go s.propagateCancel()
+
+ return s
+}
+
+func (s *Server) Close() {
+ s.cancel()
+ s.wg.Wait()
+}
+
+func (s *Server) Serve() {
+ s.wg.Add(1)
+ defer s.wg.Done()
+
+ for {
+ conn, err := s.listener.Accept()
+ if err != nil {
+ return
+ }
+
+ s.wg.Add(1)
+ go s.handleConn(conn)
+ }
+}
+
+func (s *Server) handleConn(conn net.Conn) {
+ defer s.wg.Done()
+ defer conn.Close()
+
+ req, err := ParseRequest(conn)
+ if tlsconn, ok := conn.(*tls.Conn); req != nil && ok {
+ state := tlsconn.ConnectionState()
+ req.TLSState = &state
+ }
+
+ var resp *Response
+ if err == nil {
+ resp = s.handler(s.ctx, req)
+ } else {
+ resp = BadRequest(err.Error())
+ }
+ defer resp.Close()
+
+ _, _ = io.Copy(conn, resp)
+}
+
+func (s *Server) propagateCancel() {
+ go func() {
+ <-s.ctx.Done()
+ _ = s.listener.Close()
+ }()
+}
+
+func (s *Server) closed() bool {
+ select {
+ case <-s.ctx.Done():
+ return true
+ default:
+ return false
+ }
+}
diff --git a/gemini/tls.go b/gemini/tls.go
new file mode 100644
index 0000000..3cdf93b
--- /dev/null
+++ b/gemini/tls.go
@@ -0,0 +1,16 @@
+package gemini
+
+import "crypto/tls"
+
+func FileTLS(certfile string, keyfile string) (*tls.Config, error) {
+ cert, err := tls.LoadX509KeyPair(certfile, keyfile)
+ if err != nil {
+ return nil, err
+ }
+
+ return &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS12,
+ ClientAuth: tls.RequestClientCert,
+ }, nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..7af8e8d
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,7 @@
+module tildegit.org/tjp/gus
+
+go 1.19
+
+require github.com/go-kit/log v0.2.1
+
+require github.com/go-logfmt/logfmt v0.5.1 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..fa964aa
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
+github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=