Initial commit.
continuous-integration/drone/push Build is passing Details

some basics:
 - minimal README
 - some TODOs
 - server and request handler framework
 - contribs: file serving, request logging
 - server examples
 - CI setup
This commit is contained in:
tjpcc 2023-01-09 16:40:24 -07:00
commit ff05d62013
19 changed files with 1318 additions and 0 deletions

9
.drone.yml Normal file
View File

@ -0,0 +1,9 @@
---
kind: pipeline
name: verify
steps:
- name: test
image: golang
commands:
- go test -v ./...

10
README.md Normal file
View File

@ -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.

13
TODO.md Normal file
View File

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

174
contrib/fs/dir.go Normal file
View File

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

55
contrib/fs/file.go Normal file
View File

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

28
contrib/fs/stat.go Normal file
View File

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

35
contrib/log/log.go Normal file
View File

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

99
examples/cowsay/main.go Normal file
View File

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

View File

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

View File

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

29
gemini/handler.go Normal file
View File

@ -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.")
}
}

50
gemini/request.go Normal file
View File

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

86
gemini/request_test.go Normal file
View File

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

308
gemini/response.go Normal file
View File

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

151
gemini/response_test.go Normal file
View File

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

89
gemini/serve.go Normal file
View File

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

16
gemini/tls.go Normal file
View File

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

7
go.mod Normal file
View File

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

4
go.sum Normal file
View File

@ -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=