Files
backer/AGENTS.md

322 lines
15 KiB
Markdown

# AGENTS.md
## Project Overview
Backer is a Go-based HTTP/HTTPS backup server that creates compressed archives on-the-fly from configured directories. Supports multiple compression algorithms: gzip, pgzip, bzip2, zstd, lz4, xz. It serves a single endpoint that clients (e.g., `curl`) can query to download a timestamped backup archive. Licensed under MIT.
## Fundamental Rules
- **Do not apply changes without explicit user consent.** When asked to analyze, review, or suggest any changes, present your proposal first and wait for confirmation before editing any files. Always ask "Should I implement this?" or "Should I apply these changes?" before modifying anything.
- **Keep codebase simple, small, maintainable, safe and human-readable.** Favour minimal, clear solutions over clever or abstract ones. Prefer clarity over cleverness. Prioritize explicitness. Minimize magic. Do not introduce patterns, layers, or indirection unless they directly solve a concrete problem. Every line of code must earn its place.
- **Create small focused modules and separate functionality over files.** Do not make universal implementation; try to move functions to separate files and name files after function names.
- **Use a consistent structure.** Subsequent searches will be faster and consume less context.
- **Avoid duplicate names.** This is not strict rule. If codebase contain multiple files with the same name (e.g., index.ts), we have to read them all to discover the one exactly we need. This consumes time and context.
- **When stuck, ask clarification questions, or present a short plan, or both.** Do not try to guess intent or make assumptions - ask.
- **Always make TODO plan before any job and follow it closely.**
- **Report any found defects, issues, bugs.** And keep track of them.
- **Comment the code.** Add code comments to explain corner cases, complex logic, business rules, and design decisions. Focus on **what** problem the code is solving rather than **how** it is solving it. Write comments in easy to read and understand language, for humans to comprehend. Every public API and structure field must have a docstring.
- **Use domain names.** Use naming conventions that reflect their purpose and domain concepts, not their technical implementation (e.g., OrderProcessor instead of OrderServiceFactory).
- **Test corner cases.** Write comprehensive test suites that cover various scenarios and edge cases.
- **Use tags and labels.** In comments, add tags or labels to files, classes, or functions to categorize them based on functionality, domain concepts, or design patterns.
- **Avoid obvious comments that do not add value.**
- **Remove dead code.** A half-finished refactoring can actually harm the contributions.
- **Update AGENTS.md and TODO.md automatically.** Keep AGENTS.md dense, detailed and concise.
## Workflow Rules
- **Don't use `context` package unless absolutely necessary.** Try to find another solution first.
- **Create functional and unit tests if possible/necessary** to verify changes that software code works at is should.
## Build & Test Commands
```bash
make build # Build binary (version from git or "dev")
make build VERSION=0.1.0 # Build with specific version tag
make test # Run all tests (go test ./...)
make clean # Remove built binary
make # Clean + build
make upgrade # Update dependencies (go get -u, mod tidy, vendor)
make help # Show all targets
```
Version is injected at build time via ldflags: `-X main.version=$(VERSION)`.
## Linting
```bash
golangci-lint run # Run linter (configured via .golangci.yml)
```
The project uses golangci-lint v2 with a strict set of linters. See `.golangci.yml` for the full configuration.
## Code Style & Conventions
- **Language**: Go 1.25
- **Formatting**: `gofmt` + `goimports` (enforced by linter)
- **Imports**: Grouped imports, single `import` block required (`grouper` linter)
- **Declaration order**: Type, const, var, func (`decorder` linter)
- **Named returns**: Avoid named return values, especially in `defer` blocks (`nonamedreturns` linter)
- **Naming**: Follow Go initialisms (HTTP, TLS, IP, etc.) as defined in `staticcheck` config
- **File footer**: Each source file ends with `/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */`
### Godoc Comments
All exported types, functions, and methods must have godoc comments (`godoclint`, `godot`). Comments must end with a period.
### Error Handling
- **Error messages must be capitalized** (e.g., `"Failed to read config"`, not `"failed to read config"`). The `ST1005` linter rule is disabled to allow this.
- Use `fmt.Errorf` with `%w` for error wrapping.
- Use `%v` for errors in format strings (not `%s`).
- Past tense style: `"Failed to..."`, `"Unable to..."`.
- Always handle errors explicitly; never discard with `_` except in tests where intentional.
### Logging Conventions
- Use `backer/internal/log` package (wrapper around Go's `slog`).
- Log levels: `error`, `warn`, `info`, `debug`.
- Past tense with context: `"Skipping %s: %v", filename, err`.
- Appropriate severity: Warn for recoverable issues, Error for problems, Fatal for startup failures.
### Config Validation Pattern
For each config option: set default first, then immediately validate range/required value:
```go
if C.Port == 0 {
C.Port = 8086
log.Warnf("Config option port is not set, fallback to %d", C.Port)
}
if C.Port < portMin || C.Port > portMax {
return fmt.Errorf("Config option port must be between %d and %d, got %d", portMin, portMax, C.Port)
}
```
### Function Naming
```go
// Exported (public): VerbNoun pattern
NewServer()
LoadConfig()
CreateTarGzStream()
GetFilesFromDirectories()
// Unexported (private): verbNoun or isNoun pattern
writeWithContext()
copyWithContext()
isPipeClosedError()
defaultConfigPath()
```
### JSON Tags
Struct fields use PascalCase, json tags use snake_case:
```go
BackupTimeout int `json:"backup_timeout"`
CompressionLevel int `json:"compression_level"`
```
### Intentional Linter Suppressions
| Rule | Reason |
|------|--------|
| ST1005 | Disable — allows capitalized error messages (e.g., `"Failed to..."`) |
| QF1001 | De Morgan's law disabled for auth condition clarity |
| revive (error strings) | Same reason as ST1005 |
### Design Decisions (Not Issues)
The following are intentional design decisions, not bugs or defects:
- **No rate limiting**: Backups are assumed to be infrequent enough that rate limiting is unnecessary.
- **Basic Auth only**: HTTP Basic Auth is sufficient for a backup server behind a firewall.
- **Global config**: The `C Config` global variable is intentional — config is loaded once at startup.
- **No incremental backup**: Each backup is a full snapshot, not incremental.
- **Credentials in config file**: Config file should have restricted permissions (documented).
## Project Structure
```
cmd/
backer/
main.go # CLI entrypoint, flag parsing, TLS setup, server start
internal/
backer/
types.go # Config struct definition
consts.go # Named constants for magic numbers
globals.go # Global C Config variable, compiled excludePatterns
archive.go # Common archive logic (pipe, tar, file iteration)
gzip.go # CreateTarGzStream, CreateTarPgzipStream
bzip2.go # CreateTarBzip2Stream
zstd.go # CreateTarZstdStream, zstdLevel helper
lz4.go # CreateTarLz4Stream
xz.go # CreateTarXzStream
loadConfig.go # HJSON/JSON config parsing with validation
loadConfig_test.go # Config validation tests
newServer.go # HTTP server creation, auth handler, context helpers
newServer_test.go # Server, auth, and context helper tests
getFilesFromDirectories.go # Directory walking, exclusion filtering (isExcluded)
getFilesFromDirectories_test.go # File traversal and exclude pattern tests
utils_test.go # Archive stream tests
log/
main.go # Logging wrapper around stdlib slog
main_test.go # Log package tests
data/
config_example.json # Annotated example config (HJSON with comments)
contrib/
backer # OpenRC init script (Alpine/Gentoo)
backer.service # systemd unit file
backer.freebsd # FreeBSD rc.d script
test_data/
test_config.json # Test config fixture
example.crt / example.key # TLS test certificates
test1/ # File/directory fixtures for tests
Makefile # Build targets
README.md # User documentation
LICENSE # MIT License
llms.txt # LLM-friendly project reference
```
## Configuration
Config is JSON (HJSON with comments supported). Default path depends on OS:
- Linux/macOS: `/etc/backer.json`
- FreeBSD/NetBSD/OpenBSD/DragonFly: `/usr/local/etc/backer.json`
- Override with `-c /path/to/config.json`
### Config Options
| Option | Default | Description |
|--------|---------|-------------|
| address | `0.0.0.0` | Listen address |
| port | `8086` | Listen port (1-65535) |
| cert | — | TLS certificate file path |
| key | — | TLS key file path |
| nohttps | `false` | Disable HTTPS (for dev/lab) |
| location | `/archive` | API endpoint path |
| user | — | Basic auth username (required) |
| password | — | Basic auth password (required) |
| log | stderr | Log output file path |
| loglevel | `info` | Log verbosity: error/warn/info/debug |
| directories | — | Directories to backup (required, validated for existence) |
| backup_timeout | `60` | Stream timeout in minutes (1-1440) |
| compression_level | `9` | Gzip level (1-9) |
| exclude_patterns | `[]` | Regex patterns to exclude from backup |
| filename_prefix | `backup` | Prefix for backup filename in Content-Disposition header |
| default_compression | `gzip` | Default compression algorithm: gzip, pgzip, bzip2, zstd, lz4 or xz |
| compression_algorithm | `gzip` | Alias for default_compression (deprecated) |
## Architecture & Key Patterns
### Global Config
Uses a global `C Config` variable (`internal/backer/globals.go`). This is intentional — config is loaded once at startup and never modified concurrently.
### HJSON Config Parsing
Requires double conversion (hjson → map → json → struct):
```go
hjson.Unmarshal(buf, &tmp) // parse HJSON into map[string]any
json.Marshal(tmp) // re-encode as strict JSON
json.Unmarshal(buf, &C) // decode into Config struct
```
### Streaming Archive Creation
No temp files on disk. Uses `io.Pipe` with a goroutine to stream archives on-the-fly:
- Common logic in `archive.go`: `createArchiveStream()` handles pipe, goroutine, tar writing, file iteration
- Each compression algorithm has its own file: `gzip.go`, `bzip2.go`, `zstd.go`, `lz4.go`, `xz.go`
- All return an `io.ReadCloser`
- HTTP handler copies from pipe reader directly to HTTP response
### Context-Aware I/O
Two helper functions in `newServer.go` handle context cancellation during streaming:
- `writeWithContext(ctx, fn)` — checks context before calling fn
- `copyWithContext(ctx, dst, src)` — buffered copy (32KB) with cancellation checks
### Special File Type Handling
- **Regular files**: streamed into tar archive with content
- **Directories**: included as tar directory entries
- **Symlinks**: stored as symlink entries (target path preserved)
- **Device files** (char/block): included with correct major/minor
- **Named pipes**: included (header only, no data)
- **Sockets**: skipped (not archivable)
- **Hard links**: deduplicated — if multiple files share the same inode, only the first is stored with content, subsequent hard links are stored as link entries pointing to the original
### Pipe Error Recovery
`isPipeClosedError()` checks for `"io: read/write on closed pipe"` errors to avoid noisy logging when a client disconnects mid-stream.
### Auth Handler
HTTP Basic Auth. Both username AND password must match exactly. Returns `401 Unauthorized` with `WWW-Authenticate` header on failure. The condition is intentionally written without De Morgan's law simplification for clarity (linter suppression `//nolint:staticcheck`).
### Client IP Detection
Checks `X-Forwarded-For` header first (for proxied requests), then falls back to `RemoteAddr`.
### Server Configuration
- `ReadHeaderTimeout`: 5 seconds
- `WriteTimeout`: configurable via `backup_timeout` (converted to `time.Duration`)
- TLS minimum version: TLS 1.3 (when HTTPS enabled)
### TLS Error Handling
The server uses a `serverWrapper` type to intercept and classify errors from `Serve()` and `ServeTLS()`. TLS-specific errors are logged at debug level to reduce log clutter:
- Uses `errors.As` to detect TLS error types: `tls.AlertError`, `tls.CertificateVerificationError`, `tls.ECHRejectionError`, `tls.RecordHeaderError`
- Also checks for "tls:" prefix in error messages
- Additionally uses `http.Server.ErrorLog` with a custom logger that writes at debug level for net/http's internal TLS-related messages
This ensures client-side TLS errors (handshake failures, certificate issues) don't clutter production logs.
## Dependencies
- `github.com/hjson/hjson-go` v3.3.0 — HJSON config parsing
- `github.com/dsnet/compress` v0.0.1 — bzip2 compression
- `github.com/klauspost/compress` v1.18.5 — zstd compression
- `github.com/klauspost/pgzip` v1.2.6 — parallel gzip compression
- `github.com/pierrec/lz4` v2.6.1 — lz4 compression
- `github.com/ulikunitz/xz` v0.5.15 — xz compression
## Testing
```bash
go test ./... # Run all tests
go test -v -run TestName ./... # Run specific test
make test # Via Makefile
```
Tests live alongside source files as `*_test.go` (package name, not `_test` suffix). Coverage is approximately 69% on `internal/backer` and ~100% on `internal/log`.
### Test Conventions
- Reset global config before tests: `C = Config{}`
- Reset exclude patterns: `excludePatterns = nil`
- Use `t.TempDir()` for temporary files
- Helper `writeTempConfig(t, content)` creates temp config files for validation tests
- Symlink tests skip gracefully with `t.Skip()` when not supported
- Context cancellation tests verify correct behavior
### Test Data
- `test_data/test_config.json` — valid test config (user: JohnnyGoode, password: SharpShooter)
- `test_data/test1/foo/` — test directory with files, subdirs, empty dirs
- `test_data/test1/bar/` — test directory with files and a directory named `goodbye.txt/` (edge case: directory with file extension)
- `test_data/example.crt` / `example.key` — TLS test certificates
## Service Deployment
Init scripts provided in `contrib/`:
- **systemd** (`backer.service`): `ExecStart=/usr/local/sbin/backer`, restart on failure
- **OpenRC** (`backer`): uses `supervise-daemon`, creates log/pid dirs in `start_pre`
- **FreeBSD rc.d** (`backer.freebsd`): daemonizes via `/usr/sbin/daemon`