diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9fea5c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Gemserve is a simple Gemini protocol server written in Go that serves static files over TLS-encrypted connections. The Gemini protocol is a lightweight, privacy-focused alternative to HTTP designed for serving text-based content. + +## Development Commands + +```bash +# Build, test, and format everything +make + +# Run tests only +make test + +# Build binary to ./dist/gemserve +make build + +# Format code with gofumpt and gci +make fmt + +# Run golangci-lint +make lint + +# Run linter with auto-fix +make lintfix + +# Clean build artifacts +make clean + +# Run the server (after building) +./dist/gemserve + +# Generate TLS certificates for development +certs/generate.sh +``` + +## Architecture + +### Core Components + +- **main.go**: Entry point with TLS server setup, signal handling, and graceful shutdown +- **server/**: Request processing, file serving, and Gemini protocol response handling +- **gemini/**: Gemini protocol implementation (URL parsing, status codes, path normalization) +- **config/**: CLI-based configuration system +- **uid/**: Connection ID generation for logging + +### Key Patterns + +- **Security First**: All file operations use `filepath.IsLocal()` and path cleaning to prevent directory traversal +- **Error Handling**: Uses structured errors with `xerrors` package for consistent error propagation +- **Logging**: Structured logging with configurable levels via internal logging package +- **Testing**: Table-driven tests with parallel execution, heavy focus on security edge cases + +### Request Flow + +1. TLS connection established on port 1965 +2. Read up to 1KB request (Gemini spec limit) +3. Parse and normalize Gemini URL +4. Validate path security (prevent traversal) +5. Serve file or directory index with appropriate MIME type +6. Send response with proper Gemini status codes + +## Configuration + +Server configured via CLI flags: +- `--listen`: Server address (default: localhost:1965) +- `--root-path`: Directory to serve files from +- `--dir-indexing`: Enable directory browsing +- `--log-level`: Logging verbosity +- `--response-timeout`: Response timeout in seconds + +## Testing Strategy + +- **server/server_test.go**: Path security and file serving tests +- **gemini/url_test.go**: URL parsing and normalization tests +- Focus on security edge cases (Unicode, traversal attempts, malformed URLs) +- Use parallel test execution for performance + +## Security Considerations + +- All connections require TLS certificates (stored in certs/) +- Path traversal protection is critical - test thoroughly when modifying file serving logic +- Request size limited to 1KB per Gemini specification +- Input validation on all URLs and paths \ No newline at end of file diff --git a/Makefile b/Makefile index 86975eb..03a048d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SHELL := /bin/env oksh +SHELL := /bin/sh export PATH := $(PATH) all: fmt lintfix tidy test clean build diff --git a/config/config.go b/config/config.go index 5240159..cdded94 100644 --- a/config/config.go +++ b/config/config.go @@ -14,7 +14,7 @@ type Config struct { ResponseTimeout int // Timeout for responses in seconds RootPath string // Path to serve files from DirIndexingEnabled bool // Allow client to browse directories or not - Listen string // Address to listen on + ListenAddr string // Address to listen on } var CONFIG Config //nolint:gochecknoglobals @@ -64,6 +64,6 @@ func GetConfig() *Config { ResponseTimeout: *responseTimeout, RootPath: *rootPath, DirIndexingEnabled: *dirIndexing, - Listen: *listen, + ListenAddr: *listen, } } diff --git a/gemini/status_codes.go b/gemini/status_codes.go new file mode 100644 index 0000000..2f62b62 --- /dev/null +++ b/gemini/status_codes.go @@ -0,0 +1,34 @@ +package gemini + +// Gemini status codes as defined in the Gemini spec +// gemini://geminiprotocol.net/docs/protocol-specification.gmi +const ( + // Input group + StatusInputExpected = 10 + StatusInputExpectedSensitive = 11 + + StatusSuccess = 20 + + // Redirect group + StatusRedirectTemporary = 30 + StatusRedirectPermanent = 31 + + // Temporary failure group + StatusTemporaryFailure = 40 + StatusServerUnavailable = 41 + StatusCGIError = 42 + StatusProxyError = 43 + StatusSlowDown = 44 + + // Permanent failure group + StatusPermanentFailure = 50 + StatusNotFound = 51 + StatusGone = 52 + StatusProxyRequestRefused = 53 + StatusBadRequest = 59 + + // TLS certificate group + StatusCertificateRequired = 60 + StatusCertificateNotAuthorized = 61 + StatusCertificateNotValid = 62 +) diff --git a/common/gemini_url.go b/gemini/url.go similarity index 99% rename from common/gemini_url.go rename to gemini/url.go index dc064f6..b4bd15a 100644 --- a/common/gemini_url.go +++ b/gemini/url.go @@ -1,4 +1,4 @@ -package common +package gemini import ( "database/sql/driver" diff --git a/common/gemini_url_test.go b/gemini/url_test.go similarity index 99% rename from common/gemini_url_test.go rename to gemini/url_test.go index 6b23d30..8a13e60 100644 --- a/common/gemini_url_test.go +++ b/gemini/url_test.go @@ -1,4 +1,4 @@ -package common +package gemini import ( "net/url" diff --git a/go.mod b/go.mod index 462344e..2fc79ba 100644 --- a/go.mod +++ b/go.mod @@ -2,12 +2,20 @@ module gemserve go 1.24.3 +toolchain go1.24.4 + require ( git.antanst.com/antanst/logging v0.0.1 - git.antanst.com/antanst/xerrors v0.0.1 git.antanst.com/antanst/uid v0.0.1 + git.antanst.com/antanst/xerrors v0.0.1 github.com/gabriel-vasile/mimetype v1.4.8 github.com/matoous/go-nanoid/v2 v2.1.0 ) require golang.org/x/net v0.33.0 // indirect + +replace git.antanst.com/antanst/xerrors => ../xerrors + +replace git.antanst.com/antanst/uid => ../uid + +replace git.antanst.com/antanst/logging => ../logging diff --git a/main.go b/main.go index 8d18fe4..1832fbb 100644 --- a/main.go +++ b/main.go @@ -14,12 +14,16 @@ import ( "time" "gemserve/config" + "gemserve/gemini" "gemserve/server" logging "git.antanst.com/antanst/logging" "git.antanst.com/antanst/uid" "git.antanst.com/antanst/xerrors" ) +// This channel is for handling fatal errors +// from anywhere in the app. The consumer +// should log and panic. var fatalErrors chan error func main() { @@ -37,7 +41,7 @@ func main() { func runApp() error { logging.LogInfo("Starting up. Press Ctrl+C to exit") - listenHost := config.CONFIG.Listen + listenAddr := config.CONFIG.ListenAddr signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) @@ -45,7 +49,7 @@ func runApp() error { fatalErrors = make(chan error) go func() { - err := startServer(listenHost) + err := startServer(listenAddr) if err != nil { fatalErrors <- xerrors.NewError(err, 0, "Server startup failed", true) } @@ -62,7 +66,7 @@ func runApp() error { } } -func startServer(listenHost string) (err error) { +func startServer(listenAddr string) (err error) { cert, err := tls.LoadX509KeyPair("certs/server.crt", "certs/server.key") if err != nil { return xerrors.NewError(fmt.Errorf("failed to load certificate: %w", err), 0, "Certificate loading failed", true) @@ -73,7 +77,7 @@ func startServer(listenHost string) (err error) { MinVersion: tls.VersionTLS12, } - listener, err := tls.Listen("tcp", listenHost, tlsConfig) + listener, err := tls.Listen("tcp", listenAddr, tlsConfig) if err != nil { return xerrors.NewError(fmt.Errorf("failed to create listener: %w", err), 0, "Listener creation failed", true) } @@ -87,7 +91,7 @@ func startServer(listenHost string) (err error) { } }(listener) - logging.LogInfo("Server listening on %s", listenHost) + logging.LogInfo("Server listening on %s", listenAddr) for { conn, err := listener.Accept() @@ -106,7 +110,7 @@ func startServer(listenHost string) (err error) { fatalErrors <- asErr return } else { - logging.LogWarn("%s %s Connection failed: %d %s (%v)", connId, remoteAddr, asErr.Code, asErr.UserMsg, err) + logging.LogWarn("%s %s Connection failed: %v", connId, remoteAddr, err) } } }() @@ -157,7 +161,7 @@ func handleConnection(conn *tls.Conn, connId string, remoteAddr string) (err err code = xErr.Code responseMsg = xErr.UserMsg } else { - code = 50 + code = gemini.StatusPermanentFailure responseMsg = "server error" } responseHeader = fmt.Sprintf("%d %s", code, responseMsg) diff --git a/server/server.go b/server/server.go index e457a67..795ce45 100644 --- a/server/server.go +++ b/server/server.go @@ -10,10 +10,11 @@ import ( "path/filepath" "strconv" "strings" + "unicode/utf8" - "gemserve/common" "gemserve/config" - logging "git.antanst.com/antanst/logging" + "gemserve/gemini" + "git.antanst.com/antanst/logging" "git.antanst.com/antanst/xerrors" "github.com/gabriel-vasile/mimetype" ) @@ -23,21 +24,25 @@ type ServerConfig interface { RootPath() string } -func checkRequestURL(url *common.URL) error { - if url.Protocol != "gemini" { - return xerrors.NewError(fmt.Errorf("invalid URL"), 53, "URL Protocol not Gemini, proxying refused", false) +func checkRequestURL(url *gemini.URL) error { + if !utf8.ValidString(url.String()) { + return xerrors.NewError(fmt.Errorf("invalid URL"), gemini.StatusBadRequest, "Invalid URL", false) } - _, portStr, err := net.SplitHostPort(config.CONFIG.Listen) + if url.Protocol != "gemini" { + return xerrors.NewError(fmt.Errorf("invalid URL"), gemini.StatusProxyRequestRefused, "URL Protocol not Gemini, proxying refused", false) + } + + _, portStr, err := net.SplitHostPort(config.CONFIG.ListenAddr) if err != nil { - return xerrors.NewError(fmt.Errorf("failed to parse listen address: %w", err), 50, "Server configuration error", false) + return xerrors.NewError(fmt.Errorf("failed to parse server listen address: %w", err), 0, "", true) } listenPort, err := strconv.Atoi(portStr) if err != nil { - return xerrors.NewError(fmt.Errorf("failed to parse listen port: %w", err), 50, "Server configuration error", false) + return xerrors.NewError(fmt.Errorf("invalid server listen port: %w", err), 0, "", true) } if url.Port != listenPort { - return xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), 53, "invalid URL port, proxying refused", false) + return xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusProxyRequestRefused, "invalid request port, proxying refused", false) } return nil } @@ -45,9 +50,9 @@ func checkRequestURL(url *common.URL) error { func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, error) { trimmedInput := strings.TrimSpace(input) // url will have a cleaned and normalized path after this - url, err := common.ParseURL(trimmedInput, "", true) + url, err := gemini.ParseURL(trimmedInput, "", true) if err != nil { - return nil, xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), 59, "Invalid URL", false) + return nil, xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), gemini.StatusBadRequest, "Invalid URL", false) } logging.LogDebug("%s %s normalized URL path: %s", connId, conn.RemoteAddr(), url.Path) @@ -59,16 +64,16 @@ func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, erro serverRootPath := config.CONFIG.RootPath localPath, err := calculateLocalPath(url.Path, serverRootPath) if err != nil { - return nil, xerrors.NewError(err, 59, "Invalid path", false) + return nil, xerrors.NewError(err, gemini.StatusBadRequest, "Invalid path", false) } logging.LogDebug("%s %s request file path: %s", connId, conn.RemoteAddr(), localPath) // Get file/directory information info, err := os.Stat(localPath) if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { - return []byte("51 not found\r\n"), nil + return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Not found or access denied", false) } else if err != nil { - return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), 0, "Path access failed", false) + return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Path access failed", false) } // Handle directory. @@ -78,12 +83,12 @@ func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, erro return generateResponseFile(conn, connId, url, localPath) } -func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localPath string) ([]byte, error) { +func generateResponseFile(conn *tls.Conn, connId string, url *gemini.URL, localPath string) ([]byte, error) { data, err := os.ReadFile(localPath) if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { - return []byte("51 not found\r\n"), nil + return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Path access failed", false) } else if err != nil { - return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err), 0, "File read failed", false) + return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Path access failed", false) } var mimeType string @@ -92,15 +97,15 @@ func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localP } else { mimeType = mimetype.Detect(data).String() } - headerBytes := []byte(fmt.Sprintf("20 %s; lang=en\r\n", mimeType)) + headerBytes := []byte(fmt.Sprintf("%d %s; lang=en\r\n", gemini.StatusSuccess, mimeType)) response := append(headerBytes, data...) return response, nil } -func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPath string) (output []byte, err error) { +func generateResponseDir(conn *tls.Conn, connId string, url *gemini.URL, localPath string) (output []byte, err error) { entries, err := os.ReadDir(localPath) if err != nil { - return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err), 0, "Directory read failed", false) + return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err), gemini.StatusNotFound, "Directory access failed", false) } if config.CONFIG.DirIndexingEnabled { @@ -115,7 +120,7 @@ func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPa } } data := []byte(strings.Join(contents, "")) - headerBytes := []byte("20 text/gemini; lang=en\r\n") + headerBytes := []byte(fmt.Sprintf("%d text/gemini; lang=en\r\n", gemini.StatusSuccess)) response := append(headerBytes, data...) return response, nil } else { @@ -128,7 +133,7 @@ func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPa func calculateLocalPath(input string, basePath string) (string, error) { // Check for invalid characters early if strings.ContainsAny(input, "\\") { - return "", xerrors.NewError(fmt.Errorf("invalid characters in path: %s", input), 0, "Invalid path characters", false) + return "", xerrors.NewError(fmt.Errorf("invalid characters in path: %s", input), gemini.StatusBadRequest, "Invalid path", false) } // If IsLocal(path) returns true, then Join(base, path) @@ -144,7 +149,7 @@ func calculateLocalPath(input string, basePath string) (string, error) { localPath, err := filepath.Localize(filePath) if err != nil || !filepath.IsLocal(localPath) { - return "", xerrors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err), 0, "Invalid local path", false) + return "", xerrors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err), gemini.StatusBadRequest, "Invalid path", false) } filePath = path.Join(basePath, localPath) diff --git a/srv/index.gmi b/srv/index.gmi new file mode 100644 index 0000000..e69de29