Lots more changes
This commit is contained in:
+163
-38
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
@@ -14,23 +16,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenAddr = ":8080"
|
||||
defaultReadHeaderTimeout = "5s"
|
||||
defaultReadTimeout = "30s"
|
||||
defaultWriteTimeout = "30s"
|
||||
defaultIdleTimeout = "120s"
|
||||
defaultMaxHeaderBytes = 1 << 20
|
||||
defaultMaxUploadSize = "100MiB"
|
||||
defaultTTL = "1h"
|
||||
defaultRawCacheMaxSize = "200MiB"
|
||||
defaultRawCacheMaxEntries = 32
|
||||
defaultDataDir = "./data"
|
||||
defaultCleanupInterval = "1m"
|
||||
defaultRateLimitEnabled = true
|
||||
defaultRequestsPerMinute = 30
|
||||
defaultRateLimitBurst = 10
|
||||
maxDefaultTTLLimit = 24 * time.Hour
|
||||
minCleanupInterval = 10 * time.Second
|
||||
defaultListenAddr = ":8080"
|
||||
defaultReadHeaderTimeout = "5s"
|
||||
defaultReadTimeout = "30s"
|
||||
defaultWriteTimeout = "30s"
|
||||
defaultIdleTimeout = "120s"
|
||||
defaultMaxHeaderBytes = 1 << 20
|
||||
defaultAccessLogEnabled = true
|
||||
defaultAccessLogMaxSize = "100MiB"
|
||||
defaultAccessLogMaxBackups = 5
|
||||
defaultMaxUploadSize = "100MiB"
|
||||
defaultTTL = "15m"
|
||||
defaultRawCacheMaxSize = "200MiB"
|
||||
defaultRawCacheMaxEntries = 32
|
||||
defaultDataDir = "./data"
|
||||
defaultCleanupInterval = "1m"
|
||||
defaultStorageEncryptionKey = "vVPlqMfKa8OtD3x0RKp4rVCJ4QoScf5mWdgfg4f+5GU="
|
||||
defaultEncryptionKeyFile = "config.key"
|
||||
defaultUIRateLimitEnabled = false
|
||||
defaultAPIReadRateLimitEnabled = false
|
||||
defaultAPIWriteRateLimitEnabled = true
|
||||
defaultUIRequestsPerMinute = 360
|
||||
defaultUIRateLimitBurst = 120
|
||||
defaultAPIReadRequestsPerMinute = 300
|
||||
defaultAPIReadRateLimitBurst = 100
|
||||
defaultAPIWriteRequestsPerMinute = 30
|
||||
defaultAPIWriteRateLimitBurst = 10
|
||||
maxDefaultTTLLimit = 24 * time.Hour
|
||||
minCleanupInterval = 10 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -41,16 +54,25 @@ type Config struct {
|
||||
}
|
||||
|
||||
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. Must be > 0."`
|
||||
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. 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."`
|
||||
MaxHeaderBytes int `yaml:"max_header_bytes" comment:"Maximum size of request headers in bytes. Must be > 0."`
|
||||
ReadHeaderTimeoutDur time.Duration `yaml:"-"`
|
||||
ReadTimeoutDur time.Duration `yaml:"-"`
|
||||
WriteTimeoutDur time.Duration `yaml:"-"`
|
||||
IdleTimeoutDur time.Duration `yaml:"-"`
|
||||
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. Must be > 0."`
|
||||
WriteTimeout string `yaml:"write_timeout" comment:"Maximum duration before timing out writes of a response. 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."`
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -66,14 +88,18 @@ type LimitsConfig struct {
|
||||
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)."`
|
||||
EncryptionKeyFile string `yaml:"encryption_key_file" comment:"Path to a file containing a base64-encoded 32-byte AES key for at-rest encryption. Relative paths are resolved from the config file directory."`
|
||||
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."`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit" comment:"Per-client token bucket on write and scratch read routes."`
|
||||
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)."`
|
||||
AllowedPrefixes []netip.Prefix `yaml:"-"`
|
||||
TrustedProxyPrefixes []netip.Prefix `yaml:"-"`
|
||||
}
|
||||
@@ -95,6 +121,9 @@ func Load(path string) (Config, error) {
|
||||
return Config{}, fmt.Errorf("parse config yaml: %w", err)
|
||||
}
|
||||
}
|
||||
if err := cfg.loadEncryptionKey(path); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
@@ -115,6 +144,11 @@ func defaults() Config {
|
||||
WriteTimeout: defaultWriteTimeout,
|
||||
IdleTimeout: defaultIdleTimeout,
|
||||
MaxHeaderBytes: defaultMaxHeaderBytes,
|
||||
AccessLog: AccessLogConfig{
|
||||
Enabled: defaultAccessLogEnabled,
|
||||
MaxSize: defaultAccessLogMaxSize,
|
||||
MaxBackups: defaultAccessLogMaxBackups,
|
||||
},
|
||||
},
|
||||
Limits: LimitsConfig{
|
||||
MaxUploadSize: defaultMaxUploadSize,
|
||||
@@ -123,17 +157,29 @@ func defaults() Config {
|
||||
RawCacheMaxEntries: defaultRawCacheMaxEntries,
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
DataDir: defaultDataDir,
|
||||
CleanupInterval: defaultCleanupInterval,
|
||||
DataDir: defaultDataDir,
|
||||
CleanupInterval: defaultCleanupInterval,
|
||||
EncryptionKeyFile: defaultEncryptionKeyFile,
|
||||
EncryptionKeyBytes: mustDecodeDefaultEncryptionKey(),
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
AllowedIPs: []string{
|
||||
"127.0.0.1",
|
||||
},
|
||||
RateLimit: RateLimitConfig{
|
||||
Enabled: defaultRateLimitEnabled,
|
||||
RequestsPerMinute: defaultRequestsPerMinute,
|
||||
Burst: defaultRateLimitBurst,
|
||||
RateLimitUI: RateLimitConfig{
|
||||
Enabled: defaultUIRateLimitEnabled,
|
||||
RequestsPerMinute: defaultUIRequestsPerMinute,
|
||||
Burst: defaultUIRateLimitBurst,
|
||||
},
|
||||
RateLimitAPIRead: RateLimitConfig{
|
||||
Enabled: defaultAPIReadRateLimitEnabled,
|
||||
RequestsPerMinute: defaultAPIReadRequestsPerMinute,
|
||||
Burst: defaultAPIReadRateLimitBurst,
|
||||
},
|
||||
RateLimitAPIWrite: RateLimitConfig{
|
||||
Enabled: defaultAPIWriteRateLimitEnabled,
|
||||
RequestsPerMinute: defaultAPIWriteRequestsPerMinute,
|
||||
Burst: defaultAPIWriteRateLimitBurst,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -182,6 +228,14 @@ func (c *Config) Validate() error {
|
||||
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 {
|
||||
@@ -222,6 +276,12 @@ func (c *Config) Validate() error {
|
||||
if err := ensureWritableDir(c.Storage.DataDir); err != nil {
|
||||
return fmt.Errorf("storage.data_dir not writable: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.Storage.EncryptionKeyFile) == "" {
|
||||
return errors.New("storage.encryption_key_file is required")
|
||||
}
|
||||
if len(c.Storage.EncryptionKeyBytes) != 32 {
|
||||
return fmt.Errorf("storage.encryption_key_file must decode to 32 bytes, got %d", len(c.Storage.EncryptionKeyBytes))
|
||||
}
|
||||
|
||||
prefixes, err := parseAllowedPrefixes(c.Security.AllowedIPs)
|
||||
if err != nil {
|
||||
@@ -237,11 +297,23 @@ func (c *Config) Validate() error {
|
||||
return errors.New("security.trusted_proxy_ips is required when security.trust_proxy_headers is true")
|
||||
}
|
||||
|
||||
if c.Security.RateLimit.RequestsPerMinute <= 0 {
|
||||
return errors.New("security.rate_limit.requests_per_minute must be > 0")
|
||||
if c.Security.RateLimitUI.RequestsPerMinute <= 0 {
|
||||
return errors.New("security.rate_limit_ui.requests_per_minute must be > 0")
|
||||
}
|
||||
if c.Security.RateLimit.Burst <= 0 {
|
||||
return errors.New("security.rate_limit.burst 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
|
||||
@@ -338,3 +410,56 @@ func parseHumanSize(raw string) (int64, error) {
|
||||
}
|
||||
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 (c *Config) loadEncryptionKey(configPath string) error {
|
||||
if configPath == "" {
|
||||
return nil
|
||||
}
|
||||
keyPath := strings.TrimSpace(c.Storage.EncryptionKeyFile)
|
||||
if keyPath == "" {
|
||||
return errors.New("storage.encryption_key_file is required")
|
||||
}
|
||||
if !filepath.IsAbs(keyPath) {
|
||||
keyPath = filepath.Join(filepath.Dir(configPath), keyPath)
|
||||
}
|
||||
keyRaw, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read storage.encryption_key_file %q: %w", keyPath, err)
|
||||
}
|
||||
encKey, err := decodeEncryptionKey(strings.TrimSpace(string(keyRaw)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage.encryption_key_file invalid: %w", err)
|
||||
}
|
||||
c.Storage.EncryptionKeyBytes = encKey
|
||||
return 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
|
||||
}
|
||||
|
||||
func mustDecodeDefaultEncryptionKey() []byte {
|
||||
encKey, err := decodeEncryptionKey(defaultStorageEncryptionKey)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid built-in encryption key: %v", err))
|
||||
}
|
||||
return encKey
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user