package config import ( "crypto/sha256" "encoding/base64" "errors" "fmt" "net/url" "os" "strconv" "strings" "time" ) type Mode string const ( ModeStandalone Mode = "standalone" ModeMaster Mode = "master" ModeAgent Mode = "agent" ) type Config struct { Mode Mode ListenAddr, BaseURL, DataDir, StacksDir, AppSecret string SecureCookies, AuthDisabled bool OIDCIssuer, OIDCClientID, OIDCClientSecret, OIDCRedirectURL, OIDCAdminGroup, OIDCOperatorGroup string AgentToken string CheckConcurrency, RetentionDays, AuditRetentionDays int HTTPTimeout time.Duration } func Load() (Config, error) { checkConcurrency, err := envIntStrict("CHECK_CONCURRENCY", 8) if err != nil { return Config{}, err } retentionDays, err := envIntStrict("CHECK_RETENTION_DAYS", 30) if err != nil { return Config{}, err } auditRetentionDays, err := envIntStrict("AUDIT_RETENTION_DAYS", 180) if err != nil { return Config{}, err } httpTimeoutSeconds, err := envIntStrict("HTTP_TIMEOUT_SECONDS", 10) if err != nil { return Config{}, err } authDisabled, err := envBoolStrict("AUTH_DISABLED", false) if err != nil { return Config{}, err } c := Config{ Mode: Mode(env("APP_MODE", "standalone")), ListenAddr: env("LISTEN_ADDR", ":8080"), BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"), DataDir: env("DATA_DIR", "/data"), StacksDir: env("STACKS_DIR", "/stacks"), AppSecret: os.Getenv("APP_SECRET"), AuthDisabled: authDisabled, OIDCIssuer: strings.TrimRight(os.Getenv("OIDC_ISSUER"), "/"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), OIDCAdminGroup: env("OIDC_ADMIN_GROUP", "dockwatch-admins"), OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"), AgentToken: os.Getenv("AGENT_TOKEN"), CheckConcurrency: checkConcurrency, RetentionDays: retentionDays, AuditRetentionDays: auditRetentionDays, HTTPTimeout: time.Duration(httpTimeoutSeconds) * time.Second, } c.SecureCookies = strings.HasPrefix(c.BaseURL, "https://") if c.OIDCRedirectURL == "" { c.OIDCRedirectURL = c.BaseURL + "/auth/callback" } switch c.Mode { case ModeStandalone, ModeMaster, ModeAgent: default: return c, fmt.Errorf("APP_MODE must be standalone, master, or agent") } if c.CheckConcurrency < 1 || c.CheckConcurrency > 128 { return c, fmt.Errorf("CHECK_CONCURRENCY must be between 1 and 128") } if c.RetentionDays < 0 || c.RetentionDays > 3650 { return c, fmt.Errorf("CHECK_RETENTION_DAYS must be between 0 and 3650") } if c.AuditRetentionDays < 0 || c.AuditRetentionDays > 3650 { return c, fmt.Errorf("AUDIT_RETENTION_DAYS must be between 0 and 3650") } if c.HTTPTimeout < time.Second || c.HTTPTimeout > 5*time.Minute { return c, fmt.Errorf("HTTP_TIMEOUT_SECONDS must be between 1 and 300") } if c.Mode != ModeAgent { u, err := url.Parse(c.BaseURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { return c, errors.New("BASE_URL must be an absolute http(s) URL without credentials, query or fragment") } } if c.Mode == ModeAgent { if len(c.AgentToken) < 24 { return c, errors.New("AGENT_TOKEN must be at least 24 characters in agent mode") } return c, nil } if len(c.AppSecret) < 32 { return c, errors.New("APP_SECRET must be at least 32 characters") } if !c.AuthDisabled && (c.OIDCIssuer == "" || c.OIDCClientID == "" || c.OIDCClientSecret == "") { return c, errors.New("OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET are required unless AUTH_DISABLED=true") } return c, nil } func (c Config) DBPath() string { return c.DataDir + "/dockwatch.db" } func (c Config) EncryptionKey() []byte { s := sha256.Sum256([]byte(c.AppSecret)); return s[:] } func (c Config) SecretFingerprint() string { h := sha256.Sum256([]byte(c.AppSecret)) return base64.RawURLEncoding.EncodeToString(h[:6]) } func env(k, f string) string { if v := os.Getenv(k); v != "" { return v } return f } func envIntStrict(k string, f int) (int, error) { v := strings.TrimSpace(os.Getenv(k)) if v == "" { return f, nil } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("%s must be an integer: %w", k, err) } return n, nil } func envBoolStrict(k string, f bool) (bool, error) { v := strings.TrimSpace(os.Getenv(k)) if v == "" { return f, nil } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf("%s must be a boolean: %w", k, err) } return b, nil }