initial commit
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListenAddr = ":8080"
|
||||
defaultMaxUploadSize = "100MB"
|
||||
defaultTTL = "1h"
|
||||
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"`
|
||||
Limits LimitsConfig `yaml:"limits"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Security SecurityConfig `yaml:"security"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
}
|
||||
|
||||
type LimitsConfig struct {
|
||||
MaxUploadSize string `yaml:"max_upload_size"`
|
||||
DefaultTTL string `yaml:"default_ttl"`
|
||||
MaxUploadSizeBytes int64 `yaml:"-"`
|
||||
DefaultTTLDuration time.Duration `yaml:"-"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
DataDir string `yaml:"data_dir"`
|
||||
CleanupInterval string `yaml:"cleanup_interval"`
|
||||
CleanupIntervalParsed time.Duration `yaml:"-"`
|
||||
}
|
||||
|
||||
type SecurityConfig struct {
|
||||
AllowedIPs []string `yaml:"allowed_ips"`
|
||||
TrustProxyHeaders bool `yaml:"trust_proxy_headers"`
|
||||
RateLimit RateLimitConfig `yaml:"rate_limit"`
|
||||
AllowedPrefixes []netip.Prefix `yaml:"-"`
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
RequestsPerMinute int `yaml:"requests_per_minute"`
|
||||
Burst int `yaml:"burst"`
|
||||
}
|
||||
|
||||
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 defaults() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
ListenAddr: defaultListenAddr,
|
||||
},
|
||||
Limits: LimitsConfig{
|
||||
MaxUploadSize: defaultMaxUploadSize,
|
||||
DefaultTTL: defaultTTL,
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
DataDir: defaultDataDir,
|
||||
CleanupInterval: defaultCleanupInterval,
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
"TB": 1_000_000_000_000,
|
||||
"KIB": 1024,
|
||||
"MIB": 1024 * 1024,
|
||||
"GIB": 1024 * 1024 * 1024,
|
||||
"TIB": 1024 * 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
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateAppliesDefaultsAndParsedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaults()
|
||||
cfg.Storage.DataDir = t.TempDir()
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() returned error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Limits.MaxUploadSizeBytes != 100_000_000 {
|
||||
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(100_000_000))
|
||||
}
|
||||
if cfg.Limits.DefaultTTLDuration != time.Hour {
|
||||
t.Fatalf("DefaultTTLDuration = %s, want 1h", cfg.Limits.DefaultTTLDuration)
|
||||
}
|
||||
if cfg.Storage.CleanupIntervalParsed != time.Minute {
|
||||
t.Fatalf("CleanupIntervalParsed = %s, want 1m", cfg.Storage.CleanupIntervalParsed)
|
||||
}
|
||||
if len(cfg.Security.AllowedPrefixes) != 0 {
|
||||
t.Fatalf("AllowedPrefixes len = %d, want 0", len(cfg.Security.AllowedPrefixes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadParsesConfiguredFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
dataDir := filepath.Join(tmp, "store")
|
||||
configPath := filepath.Join(tmp, "config.yaml")
|
||||
content := `
|
||||
server:
|
||||
listen_addr: "127.0.0.1:9090"
|
||||
limits:
|
||||
max_upload_size: "2MiB"
|
||||
default_ttl: "2h"
|
||||
storage:
|
||||
data_dir: "` + dataDir + `"
|
||||
cleanup_interval: "45s"
|
||||
security:
|
||||
allowed_ips:
|
||||
- "127.0.0.1"
|
||||
- "10.0.0.0/8"
|
||||
trust_proxy_headers: true
|
||||
rate_limit:
|
||||
enabled: true
|
||||
requests_per_minute: 12
|
||||
burst: 4
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.ListenAddr != "127.0.0.1:9090" {
|
||||
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:9090")
|
||||
}
|
||||
if cfg.Limits.MaxUploadSizeBytes != 2*1024*1024 {
|
||||
t.Fatalf("MaxUploadSizeBytes = %d, want %d", cfg.Limits.MaxUploadSizeBytes, int64(2*1024*1024))
|
||||
}
|
||||
if cfg.Limits.DefaultTTLDuration != 2*time.Hour {
|
||||
t.Fatalf("DefaultTTLDuration = %s, want 2h", cfg.Limits.DefaultTTLDuration)
|
||||
}
|
||||
if cfg.Storage.CleanupIntervalParsed != 45*time.Second {
|
||||
t.Fatalf("CleanupIntervalParsed = %s, want 45s", cfg.Storage.CleanupIntervalParsed)
|
||||
}
|
||||
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 len(cfg.Security.AllowedPrefixes) != 2 {
|
||||
t.Fatalf("AllowedPrefixes len = %d, want 2", len(cfg.Security.AllowedPrefixes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Config)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "listen addr required",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Server.ListenAddr = " "
|
||||
},
|
||||
wantErr: "server.listen_addr",
|
||||
},
|
||||
{
|
||||
name: "invalid max upload size",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Limits.MaxUploadSize = "oops"
|
||||
},
|
||||
wantErr: "limits.max_upload_size",
|
||||
},
|
||||
{
|
||||
name: "invalid ttl parse",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Limits.DefaultTTL = "not-a-duration"
|
||||
},
|
||||
wantErr: "limits.default_ttl",
|
||||
},
|
||||
{
|
||||
name: "ttl above max",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Limits.DefaultTTL = "25h"
|
||||
},
|
||||
wantErr: "limits.default_ttl",
|
||||
},
|
||||
{
|
||||
name: "invalid allowlist entry",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.AllowedIPs = []string{"not-an-ip"}
|
||||
},
|
||||
wantErr: "security.allowed_ips",
|
||||
},
|
||||
{
|
||||
name: "invalid rate limit burst",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimit.Burst = 0
|
||||
},
|
||||
wantErr: "security.rate_limit.burst",
|
||||
},
|
||||
{
|
||||
name: "data dir required",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Storage.DataDir = " "
|
||||
},
|
||||
wantErr: "storage.data_dir",
|
||||
},
|
||||
{
|
||||
name: "cleanup parse failure",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Storage.CleanupInterval = "not-a-duration"
|
||||
},
|
||||
wantErr: "storage.cleanup_interval",
|
||||
},
|
||||
{
|
||||
name: "cleanup too small",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Storage.CleanupInterval = "5s"
|
||||
},
|
||||
wantErr: "storage.cleanup_interval",
|
||||
},
|
||||
{
|
||||
name: "invalid rate limit rpm",
|
||||
mutate: func(cfg *Config) {
|
||||
cfg.Security.RateLimit.RequestsPerMinute = 0
|
||||
},
|
||||
wantErr: "security.rate_limit.requests_per_minute",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := defaults()
|
||||
cfg.Storage.DataDir = t.TempDir()
|
||||
tc.mutate(&cfg)
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() error = nil, want non-nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := Load(filepath.Join(t.TempDir(), "missing.yaml"))
|
||||
if err == nil || !strings.Contains(err.Error(), "read config") {
|
||||
t.Fatalf("Load() error = %v, want read config error", err)
|
||||
}
|
||||
|
||||
tmp := t.TempDir()
|
||||
configPath := filepath.Join(tmp, "bad.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("limits: [bad"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
_, err = Load(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse config yaml") {
|
||||
t.Fatalf("Load() error = %v, want yaml parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAllowedPrefixesTrimsAndParses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prefixes, err := parseAllowedPrefixes([]string{" 192.0.2.4 ", " ", "2001:db8::/32"})
|
||||
if err != nil {
|
||||
t.Fatalf("parseAllowedPrefixes() error = %v", err)
|
||||
}
|
||||
if len(prefixes) != 2 {
|
||||
t.Fatalf("prefixes len = %d, want 2", len(prefixes))
|
||||
}
|
||||
wantIP := netip.MustParsePrefix("192.0.2.4/32")
|
||||
if prefixes[0] != wantIP {
|
||||
t.Fatalf("prefixes[0] = %v, want %v", prefixes[0], wantIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHumanSizeVariants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
in string
|
||||
want int64
|
||||
wantErr string
|
||||
}{
|
||||
{in: "10", want: 10},
|
||||
{in: "1.5KB", want: 1500},
|
||||
{in: "2MiB", want: 2 * 1024 * 1024},
|
||||
{in: "0", wantErr: "size must be > 0"},
|
||||
{in: "abc", wantErr: "does not start with a number"},
|
||||
{in: "5XB", wantErr: "unsupported unit"},
|
||||
{in: "", wantErr: "size is empty"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseHumanSize(tc.in)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("parseHumanSize(%q) err=%v, want contains %q", tc.in, err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseHumanSize(%q) error = %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseHumanSize(%q) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user