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:
antanst
2025-06-06 15:02:25 +03:00
parent a426edb1f6
commit 2ead66f012
10 changed files with 174 additions and 36 deletions

87
CLAUDE.md Normal file
View 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

View File

@@ -1,4 +1,4 @@
SHELL := /bin/env oksh
SHELL := /bin/sh
export PATH := $(PATH)
all: fmt lintfix tidy test clean build

View File

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

View File

@@ -1,4 +1,4 @@
package common
package gemini
import (
"database/sql/driver"

View File

@@ -1,4 +1,4 @@
package common
package gemini
import (
"net/url"

10
go.mod
View File

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

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

View File

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