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
|
||||
}
|
||||
|
||||
+165
-11
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -37,8 +38,17 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
||||
if cfg.Server.MaxHeaderBytes != 1<<20 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 1<<20)
|
||||
}
|
||||
if cfg.Limits.DefaultTTLDuration != time.Hour {
|
||||
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
|
||||
if !cfg.Server.AccessLog.Enabled {
|
||||
t.Fatalf("AccessLog.Enabled = %v, want true", cfg.Server.AccessLog.Enabled)
|
||||
}
|
||||
if cfg.Server.AccessLog.MaxSizeBytes != 100*1024*1024 {
|
||||
t.Fatalf("AccessLog.MaxSizeBytes = %d, want %d", cfg.Server.AccessLog.MaxSizeBytes, int64(100*1024*1024))
|
||||
}
|
||||
if cfg.Server.AccessLog.MaxBackups != 5 {
|
||||
t.Fatalf("AccessLog.MaxBackups = %d, want 5", cfg.Server.AccessLog.MaxBackups)
|
||||
}
|
||||
if cfg.Limits.DefaultTTLDuration != 15*time.Minute {
|
||||
t.Fatalf("DefaultTTLDuration = %s, want 15m", cfg.Limits.DefaultTTLDuration)
|
||||
}
|
||||
if cfg.Limits.RawCacheMaxBytes != 200*1024*1024 {
|
||||
t.Fatalf("RawCacheMaxBytes = %d, want %d", cfg.Limits.RawCacheMaxBytes, int64(200*1024*1024))
|
||||
@@ -49,6 +59,9 @@ func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
||||
if cfg.Storage.CleanupIntervalParsed != time.Minute {
|
||||
t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed)
|
||||
}
|
||||
if len(cfg.Storage.EncryptionKeyBytes) != 32 {
|
||||
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
|
||||
}
|
||||
if len(cfg.Security.AllowedPrefixes) != 1 {
|
||||
t.Fatalf("AllowedPrefixes len = %d, want 1", len(cfg.Security.AllowedPrefixes))
|
||||
}
|
||||
@@ -63,6 +76,7 @@ func TestLoadParsesConfiguredFields(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dataDir := filepath.Join(tmp, "store")
|
||||
configPath := filepath.Join(tmp, "config.yaml")
|
||||
keyPath := filepath.Join(tmp, "scratchbox.key")
|
||||
content := `
|
||||
server:
|
||||
listen_addr: "127.0.0.1:9090"
|
||||
@@ -71,6 +85,11 @@ server:
|
||||
write_timeout: "35s"
|
||||
idle_timeout: "90s"
|
||||
max_header_bytes: 2097152
|
||||
access_log:
|
||||
enabled: true
|
||||
file_path: "` + filepath.Join(tmp, "access.log") + `"
|
||||
max_size: "2MiB"
|
||||
max_backups: 7
|
||||
limits:
|
||||
max_upload_size: "2MiB"
|
||||
default_ttl: "2h"
|
||||
@@ -79,6 +98,7 @@ limits:
|
||||
storage:
|
||||
data_dir: "` + dataDir + `"
|
||||
cleanup_interval: "45s"
|
||||
encryption_key_file: "` + filepath.Base(keyPath) + `"
|
||||
security:
|
||||
allowed_ips:
|
||||
- "127.0.0.1"
|
||||
@@ -86,7 +106,15 @@ security:
|
||||
trust_proxy_headers: true
|
||||
trusted_proxy_ips:
|
||||
- "10.0.0.0/8"
|
||||
rate_limit:
|
||||
rate_limit_ui:
|
||||
enabled: true
|
||||
requests_per_minute: 12
|
||||
burst: 4
|
||||
rate_limit_api_read:
|
||||
enabled: true
|
||||
requests_per_minute: 12
|
||||
burst: 4
|
||||
rate_limit_api_write:
|
||||
enabled: true
|
||||
requests_per_minute: 12
|
||||
burst: 4
|
||||
@@ -94,6 +122,9 @@ security:
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, []byte("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(key) error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
@@ -118,6 +149,15 @@ security:
|
||||
if cfg.Server.MaxHeaderBytes != 2*1024*1024 {
|
||||
t.Fatalf("MaxHeaderBytes = %d, want %d", cfg.Server.MaxHeaderBytes, 2*1024*1024)
|
||||
}
|
||||
if !cfg.Server.AccessLog.Enabled {
|
||||
t.Fatalf("AccessLog.Enabled = false, want true")
|
||||
}
|
||||
if cfg.Server.AccessLog.MaxSizeBytes != 2*1024*1024 {
|
||||
t.Fatalf("AccessLog.MaxSizeBytes = %d, want %d", cfg.Server.AccessLog.MaxSizeBytes, int64(2*1024*1024))
|
||||
}
|
||||
if cfg.Server.AccessLog.MaxBackups != 7 {
|
||||
t.Fatalf("AccessLog.MaxBackups = %d, want 7", cfg.Server.AccessLog.MaxBackups)
|
||||
}
|
||||
if cfg.Limits.MaxUploadSizeBytes != 2*1024*1024 {
|
||||
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(2*1024*1024))
|
||||
}
|
||||
@@ -133,11 +173,23 @@ security:
|
||||
if cfg.Storage.CleanupIntervalParsed != 45*time.Second {
|
||||
t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed)
|
||||
}
|
||||
if len(cfg.Storage.EncryptionKeyBytes) != 32 {
|
||||
t.Fatalf("EncryptionKeyBytes length = %d, want 32", len(cfg.Storage.EncryptionKeyBytes))
|
||||
}
|
||||
if cfg.Storage.EncryptionKeyBytes[0] != 0 {
|
||||
t.Fatalf("EncryptionKeyBytes[0] = %d, want 0", cfg.Storage.EncryptionKeyBytes[0])
|
||||
}
|
||||
if !cfg.Security.TrustProxyHeaders {
|
||||
t.Fatalf("TrustProxyHeaders = false, want true")
|
||||
}
|
||||
if cfg.Security.RateLimit.RequestsPerMinute != 12 || cfg.Security.RateLimit.Burst != 4 {
|
||||
t.Fatalf("unexpected rate_limit values: %+v", cfg.Security.RateLimit)
|
||||
if cfg.Security.RateLimitUI.RequestsPerMinute != 12 || cfg.Security.RateLimitUI.Burst != 4 {
|
||||
t.Fatalf("unexpected rate_limit_ui values: %+v", cfg.Security.RateLimitUI)
|
||||
}
|
||||
if cfg.Security.RateLimitAPIRead.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIRead.Burst != 4 {
|
||||
t.Fatalf("unexpected rate_limit_api_read values: %+v", cfg.Security.RateLimitAPIRead)
|
||||
}
|
||||
if cfg.Security.RateLimitAPIWrite.RequestsPerMinute != 12 || cfg.Security.RateLimitAPIWrite.Burst != 4 {
|
||||
t.Fatalf("unexpected rate_limit_api_write values: %+v", cfg.Security.RateLimitAPIWrite)
|
||||
}
|
||||
if len(cfg.Security.AllowedPrefixes) != 2 {
|
||||
t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes))
|
||||
@@ -204,6 +256,20 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
},
|
||||
wantErr: "server.max_header_bytes",
|
||||
},
|
||||
{
|
||||
name: "invalid access log max size",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.AccessLog.MaxSize = "oops"
|
||||
},
|
||||
wantErr: "server.access_log.max_size",
|
||||
},
|
||||
{
|
||||
name: "invalid access log max backups",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.AccessLog.MaxBackups = 0
|
||||
},
|
||||
wantErr: "server.access_log.max_backups",
|
||||
},
|
||||
{
|
||||
name: "invalid ttl parse",
|
||||
mutate: func(cfg *Config) {
|
||||
@@ -255,11 +321,25 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
wantErr: "security.trusted_proxy_ips is required",
|
||||
},
|
||||
{
|
||||
name: "invalid rate limit burst",
|
||||
name: "invalid ui rate limit burst",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimit.Burst = 0
|
||||
cfg.Security.RateLimitUI.Burst = 0
|
||||
},
|
||||
wantErr: "security.rate_limit.burst",
|
||||
wantErr: "security.rate_limit_ui.burst",
|
||||
},
|
||||
{
|
||||
name: "invalid api read rate limit burst",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimitAPIRead.Burst = 0
|
||||
},
|
||||
wantErr: "security.rate_limit_api_read.burst",
|
||||
},
|
||||
{
|
||||
name: "invalid api write rate limit burst",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimitAPIWrite.Burst = 0
|
||||
},
|
||||
wantErr: "security.rate_limit_api_write.burst",
|
||||
},
|
||||
{
|
||||
name: "data dir required",
|
||||
@@ -283,11 +363,39 @@ func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
wantErr: "storage.cleanup_interval",
|
||||
},
|
||||
{
|
||||
name: "invalid rate limit rpm",
|
||||
name: "encryption key file required",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimit.RequestsPerMinute = 0
|
||||
cfg.Storage.EncryptionKeyFile = " "
|
||||
},
|
||||
wantErr: "security.rate_limit.requests_per_minute",
|
||||
wantErr: "storage.encryption_key_file is required",
|
||||
},
|
||||
{
|
||||
name: "encryption key bytes invalid length",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Storage.EncryptionKeyBytes = []byte("short")
|
||||
},
|
||||
wantErr: "storage.encryption_key_file must decode to 32 bytes",
|
||||
},
|
||||
{
|
||||
name: "invalid ui rate limit rpm",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimitUI.RequestsPerMinute = 0
|
||||
},
|
||||
wantErr: "security.rate_limit_ui.requests_per_minute",
|
||||
},
|
||||
{
|
||||
name: "invalid api read rate limit rpm",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimitAPIRead.RequestsPerMinute = 0
|
||||
},
|
||||
wantErr: "security.rate_limit_api_read.requests_per_minute",
|
||||
},
|
||||
{
|
||||
name: "invalid api write rate limit rpm",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimitAPIWrite.RequestsPerMinute = 0
|
||||
},
|
||||
wantErr: "security.rate_limit_api_write.requests_per_minute",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -328,6 +436,29 @@ func TestLoadErrors(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), "parse config yaml") {
|
||||
t.Fatalf("Load() error = %v, want yaml parse error", err)
|
||||
}
|
||||
|
||||
validConfigPath := filepath.Join(tmp, "valid.yaml")
|
||||
validConfig := `
|
||||
storage:
|
||||
data_dir: "` + filepath.Join(tmp, "store") + `"
|
||||
cleanup_interval: "10s"
|
||||
`
|
||||
if err := os.WriteFile(validConfigPath, []byte(validConfig), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(valid config) error = %v", err)
|
||||
}
|
||||
_, err = Load(validConfigPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "read storage.encryption_key_file") {
|
||||
t.Fatalf("Load() error = %v, want missing key file error", err)
|
||||
}
|
||||
|
||||
keyPath := filepath.Join(tmp, "config.key")
|
||||
if err := os.WriteFile(keyPath, []byte("not-base64"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(invalid key) error = %v", err)
|
||||
}
|
||||
_, err = Load(validConfigPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "storage.encryption_key_file invalid: invalid base64") {
|
||||
t.Fatalf("Load() error = %v, want invalid key base64 error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedPrefixesTrimsAndParses(t *testing.T) {
|
||||
@@ -385,3 +516,26 @@ func TestParseHumanSizeVariants(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEncryptionKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
k1, err := GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEncryptionKey() error = %v", err)
|
||||
}
|
||||
k2, err := GenerateEncryptionKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateEncryptionKey() second error = %v", err)
|
||||
}
|
||||
if k1 == k2 {
|
||||
t.Fatalf("GenerateEncryptionKey() produced duplicate keys")
|
||||
}
|
||||
b1, err := base64.StdEncoding.DecodeString(k1)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeString(k1) error = %v", err)
|
||||
}
|
||||
if len(b1) != 32 {
|
||||
t.Fatalf("decoded key length = %d, want 32", len(b1))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user