diff options
-rw-r--r-- | .drone.yml | 9 | ||||
-rw-r--r-- | README.md | 10 | ||||
-rw-r--r-- | TODO.md | 13 | ||||
-rw-r--r-- | contrib/fs/dir.go | 174 | ||||
-rw-r--r-- | contrib/fs/file.go | 55 | ||||
-rw-r--r-- | contrib/fs/stat.go | 28 | ||||
-rw-r--r-- | contrib/log/log.go | 35 | ||||
-rw-r--r-- | examples/cowsay/main.go | 99 | ||||
-rw-r--r-- | examples/fileserver/main.go | 60 | ||||
-rw-r--r-- | examples/inspectls/main.go | 95 | ||||
-rw-r--r-- | gemini/handler.go | 29 | ||||
-rw-r--r-- | gemini/request.go | 50 | ||||
-rw-r--r-- | gemini/request_test.go | 86 | ||||
-rw-r--r-- | gemini/response.go | 308 | ||||
-rw-r--r-- | gemini/response_test.go | 151 | ||||
-rw-r--r-- | gemini/serve.go | 89 | ||||
-rw-r--r-- | gemini/tls.go | 16 | ||||
-rw-r--r-- | go.mod | 7 | ||||
-rw-r--r-- | go.sum | 4 |
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. @@ -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 +} @@ -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 @@ -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= |