309 lines
12 KiB
Plaintext
309 lines
12 KiB
Plaintext
# llms.txt for Backer
|
|
|
|
## Overview
|
|
Backer is a Go-based backup utility that creates compressed archives on-the-fly and serves them via HTTP/HTTPS with basic authentication. Supports gzip, pgzip, bzip2, zstd, lz4, xz compression.
|
|
|
|
## Quick Start
|
|
```bash
|
|
make # Build binary
|
|
make build VERSION=0.1.0 # Build with version
|
|
backer # Run with default config (/etc/backer.json)
|
|
backer -c /path/to/config.json # Run with custom config
|
|
backer --version # Show version
|
|
backer -h # Show help
|
|
curl -u user:pass https://server:8086/archive -o backup.tar.gz
|
|
```
|
|
|
|
## Config File Locations
|
|
- Linux: `/etc/backer.json`
|
|
- FreeBSD/NetBSD/OpenBSD/DragonFly: `/usr/local/etc/backer.json`
|
|
- Override with `-c` flag
|
|
|
|
## Directory Structure
|
|
```
|
|
/
|
|
├── cmd/backer/main.go # Entry point, CLI flags, server startup
|
|
├── internal/
|
|
│ ├── backer/ # Core logic
|
|
│ │ ├── types.go # Config struct definition
|
|
│ │ ├── consts.go # Named constants for magic numbers
|
|
│ │ ├── globals.go # Global C Config variable
|
|
│ │ ├── archive.go # Common archive logic (createArchiveStream, writeFilesToTar)
|
|
│ │ ├── gzip.go # CreateTarGzStream, CreateTarPgzipStream
|
|
│ │ ├── bzip2.go # CreateTarBzip2Stream, mapCompressionLevelToBzip2
|
|
│ │ ├── zstd.go # CreateTarZstdStream, zstdLevel
|
|
│ │ ├── lz4.go # CreateTarLz4Stream, mapCompressionLevelToLz4
|
|
│ │ ├── xz.go # CreateTarXzStream
|
|
│ │ ├── loadConfig.go # HJSON/JSON config parsing with validation
|
|
│ │ ├── newServer.go # HTTP server, auth handler, context helpers
|
|
│ │ ├── getFilesFromDirectories.go # Directory walking, exclusion filtering, isExcluded
|
|
│ │ └── *_test.go # Test files
|
|
│ └── log/main.go # Logging wrapper around stdlib slog
|
|
├── contrib/ # Init scripts
|
|
│ ├── backer # OpenRC (Alpine/Gentoo)
|
|
│ ├── backer.service # systemd
|
|
│ └── backer.freebsd # FreeBSD rc.d
|
|
├── test_data/ # Test fixtures
|
|
├── data/config_example.json # Example config
|
|
├── Makefile # Build targets
|
|
├── README.md # User documentation
|
|
└── LICENSE # MIT License
|
|
```
|
|
|
|
## Config Options
|
|
| Option | Default | Description |
|
|
|--------|---------|-------------|
|
|
| address | 0.0.0.0 | Listen address |
|
|
| port | 8086 | Listen port |
|
|
| cert/key | - | TLS cert/key paths (required when nohttps=false) |
|
|
| nohttps | false | Disable HTTPS |
|
|
| location | /archive | API endpoint path |
|
|
| user | - | Basic auth username (required) |
|
|
| password | - | Basic auth password (required) |
|
|
| log | stderr | Log output |
|
|
| loglevel | info | Log verbosity (debug/info/warn/error) |
|
|
| directories | - | Directories to backup (required) |
|
|
| backup_timeout | 60 | Stream timeout in minutes (1-1440) |
|
|
| dir_scan_timeout | 60 | Directory scan timeout in minutes (1-1440) |
|
|
| compression_level | 9 | Compression 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, xz) |
|
|
| compression_algorithm | gzip | Alias for default_compression (deprecated) |
|
|
|
|
## Compression Level Mapping
|
|
|
|
The `compression_level` config option (1-9) is mapped differently for each algorithm:
|
|
|
|
| Algorithm | Level Mapping |
|
|
|-----------|---------------|
|
|
| gzip/pgzip/bzip2 | 1-9 directly maps to compression levels |
|
|
| lz4 | 1-3 → Fastest (0), 4-6 → Default (1), 7-9 → Best (2) |
|
|
| zstd | 1-3 → Fastest, 4-6 → Default, 7-9 → BetterCompression |
|
|
| xz | Uses default compression (no configurable level) |
|
|
|
|
## Key Patterns
|
|
|
|
### Global Config
|
|
Intentional design - uses global `C` variable:
|
|
```go
|
|
var C Config // internal/backer/globals.go
|
|
```
|
|
|
|
### Config Parsing
|
|
HJSON requires double conversion (hjson → map → json → struct):
|
|
```go
|
|
hjson.Unmarshal(buf, &tmp) // tmp is map[string]any
|
|
json.Marshal(tmp)
|
|
json.Unmarshal(buf, &C)
|
|
```
|
|
|
|
### 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, computes MD5 hash
|
|
|
|
```go
|
|
func writeWithContext(ctx context.Context, fn func() error) error
|
|
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, string, error)
|
|
```
|
|
|
|
### HTTP Server Middleware
|
|
Two middleware functions wrap the handler:
|
|
- `withServerHeader` — adds "Server: backer" header to all responses
|
|
- `withStatusLogging` — logs 404 and 405 responses
|
|
|
|
```go
|
|
Handler: withServerHeader(withStatusLogging(mux))
|
|
```
|
|
|
|
### Unmatched Route Logging
|
|
The server wraps http.ResponseWriter to capture status codes:
|
|
- **404 Not Found**: `"Not Found from {clientIP} to {path}"`
|
|
- **405 Method Not Allowed**: `"Method Not Allowed from {clientIP} for {method} {path}"`
|
|
|
|
Uses `responseWriterWrapper` that tracks status code via WriteHeader() method.
|
|
|
|
### Pipe Error Handling
|
|
Uses `pipeErr` tracking to avoid noisy errors when reader closes early.
|
|
Skips files that can't be read (logs warning, continues backup).
|
|
- `isPipeClosedError()` checks for "io: read/write on closed pipe" errors
|
|
|
|
### Content-Disposition Filename
|
|
Backup filenames include timestamp and configured prefix: `backup-20060102-150405.tar.gz` (extension depends on compression_algorithm).
|
|
|
|
### Special File Types
|
|
- **Regular files**: streamed into tar archive with content
|
|
- **Directories**: included as tar directory entries
|
|
- **Symlinks**: stored as symlink entries (target path preserved) - uses os.Lstat, no cycles possible
|
|
- **Broken symlinks**: archived with their target path (even if target doesn't exist)
|
|
- **Device files** (char/block): included with correct major/minor numbers
|
|
- **Named pipes (FIFOs)**: included (header only, no data)
|
|
- **Sockets**: skipped (not archivable)
|
|
- **Hard links**: deduplicated - first occurrence stored with content, subsequent as link entries
|
|
- **GNU tar format**: uses `header.Format = tar.FormatGNU` for GNU tar compatibility
|
|
|
|
### Test Coverage
|
|
All entity types are tested: regular files, empty files, directories, symlinks, broken symlinks, symlinks to directories, hard links, device files, named pipes (FIFOs), special characters in filenames.
|
|
|
|
### Error Recovery
|
|
- `writeWithContext` checks context before calling fn.
|
|
- `copyWithContext` does buffered copy with cancellation checks.
|
|
|
|
### Client IP Detection
|
|
Checks `X-Forwarded-For` header first (for proxied requests), then falls back to `RemoteAddr`.
|
|
|
|
### TLS Error Handling
|
|
Uses `ServerWrapper` to intercept and classify errors from Serve() and ServeTLS():
|
|
- TLS-specific errors logged at debug level to reduce log clutter
|
|
- Uses errors.As to detect: tls.AlertError, tls.CertificateVerificationError, tls.ECHRejectionError, tls.RecordHeaderError
|
|
|
|
### HTTP Routing & Compression Selection
|
|
The server uses http.NewServeMux() for routing. Multiple routes support different compression formats:
|
|
```go
|
|
mux.Handle(C.Location, http.HandlerFunc(backupHandler)) // /archive
|
|
mux.Handle(C.Location+".tar.gz", http.HandlerFunc(backupHandler)) // archive.tar.gz
|
|
mux.Handle(C.Location+".tar.xz", http.HandlerFunc(backupHandler)) // archive.tar.xz
|
|
mux.Handle(C.Location+".tar.bz2", http.HandlerFunc(backupHandler)) // archive.tar.bz2
|
|
// ... etc (.tar.lz4, .tar.zst, .tar.zstd)
|
|
```
|
|
|
|
Compression algorithm determined by path extension using `path.Ext()`:
|
|
- Full path: `/archive.tar.xz` → `.xz` → xz
|
|
- Bare extension: `tar.xz` → `.xz` → xz
|
|
- Default when no extension matches
|
|
|
|
### Performance Notes
|
|
- Exclude patterns are precompiled after config load for faster matching.
|
|
- Compression level is configurable (1-9); default 9 uses more CPU.
|
|
- Large directory walks use `filepath.WalkDir` (efficient).
|
|
- Context cancellation checked on each file during directory scan.
|
|
|
|
## Coding Style & Conventions
|
|
|
|
### Function Naming
|
|
```go
|
|
// Exported (public): VerbNoun pattern
|
|
NewServer()
|
|
LoadConfig()
|
|
CreateTarGzStream()
|
|
GetFilesFromDirectories()
|
|
|
|
// Unexported (private): verbNoun or isNoun pattern
|
|
writeWithContext()
|
|
copyWithContext()
|
|
isPipeClosedError()
|
|
isExcluded()
|
|
defaultConfigPath()
|
|
```
|
|
|
|
### JSON Tags
|
|
Struct fields use PascalCase, json tags use snake_case:
|
|
```go
|
|
BackupTimeout int `json:"backup_timeout"`
|
|
CompressionLevel int `json:"compression_level"`
|
|
DirScanTimeout int `json:"dir_scan_timeout"`
|
|
```
|
|
|
|
### Error Messages
|
|
- Start with capital letter (ST1005 disabled)
|
|
- Use past tense: `"Failed to..."`, `"Unable to..."`
|
|
- Include context: `fmt.Errorf("Failed to close %s: %w", name, err)`
|
|
- Use `%v` for errors (not `%s`)
|
|
|
|
### Logging
|
|
- Past tense: `"Failed to close gzip writer: %v", err`
|
|
- Context included: `"Skipping %s: %v", filename, err`
|
|
- Appropriate level: Warn for recoverable, Error for issues, Fatal for startup failures
|
|
- Log levels: error, warn, info, debug
|
|
|
|
### Config Validation Pattern
|
|
For each config option: set default first, then immediately validate:
|
|
```go
|
|
// Port: set default, then validate range.
|
|
if C.Port == 0 {
|
|
C.Port = 8086
|
|
log.Warnf("Config option port is not set, fallback to %d", C.Port)
|
|
}
|
|
|
|
if C.Port < 1 || C.Port > 65535 {
|
|
return fmt.Errorf("Config option port must be between 1 and 65535, got %d", C.Port)
|
|
}
|
|
```
|
|
|
|
### Go Formatting
|
|
- Tabs for indentation
|
|
- Vim modeline at EOF: `/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
|
|
|
### Linter Suppressions (intentional)
|
|
| Rule | Reason |
|
|
|------|--------|
|
|
| ST1005 | Capitalized error messages for readability |
|
|
| QF1001 | De Morgan's law disabled for auth condition clarity |
|
|
| revive (error strings) | Same reason as ST1005 |
|
|
|
|
### Comment Style
|
|
- Profanity allowed (pet project)
|
|
- Use `//nolint:` only when absolutely necessary
|
|
|
|
## Testing
|
|
```bash
|
|
go test ./... # Run tests (runs make clean first)
|
|
go test -v -run TestName ./... # Run specific test
|
|
golangci-lint run ./... # Lint
|
|
make test # Clean + test (deterministic)
|
|
```
|
|
|
|
Coverage: ~64% on internal/backer (~100% on internal/log)
|
|
|
|
### Test Data
|
|
All test data is created by `TestMain` in `internal/backer/main_test.go` before tests run and cleaned up afterward. This ensures tests are independent of pre-existing files.
|
|
|
|
- `test_data/test1/foo/` — test directory with files, subdirs, empty_dir
|
|
- `test_data/test1/bar/` — test directory with files and a directory named `goodbye.txt/` (edge case: directory with file extension)
|
|
- `test_data/test1/hardlinks/` — test directory with hard-linked files (original.txt, hardlink1.txt, hardlink2.txt)
|
|
- `test_data/test1/symlinks/` — test directory with symbolic links
|
|
- `test_data/test1/empty_dir/` — empty directory for testing
|
|
- `test_data/tmp/` — reserved for test temporary files (use instead of system `/tmp`)
|
|
|
|
### Test Conventions
|
|
- Reset global config: `C = Config{}`
|
|
- Reset exclude patterns: `excludePatterns = nil`
|
|
- Use `t.TempDir()` for temporary files
|
|
- Helper function `writeTempConfig()` for config tests
|
|
- Test data created by TestMain setup, cleaned after tests
|
|
- Always check errors with meaningful diagnostics (use `t.Fatalf` for setup errors)
|
|
|
|
## Build
|
|
```bash
|
|
make # Clean + build binary (version from git or "dev")
|
|
make build VERSION=0.1.0 # Build with specific version
|
|
make clean # Remove binary, test_data/tmp, test_data/test1
|
|
make test # Clean + test (deterministic)
|
|
make upgrade # Update dependencies
|
|
make help # Show targets
|
|
```
|
|
|
|
### Versioning
|
|
Version is injected at build time via ldflags:
|
|
```go
|
|
var version = "dev" // overridden by: -X main.version=$(VERSION)
|
|
```
|
|
|
|
## Request Flow (Good Path)
|
|
```
|
|
Request → Method Check (GET) → Auth Check → Get Files (with timeout) → Create Archive → Stream → Complete
|
|
```
|
|
|
|
## Request Flow (Error Paths)
|
|
| Request | Response | Logged |
|
|
|---------|----------|--------|
|
|
| Non-GET method | 405 Method Not Allowed | Yes (info) |
|
|
| No auth | 401 Unauthorized | Yes (info) |
|
|
| Wrong credentials | 401 Unauthorized | Yes (info) |
|
|
| Unknown path | 404 Not Found | Yes (info) |
|
|
| Dir scan timeout | 504 Request Timeout | Yes (error) |
|
|
| Failed to get files | 500 Internal Server Error | Yes (error) |
|
|
| Client disconnect | - | Yes (info) |
|
|
| Backup completed | 200 OK | Yes (info) |
|