90eb56465b
- Updated `.env.example` to include new environment variables for server access logging and security settings, ensuring comprehensive configuration options. - Revised `docker-entrypoint.sh` to clarify the source of supported environment variables. - Improved `README.md` to reflect the latest environment variable changes and maintain synchronization with the canonical list in `internal/config/config.go`. - Added tests to ensure documentation consistency across `.env.example` and `README.md`, preventing future discrepancies.
748 lines
27 KiB
Go
748 lines
27 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const (
|
|
defaultListenAddr = ":8080"
|
|
defaultReadHeaderTimeout = "5s"
|
|
defaultReadTimeout = "30s"
|
|
defaultWriteTimeout = "30s"
|
|
defaultIdleTimeout = "120s"
|
|
defaultShutdownTimeout = "10s"
|
|
defaultMaxHeaderBytes = 1 << 20
|
|
defaultAccessLogEnabled = true
|
|
defaultAccessLogMaxSize = "100MiB"
|
|
defaultAccessLogMaxBackups = 5
|
|
defaultMaxUploadSize = "100MiB"
|
|
defaultTTL = "15m"
|
|
defaultRawCacheMaxSize = "200MiB"
|
|
defaultRawCacheMaxEntries = 32
|
|
defaultDataDir = "./data"
|
|
defaultCleanupInterval = "1m"
|
|
dataDirEncryptionKeyFilename = "metadata.key"
|
|
defaultUIRateLimitEnabled = false
|
|
defaultAPIReadRateLimitEnabled = false
|
|
defaultAPIWriteRateLimitEnabled = true
|
|
defaultUIRequestsPerMinute = 360
|
|
defaultUIRateLimitBurst = 120
|
|
defaultAPIReadRequestsPerMinute = 300
|
|
defaultAPIReadRateLimitBurst = 100
|
|
defaultAPIWriteRequestsPerMinute = 30
|
|
defaultAPIWriteRateLimitBurst = 10
|
|
defaultHSTSEnabled = true
|
|
maxDefaultTTLLimit = 24 * time.Hour
|
|
minCleanupInterval = 10 * time.Second
|
|
envPrefix = "SCRATCHBOX_"
|
|
)
|
|
|
|
// envSuffixes is the single source of truth for every SCRATCHBOX_* environment
|
|
// variable suffix supported by applyEnvOverrides (without the "SCRATCHBOX_" prefix).
|
|
//
|
|
// Usage sites that hardcode or document these vars MUST have a comment pointing here:
|
|
// - applyEnvOverrides (this file)
|
|
// - printEnvStartup + flag docs in cmd/scratchbox/server.go
|
|
// - docker-entrypoint.sh
|
|
// - .env.example
|
|
// - README.md (supported list + example block)
|
|
// - tests that setenv them (e.g. config_test.go, main_test.go)
|
|
//
|
|
// When you add, remove, or rename support in applyEnvOverrides (or the corresponding
|
|
// struct fields), you MUST:
|
|
// 1. Update this list.
|
|
// 2. Add/update the pointer comment at every usage site.
|
|
// 3. Update .env.example with the new var (plus a sensible default + comment).
|
|
// 4. Update the "Environment variables supported by..." list in README.md.
|
|
// 5. Update the example .env block in README.md.
|
|
//
|
|
// A test in config_test.go will fail if .env.example or README.md are missing any
|
|
// entry from this list. This prevents the documentation drift that has happened
|
|
// in the past when new config fields (e.g. shutdown_timeout, access_log details)
|
|
// were added.
|
|
var envSuffixes = []string{
|
|
"SERVER_LISTEN_ADDR",
|
|
"SERVER_READ_HEADER_TIMEOUT",
|
|
"SERVER_READ_TIMEOUT",
|
|
"SERVER_WRITE_TIMEOUT",
|
|
"SERVER_IDLE_TIMEOUT",
|
|
"SERVER_SHUTDOWN_TIMEOUT",
|
|
"SERVER_MAX_HEADER_BYTES",
|
|
"SERVER_ACCESS_LOG_ENABLED",
|
|
"SERVER_ACCESS_LOG_FILE_PATH",
|
|
"SERVER_ACCESS_LOG_MAX_SIZE",
|
|
"SERVER_ACCESS_LOG_MAX_BACKUPS",
|
|
"LIMITS_MAX_UPLOAD_SIZE",
|
|
"LIMITS_DEFAULT_TTL",
|
|
"LIMITS_RAW_CACHE_MAX_SIZE",
|
|
"LIMITS_RAW_CACHE_MAX_ENTRIES",
|
|
"STORAGE_DATA_DIR",
|
|
"STORAGE_CLEANUP_INTERVAL",
|
|
"SECURITY_ALLOWED_IPS",
|
|
"SECURITY_TRUST_PROXY_HEADERS",
|
|
"SECURITY_TRUSTED_PROXY_IPS",
|
|
"SECURITY_RATE_LIMIT_UI_ENABLED",
|
|
"SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE",
|
|
"SECURITY_RATE_LIMIT_UI_BURST",
|
|
"SECURITY_RATE_LIMIT_API_READ_ENABLED",
|
|
"SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE",
|
|
"SECURITY_RATE_LIMIT_API_READ_BURST",
|
|
"SECURITY_RATE_LIMIT_API_WRITE_ENABLED",
|
|
"SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE",
|
|
"SECURITY_RATE_LIMIT_API_WRITE_BURST",
|
|
"SECURITY_HSTS_ENABLED",
|
|
}
|
|
|
|
type Config struct {
|
|
Server ServerConfig `yaml:"server" comment:"HTTP listener settings."`
|
|
Limits LimitsConfig `yaml:"limits" comment:"Upload, expiration, and raw cache defaults."`
|
|
Storage StorageConfig `yaml:"storage" comment:"Filesystem storage location and cleanup cadence."`
|
|
Security SecurityConfig `yaml:"security" comment:"Write-route IP restrictions, rate limiting, and HSTS."`
|
|
}
|
|
|
|
type ServerConfig struct {
|
|
ListenAddr string `yaml:"listen_addr" comment:"Address to bind the HTTP server to."`
|
|
ReadHeaderTimeout string `yaml:"read_header_timeout" comment:"Maximum duration for reading request headers (for slowloris protection). Must be > 0."`
|
|
ReadTimeout string `yaml:"read_timeout" comment:"Maximum duration for reading the full request, including body. Use 0 to disable (recommended when limits.max_upload_size is multi-GiB or clients are slow). Must be >= 0."`
|
|
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response (also bounds slow handlers after headers are read). Use 0 to disable (recommended for large uploads/downloads). Must be >= 0."`
|
|
IdleTimeout string `yaml:"idle_timeout" comment:"Maximum amount of time to wait for the next request when keep-alives are enabled. Must be > 0."`
|
|
ShutdownTimeout string `yaml:"shutdown_timeout" comment:"Graceful shutdown timeout for the HTTP server. Must be > 0."`
|
|
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
|
|
AccessLog AccessLogConfig `yaml:"access_log" comment:"Access log settings for all HTTP requests."`
|
|
ReadHeaderTimeoutDur time.Duration `yaml:"-"`
|
|
ReadTimeoutDur time.Duration `yaml:"-"`
|
|
WriteTimeoutDur time.Duration `yaml:"-"`
|
|
IdleTimeoutDur time.Duration `yaml:"-"`
|
|
ShutdownTimeoutDur time.Duration `yaml:"-"`
|
|
}
|
|
|
|
type AccessLogConfig struct {
|
|
Enabled bool `yaml:"enabled" comment:"Enable HTTP access logs."`
|
|
FilePath string `yaml:"file_path" comment:"Optional file path for access logs. When set, logs are tee'd to stdout and this file."`
|
|
MaxSize string `yaml:"max_size" comment:"Maximum size for access log file before rotation. Supports B, KB, MB, GB and KiB, MiB, GiB."`
|
|
MaxBackups int `yaml:"max_backups" comment:"Number of rotated access log files to keep."`
|
|
MaxSizeBytes int64 `yaml:"-"`
|
|
}
|
|
|
|
type LimitsConfig struct {
|
|
MaxUploadSize string `yaml:"max_upload_size" comment:"Maximum upload request body size. Supports B, KB, MB, GB and KiB, MiB, GiB (case-insensitive). Defaults to bytes when unit is omitted (example: 1048576)."`
|
|
DefaultTTL string `yaml:"default_ttl" comment:"Default expiration for new scratches. Must be > 0 and <= 24h."`
|
|
RawCacheMaxSize string `yaml:"raw_cache_max_size" comment:"Maximum total decompressed bytes cached in memory for /api/raw range requests. Supports B, KB, MB, GB and KiB, MiB, GiB. Set this above limits.max_upload_size based on your expected concurrent large downloads."`
|
|
RawCacheMaxEntries int `yaml:"raw_cache_max_entries" comment:"Maximum number of decompressed /api/raw entries retained in memory cache. Use this as a secondary cap alongside raw_cache_max_size."`
|
|
MaxUploadSizeBytes int64 `yaml:"-"`
|
|
DefaultTTLDuration time.Duration `yaml:"-"`
|
|
RawCacheMaxBytes int64 `yaml:"-"`
|
|
}
|
|
|
|
type StorageConfig struct {
|
|
DataDir string `yaml:"data_dir" comment:"Directory where scratch files and metadata are stored."`
|
|
CleanupInterval string `yaml:"cleanup_interval" comment:"How often expired scratches are deleted (minimum 10s)."`
|
|
CleanupIntervalParsed time.Duration `yaml:"-"`
|
|
EncryptionKeyBytes []byte `yaml:"-"`
|
|
}
|
|
|
|
type SecurityConfig struct {
|
|
AllowedIPs []string `yaml:"allowed_ips" comment:"Allowlist for POST /api/scratch as IPs or CIDRs (examples: 127.0.0.1, 192.168.1.0/24). Keep 127.0.0.1 for local access; add trusted ranges as needed. Empty means unrestricted."`
|
|
TrustProxyHeaders bool `yaml:"trust_proxy_headers" comment:"Honor X-Forwarded-For / X-Real-IP only when request source IP matches security.trusted_proxy_ips."`
|
|
TrustedProxyIPs []string `yaml:"trusted_proxy_ips" comment:"IPs/CIDRs of reverse proxies allowed to supply forwarded client IP headers. Required when trust_proxy_headers is true. Must be the *immediate* proxies only (never 0/0 or untrusted ranges)."`
|
|
RateLimitUI RateLimitConfig `yaml:"rate_limit_ui" comment:"Per-client token bucket on UI routes (/, /u, /s/{id})."`
|
|
RateLimitAPIRead RateLimitConfig `yaml:"rate_limit_api_read" comment:"Per-client token bucket on API read routes (GET /api/config, GET /api/scratch/{id}, GET /api/raw/{id})."`
|
|
RateLimitAPIWrite RateLimitConfig `yaml:"rate_limit_api_write" comment:"Per-client token bucket on API write route (POST /api/scratch)."`
|
|
HSTSEnabled bool `yaml:"hsts_enabled" comment:"Enable Strict-Transport-Security (HSTS) header. Recommended when behind a TLS-terminating reverse proxy for public deployments (default: true). Set to false for local-only HTTP use without proxies."`
|
|
AllowedPrefixes []netip.Prefix `yaml:"-"`
|
|
TrustedProxyPrefixes []netip.Prefix `yaml:"-"`
|
|
}
|
|
|
|
type RateLimitConfig struct {
|
|
Enabled bool `yaml:"enabled" comment:"Enable per-client rate limiting for write and scratch read routes."`
|
|
RequestsPerMinute int `yaml:"requests_per_minute" comment:"Steady refill rate per client IP."`
|
|
Burst int `yaml:"burst" comment:"Maximum immediate burst capacity per client IP."`
|
|
}
|
|
|
|
func Load(path string) (Config, error) {
|
|
cfg := defaults()
|
|
if path != "" {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("read config: %w", err)
|
|
}
|
|
if err := yaml.Unmarshal(b, &cfg); err != nil {
|
|
return Config{}, fmt.Errorf("parse config yaml: %w", err)
|
|
}
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func LoadFromEnv() (Config, error) {
|
|
cfg := defaults()
|
|
if err := cfg.applyEnvOverrides(envPrefix); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func Default() Config {
|
|
return defaults()
|
|
}
|
|
|
|
func defaults() Config {
|
|
return Config{
|
|
Server: ServerConfig{
|
|
ListenAddr: defaultListenAddr,
|
|
ReadHeaderTimeout: defaultReadHeaderTimeout,
|
|
ReadTimeout: defaultReadTimeout,
|
|
WriteTimeout: defaultWriteTimeout,
|
|
IdleTimeout: defaultIdleTimeout,
|
|
ShutdownTimeout: defaultShutdownTimeout,
|
|
MaxHeaderBytes: defaultMaxHeaderBytes,
|
|
AccessLog: AccessLogConfig{
|
|
Enabled: defaultAccessLogEnabled,
|
|
MaxSize: defaultAccessLogMaxSize,
|
|
MaxBackups: defaultAccessLogMaxBackups,
|
|
},
|
|
},
|
|
Limits: LimitsConfig{
|
|
MaxUploadSize: defaultMaxUploadSize,
|
|
DefaultTTL: defaultTTL,
|
|
RawCacheMaxSize: defaultRawCacheMaxSize,
|
|
RawCacheMaxEntries: defaultRawCacheMaxEntries,
|
|
},
|
|
Storage: StorageConfig{
|
|
DataDir: defaultDataDir,
|
|
CleanupInterval: defaultCleanupInterval,
|
|
},
|
|
Security: SecurityConfig{
|
|
AllowedIPs: []string{
|
|
"127.0.0.1",
|
|
},
|
|
RateLimitUI: RateLimitConfig{
|
|
Enabled: defaultUIRateLimitEnabled,
|
|
RequestsPerMinute: defaultUIRequestsPerMinute,
|
|
Burst: defaultUIRateLimitBurst,
|
|
},
|
|
RateLimitAPIRead: RateLimitConfig{
|
|
Enabled: defaultAPIReadRateLimitEnabled,
|
|
RequestsPerMinute: defaultAPIReadRequestsPerMinute,
|
|
Burst: defaultAPIReadRateLimitBurst,
|
|
},
|
|
RateLimitAPIWrite: RateLimitConfig{
|
|
Enabled: defaultAPIWriteRateLimitEnabled,
|
|
RequestsPerMinute: defaultAPIWriteRequestsPerMinute,
|
|
Burst: defaultAPIWriteRateLimitBurst,
|
|
},
|
|
HSTSEnabled: defaultHSTSEnabled,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Config) Validate() error {
|
|
if strings.TrimSpace(c.Server.ListenAddr) == "" {
|
|
return errors.New("server.listen_addr is required")
|
|
}
|
|
readHeaderTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ReadHeaderTimeout))
|
|
if err != nil {
|
|
return fmt.Errorf("server.read_header_timeout invalid: %w", err)
|
|
}
|
|
if readHeaderTimeout <= 0 {
|
|
return errors.New("server.read_header_timeout must be > 0")
|
|
}
|
|
c.Server.ReadHeaderTimeoutDur = readHeaderTimeout
|
|
|
|
readTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ReadTimeout))
|
|
if err != nil {
|
|
return fmt.Errorf("server.read_timeout invalid: %w", err)
|
|
}
|
|
// 0 means no timeout (matches net/http.Server.ReadTimeout). Negative is invalid.
|
|
if readTimeout < 0 {
|
|
return errors.New("server.read_timeout must be >= 0")
|
|
}
|
|
c.Server.ReadTimeoutDur = readTimeout
|
|
|
|
writeTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.WriteTimeout))
|
|
if err != nil {
|
|
return fmt.Errorf("server.write_timeout invalid: %w", err)
|
|
}
|
|
// 0 means no timeout (matches net/http.Server.WriteTimeout). Negative is invalid.
|
|
if writeTimeout < 0 {
|
|
return errors.New("server.write_timeout must be >= 0")
|
|
}
|
|
c.Server.WriteTimeoutDur = writeTimeout
|
|
|
|
idleTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.IdleTimeout))
|
|
if err != nil {
|
|
return fmt.Errorf("server.idle_timeout invalid: %w", err)
|
|
}
|
|
if idleTimeout <= 0 {
|
|
return errors.New("server.idle_timeout must be > 0")
|
|
}
|
|
c.Server.IdleTimeoutDur = idleTimeout
|
|
|
|
shutdownTimeout, err := time.ParseDuration(strings.TrimSpace(c.Server.ShutdownTimeout))
|
|
if err != nil {
|
|
return fmt.Errorf("server.shutdown_timeout invalid: %w", err)
|
|
}
|
|
if shutdownTimeout <= 0 {
|
|
return errors.New("server.shutdown_timeout must be > 0")
|
|
}
|
|
c.Server.ShutdownTimeoutDur = shutdownTimeout
|
|
|
|
if c.Server.MaxHeaderBytes <= 0 {
|
|
return errors.New("server.max_header_bytes must be > 0")
|
|
}
|
|
accessLogMaxSizeBytes, err := parseHumanSize(c.Server.AccessLog.MaxSize)
|
|
if err != nil {
|
|
return fmt.Errorf("server.access_log.max_size invalid: %w", err)
|
|
}
|
|
c.Server.AccessLog.MaxSizeBytes = accessLogMaxSizeBytes
|
|
if c.Server.AccessLog.MaxBackups <= 0 {
|
|
return errors.New("server.access_log.max_backups must be > 0")
|
|
}
|
|
|
|
sizeBytes, err := parseHumanSize(c.Limits.MaxUploadSize)
|
|
if err != nil {
|
|
return fmt.Errorf("limits.max_upload_size invalid: %w", err)
|
|
}
|
|
c.Limits.MaxUploadSizeBytes = sizeBytes
|
|
|
|
ttl, err := time.ParseDuration(strings.TrimSpace(c.Limits.DefaultTTL))
|
|
if err != nil {
|
|
return fmt.Errorf("limits.default_ttl invalid: %w", err)
|
|
}
|
|
if ttl <= 0 || ttl > maxDefaultTTLLimit {
|
|
return fmt.Errorf("limits.default_ttl must be in (0, %s]", maxDefaultTTLLimit)
|
|
}
|
|
c.Limits.DefaultTTLDuration = ttl
|
|
|
|
rawCacheBytes, err := parseHumanSize(c.Limits.RawCacheMaxSize)
|
|
if err != nil {
|
|
return fmt.Errorf("limits.raw_cache_max_size invalid: %w", err)
|
|
}
|
|
c.Limits.RawCacheMaxBytes = rawCacheBytes
|
|
if c.Limits.RawCacheMaxEntries <= 0 {
|
|
return errors.New("limits.raw_cache_max_entries must be > 0")
|
|
}
|
|
|
|
cleanupInterval, err := time.ParseDuration(strings.TrimSpace(c.Storage.CleanupInterval))
|
|
if err != nil {
|
|
return fmt.Errorf("storage.cleanup_interval invalid: %w", err)
|
|
}
|
|
if cleanupInterval < minCleanupInterval {
|
|
return fmt.Errorf("storage.cleanup_interval must be at least %s", minCleanupInterval)
|
|
}
|
|
c.Storage.CleanupIntervalParsed = cleanupInterval
|
|
|
|
if strings.TrimSpace(c.Storage.DataDir) == "" {
|
|
return errors.New("storage.data_dir is required")
|
|
}
|
|
// NOTE: directory creation + encryption key provisioning (with auto-gen on empty dir)
|
|
// moved to PrepareDataDir() to keep Validate() free of filesystem side effects.
|
|
// Callers (server startup, tests) must invoke PrepareDataDir after successful Validate/Load
|
|
// if they need EncryptionKeyBytes populated and data dir ensured.
|
|
prefixes, err := parseAllowedPrefixes(c.Security.AllowedIPs)
|
|
if err != nil {
|
|
return fmt.Errorf("security.allowed_ips invalid: %w", err)
|
|
}
|
|
c.Security.AllowedPrefixes = prefixes
|
|
trustedProxyPrefixes, err := parseAllowedPrefixes(c.Security.TrustedProxyIPs)
|
|
if err != nil {
|
|
return fmt.Errorf("security.trusted_proxy_ips invalid: %w", err)
|
|
}
|
|
c.Security.TrustedProxyPrefixes = trustedProxyPrefixes
|
|
if c.Security.TrustProxyHeaders && len(c.Security.TrustedProxyPrefixes) == 0 {
|
|
return errors.New("security.trusted_proxy_ips is required when security.trust_proxy_headers is true")
|
|
}
|
|
|
|
if c.Security.RateLimitUI.RequestsPerMinute <= 0 {
|
|
return errors.New("security.rate_limit_ui.requests_per_minute must be > 0")
|
|
}
|
|
if c.Security.RateLimitUI.Burst <= 0 {
|
|
return errors.New("security.rate_limit_ui.burst must be > 0")
|
|
}
|
|
if c.Security.RateLimitAPIRead.RequestsPerMinute <= 0 {
|
|
return errors.New("security.rate_limit_api_read.requests_per_minute must be > 0")
|
|
}
|
|
if c.Security.RateLimitAPIRead.Burst <= 0 {
|
|
return errors.New("security.rate_limit_api_read.burst must be > 0")
|
|
}
|
|
if c.Security.RateLimitAPIWrite.RequestsPerMinute <= 0 {
|
|
return errors.New("security.rate_limit_api_write.requests_per_minute must be > 0")
|
|
}
|
|
if c.Security.RateLimitAPIWrite.Burst <= 0 {
|
|
return errors.New("security.rate_limit_api_write.burst must be > 0")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PrepareDataDir performs the filesystem side effects that were previously inside Validate:
|
|
// ensures the data dir is creatable/writable (via probe), and ensures the encryption key
|
|
// file exists at data_dir/metadata.key (auto-generating a fresh one only for a truly empty dir).
|
|
// It populates Storage.EncryptionKeyBytes. This keeps Validate pure (checks + duration/size
|
|
// parsing only). Must be called after Load/Validate (or on a manually built Config) before
|
|
// constructing a FilesystemStore that needs the key. Idempotent for subsequent calls if key
|
|
// already present.
|
|
func (c *Config) PrepareDataDir() error {
|
|
if strings.TrimSpace(c.Storage.DataDir) == "" {
|
|
return errors.New("storage.data_dir is required")
|
|
}
|
|
if err := ensureWritableDir(c.Storage.DataDir); err != nil {
|
|
return fmt.Errorf("storage.data_dir not writable: %w", err)
|
|
}
|
|
encKey, err := ensureEncryptionKeyInDataDir(c.Storage.DataDir)
|
|
if err != nil {
|
|
return fmt.Errorf("storage encryption key setup failed: %w", err)
|
|
}
|
|
if len(encKey) != 32 {
|
|
return fmt.Errorf("storage encryption key must decode to 32 bytes, got %d", len(encKey))
|
|
}
|
|
c.Storage.EncryptionKeyBytes = encKey
|
|
return nil
|
|
}
|
|
|
|
func ensureWritableDir(dir string) error {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
probe := filepath.Join(dir, ".write_probe")
|
|
if err := os.WriteFile(probe, []byte("ok"), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return os.Remove(probe)
|
|
}
|
|
|
|
func parseAllowedPrefixes(values []string) ([]netip.Prefix, error) {
|
|
if len(values) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
out := make([]netip.Prefix, 0, len(values))
|
|
for _, value := range values {
|
|
item := strings.TrimSpace(value)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(item, "/") {
|
|
prefix, err := netip.ParsePrefix(item)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse cidr %q: %w", item, err)
|
|
}
|
|
out = append(out, prefix)
|
|
continue
|
|
}
|
|
|
|
ip, err := netip.ParseAddr(item)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse ip %q: %w", item, err)
|
|
}
|
|
out = append(out, netip.PrefixFrom(ip, ip.BitLen()))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseHumanSize(raw string) (int64, error) {
|
|
value := strings.TrimSpace(strings.ToUpper(raw))
|
|
if value == "" {
|
|
return 0, errors.New("size is empty")
|
|
}
|
|
|
|
idx := 0
|
|
for idx < len(value) && (value[idx] == '.' || (value[idx] >= '0' && value[idx] <= '9')) {
|
|
idx++
|
|
}
|
|
if idx == 0 {
|
|
return 0, fmt.Errorf("size %q does not start with a number", raw)
|
|
}
|
|
|
|
numPart := value[:idx]
|
|
unitPart := strings.TrimSpace(value[idx:])
|
|
if unitPart == "" {
|
|
unitPart = "B"
|
|
}
|
|
|
|
number, err := strconv.ParseFloat(numPart, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse number %q: %w", numPart, err)
|
|
}
|
|
if number <= 0 {
|
|
return 0, errors.New("size must be > 0")
|
|
}
|
|
|
|
multipliers := map[string]float64{
|
|
"B": 1,
|
|
"KB": 1_000,
|
|
"MB": 1_000_000,
|
|
"GB": 1_000_000_000,
|
|
"KIB": 1024,
|
|
"MIB": 1024 * 1024,
|
|
"GIB": 1024 * 1024 * 1024,
|
|
}
|
|
|
|
multiplier, ok := multipliers[unitPart]
|
|
if !ok {
|
|
return 0, fmt.Errorf("unsupported unit %q", unitPart)
|
|
}
|
|
|
|
size := int64(number * multiplier)
|
|
if size <= 0 {
|
|
if number > 0 {
|
|
// overflow or truncation from huge float (e.g. 1e20 * GiB); guard before
|
|
// passing surprising/negative limit downstream.
|
|
return 0, errors.New("size too large")
|
|
}
|
|
return 0, errors.New("calculated size must be > 0")
|
|
}
|
|
return size, nil
|
|
}
|
|
|
|
func GenerateEncryptionKey() (string, error) {
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return "", fmt.Errorf("generate encryption key bytes: %w", err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
func ensureEncryptionKeyInDataDir(dataDir string) ([]byte, error) {
|
|
keyPath := filepath.Join(dataDir, dataDirEncryptionKeyFilename)
|
|
keyRaw, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("read %q: %w", keyPath, err)
|
|
}
|
|
hasFiles, checkErr := dataDirContainsFiles(dataDir, keyPath)
|
|
if checkErr != nil {
|
|
return nil, fmt.Errorf("scan %q for existing files: %w", dataDir, checkErr)
|
|
}
|
|
if hasFiles {
|
|
return nil, fmt.Errorf("refusing to generate missing %q because %q contains files; restore the original key file to avoid data corruption", dataDirEncryptionKeyFilename, dataDir)
|
|
}
|
|
generated, genErr := GenerateEncryptionKey()
|
|
if genErr != nil {
|
|
return nil, fmt.Errorf("generate key: %w", genErr)
|
|
}
|
|
if writeErr := os.WriteFile(keyPath, []byte(generated+"\n"), 0o600); writeErr != nil {
|
|
return nil, fmt.Errorf("write %q: %w", keyPath, writeErr)
|
|
}
|
|
return decodeEncryptionKey(generated)
|
|
}
|
|
encKey, err := decodeEncryptionKey(strings.TrimSpace(string(keyRaw)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%q invalid: %w", keyPath, err)
|
|
}
|
|
return encKey, nil
|
|
}
|
|
|
|
func dataDirContainsFiles(dataDir string, keyPath string) (bool, error) {
|
|
keyPath = filepath.Clean(keyPath)
|
|
foundFile := false
|
|
err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if filepath.Clean(path) == keyPath {
|
|
return nil
|
|
}
|
|
foundFile = true
|
|
return fs.SkipAll
|
|
})
|
|
if err != nil && !errors.Is(err, fs.SkipAll) {
|
|
return false, err
|
|
}
|
|
return foundFile, nil
|
|
}
|
|
|
|
func decodeEncryptionKey(raw string) ([]byte, error) {
|
|
if raw == "" {
|
|
return nil, errors.New("key is empty")
|
|
}
|
|
encKey, err := base64.StdEncoding.DecodeString(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid base64: %w", err)
|
|
}
|
|
if len(encKey) != 32 {
|
|
return nil, fmt.Errorf("must decode to 32 bytes, got %d", len(encKey))
|
|
}
|
|
return encKey, nil
|
|
}
|
|
|
|
// applyEnvOverrides applies all supported SCRATCHBOX_* vars.
|
|
// The canonical list of supported suffixes is in envSuffixes (defined earlier in this file).
|
|
// See the comment on envSuffixes for the full maintenance rules (update list + .env.example + README).
|
|
func (c *Config) applyEnvOverrides(prefix string) error {
|
|
if err := applyStringEnv(prefix+"SERVER_LISTEN_ADDR", &c.Server.ListenAddr); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_READ_HEADER_TIMEOUT", &c.Server.ReadHeaderTimeout); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_READ_TIMEOUT", &c.Server.ReadTimeout); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_WRITE_TIMEOUT", &c.Server.WriteTimeout); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_IDLE_TIMEOUT", &c.Server.IdleTimeout); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_SHUTDOWN_TIMEOUT", &c.Server.ShutdownTimeout); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SERVER_MAX_HEADER_BYTES", &c.Server.MaxHeaderBytes); err != nil {
|
|
return err
|
|
}
|
|
if err := applyBoolEnv(prefix+"SERVER_ACCESS_LOG_ENABLED", &c.Server.AccessLog.Enabled); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_ACCESS_LOG_FILE_PATH", &c.Server.AccessLog.FilePath); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"SERVER_ACCESS_LOG_MAX_SIZE", &c.Server.AccessLog.MaxSize); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SERVER_ACCESS_LOG_MAX_BACKUPS", &c.Server.AccessLog.MaxBackups); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := applyStringEnv(prefix+"LIMITS_MAX_UPLOAD_SIZE", &c.Limits.MaxUploadSize); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"LIMITS_DEFAULT_TTL", &c.Limits.DefaultTTL); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"LIMITS_RAW_CACHE_MAX_SIZE", &c.Limits.RawCacheMaxSize); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"LIMITS_RAW_CACHE_MAX_ENTRIES", &c.Limits.RawCacheMaxEntries); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := applyStringEnv(prefix+"STORAGE_DATA_DIR", &c.Storage.DataDir); err != nil {
|
|
return err
|
|
}
|
|
if err := applyStringEnv(prefix+"STORAGE_CLEANUP_INTERVAL", &c.Storage.CleanupInterval); err != nil {
|
|
return err
|
|
}
|
|
|
|
applyListEnv(prefix+"SECURITY_ALLOWED_IPS", &c.Security.AllowedIPs)
|
|
if err := applyBoolEnv(prefix+"SECURITY_TRUST_PROXY_HEADERS", &c.Security.TrustProxyHeaders); err != nil {
|
|
return err
|
|
}
|
|
applyListEnv(prefix+"SECURITY_TRUSTED_PROXY_IPS", &c.Security.TrustedProxyIPs)
|
|
|
|
if err := applyBoolEnv(prefix+"SECURITY_RATE_LIMIT_UI_ENABLED", &c.Security.RateLimitUI.Enabled); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_UI_REQUESTS_PER_MINUTE", &c.Security.RateLimitUI.RequestsPerMinute); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_UI_BURST", &c.Security.RateLimitUI.Burst); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := applyBoolEnv(prefix+"SECURITY_RATE_LIMIT_API_READ_ENABLED", &c.Security.RateLimitAPIRead.Enabled); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_READ_REQUESTS_PER_MINUTE", &c.Security.RateLimitAPIRead.RequestsPerMinute); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_READ_BURST", &c.Security.RateLimitAPIRead.Burst); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := applyBoolEnv(prefix+"SECURITY_RATE_LIMIT_API_WRITE_ENABLED", &c.Security.RateLimitAPIWrite.Enabled); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_WRITE_REQUESTS_PER_MINUTE", &c.Security.RateLimitAPIWrite.RequestsPerMinute); err != nil {
|
|
return err
|
|
}
|
|
if err := applyIntEnv(prefix+"SECURITY_RATE_LIMIT_API_WRITE_BURST", &c.Security.RateLimitAPIWrite.Burst); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := applyBoolEnv(prefix+"SECURITY_HSTS_ENABLED", &c.Security.HSTSEnabled); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func applyStringEnv(name string, target *string) error {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
*target = strings.TrimSpace(value)
|
|
return nil
|
|
}
|
|
|
|
func applyBoolEnv(name string, target *bool) error {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return fmt.Errorf("%s invalid bool: %w", name, err)
|
|
}
|
|
*target = parsed
|
|
return nil
|
|
}
|
|
|
|
func applyIntEnv(name string, target *int) error {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return fmt.Errorf("%s invalid int: %w", name, err)
|
|
}
|
|
*target = parsed
|
|
return nil
|
|
}
|
|
|
|
func applyListEnv(name string, target *[]string) {
|
|
value, ok := os.LookupEnv(name)
|
|
if !ok {
|
|
return
|
|
}
|
|
if strings.TrimSpace(value) == "" {
|
|
*target = nil
|
|
return
|
|
}
|
|
|
|
parts := strings.Split(value, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
item := strings.TrimSpace(part)
|
|
if item == "" {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
*target = out
|
|
}
|