Files
scratchbox/internal/config/config.go
T
s1d3sw1ped 8bec7f0dda Apply existing rate limiting to scratch read routes.
Reuse the current IP-based limiter for read endpoints so public reads remain open but throttled, and add integration coverage plus docs updates for the expanded scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:09:25 -05:00

341 lines
12 KiB
Go

package config
import (
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
)
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
)
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 and rate limiting."`
}
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:"-"`
}
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:"-"`
}
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."`
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 Default() Config {
return defaults()
}
func defaults() Config {
return Config{
Server: ServerConfig{
ListenAddr: defaultListenAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
WriteTimeout: defaultWriteTimeout,
IdleTimeout: defaultIdleTimeout,
MaxHeaderBytes: defaultMaxHeaderBytes,
},
Limits: LimitsConfig{
MaxUploadSize: defaultMaxUploadSize,
DefaultTTL: defaultTTL,
RawCacheMaxSize: defaultRawCacheMaxSize,
RawCacheMaxEntries: defaultRawCacheMaxEntries,
},
Storage: StorageConfig{
DataDir: defaultDataDir,
CleanupInterval: defaultCleanupInterval,
},
Security: SecurityConfig{
AllowedIPs: []string{
"127.0.0.1",
},
RateLimit: RateLimitConfig{
Enabled: defaultRateLimitEnabled,
RequestsPerMinute: defaultRequestsPerMinute,
Burst: defaultRateLimitBurst,
},
},
}
}
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)
}
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)
}
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
if c.Server.MaxHeaderBytes <= 0 {
return errors.New("server.max_header_bytes 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")
}
if err := ensureWritableDir(c.Storage.DataDir); err != nil {
return fmt.Errorf("storage.data_dir not writable: %w", err)
}
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.RateLimit.RequestsPerMinute <= 0 {
return errors.New("security.rate_limit.requests_per_minute must be > 0")
}
if c.Security.RateLimit.Burst <= 0 {
return errors.New("security.rate_limit.burst must be > 0")
}
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 {
return 0, errors.New("calculated size must be > 0")
}
return size, nil
}