@@ -0,0 +1,148 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStrictEnvironmentParsing(t *testing.T) {
|
||||
t.Setenv("CHECK_CONCURRENCY", "not-a-number")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected invalid CHECK_CONCURRENCY to fail instead of silently using a default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictBooleanParsing(t *testing.T) {
|
||||
t.Setenv("CHECK_CONCURRENCY", "8")
|
||||
t.Setenv("AUTH_DISABLED", "sometimes")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected invalid AUTH_DISABLED to fail")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user