Files
routewatch/internal/config/config.go
T
sneak 293d0aec23
check / check (push) Canceled after 0s
Add the HTTP hardening the policy requires before 1.0 (closes #108)
Every answer carries the six security headers REPO_POLICIES.md lists.
The Content-Security-Policy allows inline content only with a nonce made
for that answer; the pages put it on their style and script blocks and
drop their four style attributes.

The request log and /ip/ take a forwarded address only from a proxy
listed in TRUSTED_PROXIES, which stops the start when set but invalid:
the last X-Forwarded-For entry that is not a trusted proxy. X-Real-IP
and True-Client-IP are no longer read.

The JSON routes and the health check send fixed messages in place of
database, parser and resolver errors, and log the detail.

Model: opus-5-5
2026-10-19 23:21:43 +00:00

204 lines
5.4 KiB
Go

// Package config provides centralized configuration management for RouteWatch
package config
import (
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
const (
// AppIdentifier is the reverse domain name identifier for the app
AppIdentifier = "berlin.sneak.app.routewatch"
// dirPermissions for creating directories
dirPermissions = 0750 // rwxr-x---
// defaultRouteExpirationMinutes is the default route expiration timeout in minutes
defaultRouteExpirationMinutes = 5
// defaultPort is the HTTP port used when PORT is not set
defaultPort = 8080
// maxPort is the highest TCP port number
maxPort = 65535
)
var (
errRelativeXDGDataHome = errors.New("XDG_DATA_HOME must be an absolute path")
errUnsupportedOS = errors.New("unsupported operating system")
errInvalidPort = errors.New("PORT must be a whole number")
errInvalidProxy = errors.New(
"TRUSTED_PROXIES must list IP addresses and CIDR ranges, comma-separated")
)
// Config holds configuration for the entire application
type Config struct {
// StateDir is the directory for all application state (database, snapshots)
StateDir string
// Port is the TCP port the HTTP server listens on
Port int
// TrustedProxies are the reverse proxies whose X-Forwarded-For header
// the HTTP server believes
TrustedProxies []netip.Prefix
// MaxRuntime is the maximum runtime (0 = run forever)
MaxRuntime time.Duration
// EnableBatchedDatabaseWrites enables batched database operations for
// better performance
EnableBatchedDatabaseWrites bool
// RouteExpirationTimeout is how long a route can go without being
// refreshed before expiring
// Default is 2 hours which is conservative for BGP (typical BGP hold
// time is 90-180 seconds)
RouteExpirationTimeout time.Duration
}
// New creates a new Config with default paths based on the OS
func New() (*Config, error) {
stateDir, err := getStateDirectory()
if err != nil {
return nil, fmt.Errorf("failed to determine state directory: %w", err)
}
port, err := getPort()
if err != nil {
return nil, err
}
trustedProxies, err := getTrustedProxies()
if err != nil {
return nil, err
}
return &Config{
StateDir: stateDir,
Port: port,
TrustedProxies: trustedProxies,
// Run forever by default
MaxRuntime: 0,
// Enable batching by default
EnableBatchedDatabaseWrites: true,
// For active route monitoring
RouteExpirationTimeout: defaultRouteExpirationMinutes * time.Minute,
}, nil
}
// GetStateDir returns the state directory path
func (c *Config) GetStateDir() string {
return c.StateDir
}
// getStateDirectory returns the appropriate state directory based on the OS
func getStateDirectory() (string, error) {
switch runtime.GOOS {
case "darwin":
// macOS: ~/Library/Application Support/berlin.sneak.app.routewatch
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Library", "Application Support", AppIdentifier), nil
case "linux", "freebsd", "openbsd", "netbsd":
// The XDG spec requires an absolute path; a relative one would put
// the database somewhere unexpected.
xdgData := os.Getenv("XDG_DATA_HOME")
if xdgData != "" && !filepath.IsAbs(xdgData) {
return "", fmt.Errorf("%w, got %q", errRelativeXDGDataHome, xdgData)
}
// Unix-like: /var/lib/berlin.sneak.app.routewatch if root, else XDG_DATA_HOME
if os.Geteuid() == 0 {
return filepath.Join("/var/lib", AppIdentifier), nil
}
// Check XDG_DATA_HOME first
if xdgData != "" {
return filepath.Join(xdgData, AppIdentifier), nil
}
// Fall back to ~/.local/share/berlin.sneak.app.routewatch
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", AppIdentifier), nil
default:
return "", fmt.Errorf("%w: %s", errUnsupportedOS, runtime.GOOS)
}
}
// getPort returns the HTTP port from PORT, or defaultPort when PORT is not set
func getPort() (int, error) {
value := os.Getenv("PORT")
if value == "" {
return defaultPort, nil
}
// ParseUint, unlike Atoi, refuses a sign: the health check URL cannot use "+9090"
port, err := strconv.ParseUint(value, 10, 0)
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf("%w from 1 to %d, got %q",
errInvalidPort, maxPort, value)
}
return int(port), nil
}
// getTrustedProxies returns the addresses and CIDR ranges listed in
// TRUSTED_PROXIES, such as "10.0.0.0/8,192.0.2.1", or none when it is not set.
// An address is returned as a range holding only that address.
func getTrustedProxies() ([]netip.Prefix, error) {
value := os.Getenv("TRUSTED_PROXIES")
if value == "" {
return nil, nil
}
var proxies []netip.Prefix
for entry := range strings.SplitSeq(value, ",") {
entry = strings.TrimSpace(entry)
addr, err := netip.ParseAddr(entry)
if err == nil {
proxies = append(proxies, netip.PrefixFrom(addr, addr.BitLen()))
continue
}
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, fmt.Errorf("%w, got %q", errInvalidProxy, entry)
}
proxies = append(proxies, prefix)
}
return proxies, nil
}
// EnsureDirectories creates all necessary directories if they don't exist
func (c *Config) EnsureDirectories() error {
// Ensure state directory exists
err := os.MkdirAll(c.StateDir, dirPermissions)
if err != nil {
return fmt.Errorf("failed to create state directory: %w", err)
}
return nil
}