Files
jbergner f3a687543c
release-tag / release-image (push) Successful in 2m28s
v9.6.0
2026-09-01 22:46:20 +02:00

214 lines
7.8 KiB
Go

package config
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"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, HostRoot, HostAURUser string
AllowHostUserManagement, AllowHostPermissionManagement bool
HostSecurityEnabled, AllowHostSecurityChanges, AllowHostPackageManagement bool
HostSecurityPID int
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
}
allowHostUserManagement, err := envBoolStrict("ALLOW_HOST_USER_MANAGEMENT", false)
if err != nil {
return Config{}, err
}
allowHostPermissionManagement, err := envBoolStrict("ALLOW_HOST_PERMISSION_MANAGEMENT", false)
if err != nil {
return Config{}, err
}
hostSecurityEnabled, err := envBoolStrict("HOST_SECURITY_ENABLED", false)
if err != nil {
return Config{}, err
}
allowHostSecurityChanges, err := envBoolStrict("ALLOW_HOST_SECURITY_CHANGES", false)
if err != nil {
return Config{}, err
}
allowHostPackageManagement, err := envBoolStrict("ALLOW_HOST_PACKAGE_MANAGEMENT", false)
if err != nil {
return Config{}, err
}
hostSecurityPID, err := envIntStrict("HOST_SECURITY_HOST_PID", 1)
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"),
HostRoot: cleanOptionalPath(os.Getenv("HOST_ROOT")),
HostAURUser: strings.TrimSpace(os.Getenv("HOST_AUR_USER")),
AllowHostUserManagement: allowHostUserManagement,
AllowHostPermissionManagement: allowHostPermissionManagement,
HostSecurityEnabled: hostSecurityEnabled,
AllowHostSecurityChanges: allowHostSecurityChanges,
AllowHostPackageManagement: allowHostPackageManagement,
HostSecurityPID: hostSecurityPID,
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.HostRoot != "" && !filepath.IsAbs(c.HostRoot) {
return c, errors.New("HOST_ROOT must be an absolute path")
}
if c.AllowHostUserManagement && c.HostRoot == "" {
return c, errors.New("ALLOW_HOST_USER_MANAGEMENT=true requires HOST_ROOT")
}
if c.AllowHostPermissionManagement && c.HostRoot == "" {
return c, errors.New("ALLOW_HOST_PERMISSION_MANAGEMENT=true requires HOST_ROOT")
}
if c.HostSecurityEnabled && c.HostRoot == "" {
return c, errors.New("HOST_SECURITY_ENABLED=true requires HOST_ROOT")
}
if c.AllowHostSecurityChanges && !c.HostSecurityEnabled {
return c, errors.New("ALLOW_HOST_SECURITY_CHANGES=true requires HOST_SECURITY_ENABLED=true")
}
if c.AllowHostPackageManagement && !c.AllowHostSecurityChanges {
return c, errors.New("ALLOW_HOST_PACKAGE_MANAGEMENT=true requires ALLOW_HOST_SECURITY_CHANGES=true")
}
if c.HostSecurityPID < 1 {
return c, errors.New("HOST_SECURITY_HOST_PID must be greater than 0")
}
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 cleanOptionalPath(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
return filepath.Clean(v)
}
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
}