check / check (push) Canceled after 0s
With TRUSTED_PROXIES unset, the server now trusts reverse proxies in the private ranges of RFC 1918. A proxy such as traefik on a Docker network is then believed. /ip/, the home page's curl card and the request log give the client's address from X-Forwarded-For instead of the proxy's address. A list in TRUSTED_PROXIES replaces the default. TRUSTED_PROXIES set but empty trusts no proxy, so the config package reads it with os.LookupEnv. A direct client at a public address still cannot choose its address with a forged header. Model: opus-5-5
222 lines
5.9 KiB
Go
222 lines
5.9 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
|
|
)
|
|
|
|
// The private address ranges of RFC 1918. The HTTP server trusts a reverse
|
|
// proxy in any of them when TRUSTED_PROXIES is not set.
|
|
const (
|
|
privateRange10 = "10.0.0.0/8"
|
|
privateRange172 = "172.16.0.0/12"
|
|
privateRange192 = "192.168.0.0/16"
|
|
)
|
|
|
|
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 reverse proxies the HTTP server trusts. When
|
|
// TRUSTED_PROXIES is not set, they are the private ranges of RFC 1918. When it
|
|
// is set, they are the addresses and CIDR ranges it lists, such as
|
|
// "10.0.0.0/8,192.0.2.1", and none when it is empty. An address is returned as
|
|
// a range holding only that address.
|
|
func getTrustedProxies() ([]netip.Prefix, error) {
|
|
value, set := os.LookupEnv("TRUSTED_PROXIES")
|
|
if !set {
|
|
return []netip.Prefix{
|
|
netip.MustParsePrefix(privateRange10),
|
|
netip.MustParsePrefix(privateRange172),
|
|
netip.MustParsePrefix(privateRange192),
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|