Refactor Gemini protocol implementation and improve server architecture
- Move gemini URL parsing from common/ to gemini/ package - Add structured status codes in gemini/status_codes.go - Improve error handling with proper Gemini status codes - Update configuration field naming (Listen -> ListenAddr) - Add UTF-8 validation for URLs - Enhance security with better path validation - Add CLAUDE.md for development guidance - Include example content in srv/ directory - Update build system to use standard shell
This commit is contained in:
87
CLAUDE.md
Normal file
87
CLAUDE.md
Normal file
@@ -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
|
||||
2
Makefile
2
Makefile
@@ -1,4 +1,4 @@
|
||||
SHELL := /bin/env oksh
|
||||
SHELL := /bin/sh
|
||||
export PATH := $(PATH)
|
||||
|
||||
all: fmt lintfix tidy test clean build
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
34
gemini/status_codes.go
Normal file
34
gemini/status_codes.go
Normal file
@@ -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
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
package common
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
@@ -1,4 +1,4 @@
|
||||
package common
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
10
go.mod
10
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
|
||||
|
||||
18
main.go
18
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
srv/index.gmi
Normal file
0
srv/index.gmi
Normal file
Reference in New Issue
Block a user