package config import ( "errors" "fmt" "net/netip" "net/url" "os" "regexp" "strconv" "strings" "time" ) type Environment string const ( Development Environment = "development" Test Environment = "test" Production Environment = "production" ) type Config struct { Environment Environment Timezone string DefaultLocale string LogLevel string PublicURL string DatabaseURL string PrometheusURL string PrometheusTimeout time.Duration UnraidURL string UnraidAPIToken string AuthMode string OIDCIssuer string OIDCClientID string OIDCClientSecret string OIDCRedirectURL string OIDCGroupsClaim string OIDCRoleMapping map[string]string SessionIdleTTL time.Duration SessionAbsoluteTTL time.Duration BreakGlassEnabled bool BackupDirectory string BackupRetention int // ContainerSourceID is the data_sources UUID the worker attributes container // discovery to. Discovery stays disabled until a source is registered, so no // inventory is ever written against an unknown origin. ContainerSourceID string // ProbeAllowedNetworks are the private/loopback CIDRs service probes may // reach. The probe network policy blocks private space unless it is // explicitly allowlisted here; link-local, multicast and cloud metadata // addresses stay blocked regardless. ProbeAllowedNetworks []string NotificationWebhookURL string NotificationWebhookToken string NotificationWebhookTimeout time.Duration } type ValidationError struct { Fields []string } func (e *ValidationError) Error() string { return "invalid configuration: " + strings.Join(e.Fields, "; ") } func Load() (Config, error) { return LoadFrom(os.LookupEnv) } func LoadFrom(lookup func(string) (string, bool)) (Config, error) { config, err := parseFrom(lookup) if err != nil { return Config{}, err } return config, Validate(config) } // LoadWorker loads only the settings consumed by the background worker. API // authentication credentials are intentionally not part of that container's // privilege boundary. func LoadWorker() (Config, error) { return LoadWorkerFrom(os.LookupEnv) } func LoadWorkerFrom(lookup func(string) (string, bool)) (Config, error) { config, err := parseFrom(lookup) if err != nil { return Config{}, err } return config, ValidateWorker(config) } func parseFrom(lookup func(string) (string, bool)) (Config, error) { get := func(key, fallback string) string { if value, ok := lookup(key); ok { return value } return fallback } config := Config{ Environment: Environment(get("PULSE_ENV", string(Development))), Timezone: get("PULSE_TIMEZONE", "Europe/Brussels"), DefaultLocale: get("PULSE_DEFAULT_LOCALE", "nl-BE"), LogLevel: get("PULSE_LOG_LEVEL", "info"), PublicURL: get("PULSE_PUBLIC_URL", ""), DatabaseURL: get("PULSE_DATABASE_URL", ""), PrometheusURL: get("PULSE_PROMETHEUS_URL", ""), UnraidURL: get("PULSE_UNRAID_URL", ""), UnraidAPIToken: get("PULSE_UNRAID_API_TOKEN", ""), AuthMode: get("PULSE_AUTH_MODE", "oidc"), OIDCIssuer: get("PULSE_OIDC_ISSUER", ""), OIDCClientID: get("PULSE_OIDC_CLIENT_ID", ""), OIDCClientSecret: get("PULSE_OIDC_CLIENT_SECRET", ""), OIDCRedirectURL: get("PULSE_OIDC_REDIRECT_URL", ""), OIDCGroupsClaim: get("PULSE_OIDC_GROUPS_CLAIM", "groups"), SessionIdleTTL: 8 * time.Hour, SessionAbsoluteTTL: 7 * 24 * time.Hour, BackupDirectory: get("PULSE_BACKUP_DIR", ""), BackupRetention: 5, ContainerSourceID: strings.TrimSpace(get("PULSE_CONTAINER_SOURCE_ID", "")), NotificationWebhookURL: strings.TrimSpace(get("PULSE_NOTIFICATION_WEBHOOK_URL", "")), NotificationWebhookToken: get("PULSE_NOTIFICATION_WEBHOOK_TOKEN", ""), } networks, networksErr := parseAllowedNetworks(get("PULSE_PROBE_ALLOWED_NETWORKS", "")) if networksErr != nil { return Config{}, &ValidationError{Fields: []string{networksErr.Error()}} } config.ProbeAllowedNetworks = networks mapping, mappingErr := parseRoleMapping(get("PULSE_OIDC_ROLE_MAPPING", "")) if mappingErr != nil { return Config{}, &ValidationError{Fields: []string{mappingErr.Error()}} } config.OIDCRoleMapping = mapping config.PrometheusTimeout = 10 * time.Second config.NotificationWebhookTimeout = 10 * time.Second if raw := get("PULSE_SESSION_IDLE_TTL", ""); raw != "" { parsed, err := time.ParseDuration(raw) if err != nil { return Config{}, &ValidationError{Fields: []string{"PULSE_SESSION_IDLE_TTL must be a duration"}} } config.SessionIdleTTL = parsed } if raw := get("PULSE_SESSION_ABSOLUTE_TTL", ""); raw != "" { parsed, err := time.ParseDuration(raw) if err != nil { return Config{}, &ValidationError{Fields: []string{"PULSE_SESSION_ABSOLUTE_TTL must be a duration"}} } config.SessionAbsoluteTTL = parsed } if raw := get("PULSE_PROMETHEUS_TIMEOUT", ""); raw != "" { parsed, err := time.ParseDuration(raw) if err != nil { return Config{}, &ValidationError{Fields: []string{"PULSE_PROMETHEUS_TIMEOUT must be a duration"}} } config.PrometheusTimeout = parsed } if raw := get("PULSE_NOTIFICATION_WEBHOOK_TIMEOUT", ""); raw != "" { parsed, err := time.ParseDuration(raw) if err != nil { return Config{}, &ValidationError{Fields: []string{"PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be a duration"}} } config.NotificationWebhookTimeout = parsed } if raw := get("PULSE_BREAK_GLASS_ENABLED", "false"); raw != "" { parsed, err := strconv.ParseBool(raw) if err != nil { return Config{}, &ValidationError{Fields: []string{"PULSE_BREAK_GLASS_ENABLED must be true or false"}} } config.BreakGlassEnabled = parsed } if raw := get("PULSE_BACKUP_RETENTION", ""); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil { return Config{}, &ValidationError{Fields: []string{"PULSE_BACKUP_RETENTION must be an integer"}} } config.BackupRetention = parsed } return config, nil } // ValidateWorker validates the worker's actual source and sink boundary. OIDC, // session, backup and Unraid settings belong to the API or agent and must not // be copied into the worker merely to satisfy unrelated validation. func ValidateWorker(config Config) error { var fields []string if config.Environment != Development && config.Environment != Test && config.Environment != Production { fields = append(fields, "PULSE_ENV must be development, test, or production") } if strings.TrimSpace(config.DatabaseURL) == "" { fields = append(fields, "PULSE_DATABASE_URL is required: every worker job is database-coordinated") } else { validateDatabaseURL(&fields, config.DatabaseURL) } if config.PrometheusTimeout <= 0 || config.PrometheusTimeout > time.Minute { fields = append(fields, "PULSE_PROMETHEUS_TIMEOUT must be between 1ns and 1m") } if config.PrometheusURL != "" { validateURL(&fields, "PULSE_PROMETHEUS_URL", config.PrometheusURL, config.Environment, false) } if config.ContainerSourceID != "" && !uuidPattern.MatchString(config.ContainerSourceID) { fields = append(fields, "PULSE_CONTAINER_SOURCE_ID must be a UUID") } if config.NotificationWebhookTimeout < time.Second || config.NotificationWebhookTimeout > 30*time.Second { fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be between 1s and 30s") } if config.NotificationWebhookURL != "" { validateURL(&fields, "PULSE_NOTIFICATION_WEBHOOK_URL", config.NotificationWebhookURL, config.Environment, true) parsed, err := url.Parse(config.NotificationWebhookURL) if err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") { fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_URL may not contain credentials, query parameters, or a fragment") } if strings.TrimSpace(config.NotificationWebhookToken) == "" { fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TOKEN is required when the webhook is configured") } } if len(fields) > 0 { return &ValidationError{Fields: fields} } return nil } func Validate(config Config) error { var fields []string if config.Environment != Development && config.Environment != Test && config.Environment != Production { fields = append(fields, "PULSE_ENV must be development, test, or production") } if config.Timezone == "" { fields = append(fields, "PULSE_TIMEZONE is required") } else if _, err := time.LoadLocation(config.Timezone); err != nil { fields = append(fields, "PULSE_TIMEZONE must be a valid IANA timezone") } if config.DefaultLocale == "" { fields = append(fields, "PULSE_DEFAULT_LOCALE is required") } if !contains([]string{"debug", "info", "warn", "error"}, config.LogLevel) { fields = append(fields, "PULSE_LOG_LEVEL must be debug, info, warn, or error") } if config.PrometheusTimeout <= 0 || config.PrometheusTimeout > time.Minute { fields = append(fields, "PULSE_PROMETHEUS_TIMEOUT must be between 1ns and 1m") } if config.NotificationWebhookTimeout < time.Second || config.NotificationWebhookTimeout > 30*time.Second { fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TIMEOUT must be between 1s and 30s") } minimumIdle := 5 * time.Second minimumAbsolute := config.SessionIdleTTL if config.Environment == Production { minimumIdle = 10 * time.Minute minimumAbsolute = 24 * time.Hour } if config.SessionIdleTTL < minimumIdle || config.SessionIdleTTL > 24*time.Hour { fields = append(fields, fmt.Sprintf("PULSE_SESSION_IDLE_TTL must be between %s and 24h", minimumIdle)) } if config.SessionAbsoluteTTL < minimumAbsolute || config.SessionAbsoluteTTL > 30*24*time.Hour || config.SessionAbsoluteTTL < config.SessionIdleTTL { fields = append(fields, fmt.Sprintf("PULSE_SESSION_ABSOLUTE_TTL must be between %s and 720h and not shorter than PULSE_SESSION_IDLE_TTL", minimumAbsolute)) } if config.NotificationWebhookURL != "" { validateURL(&fields, "PULSE_NOTIFICATION_WEBHOOK_URL", config.NotificationWebhookURL, config.Environment, true) parsed, err := url.Parse(config.NotificationWebhookURL) if err == nil && (parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "") { fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_URL may not contain credentials, query parameters, or a fragment") } if strings.TrimSpace(config.NotificationWebhookToken) == "" { fields = append(fields, "PULSE_NOTIFICATION_WEBHOOK_TOKEN is required when the webhook is configured") } } if config.BackupRetention < 1 || config.BackupRetention > 100 { fields = append(fields, "PULSE_BACKUP_RETENTION must be between 1 and 100") } if config.PublicURL != "" { validateURL(&fields, "PULSE_PUBLIC_URL", config.PublicURL, config.Environment, true) } if config.DatabaseURL != "" { validateDatabaseURL(&fields, config.DatabaseURL) } if config.PrometheusURL != "" { validateURL(&fields, "PULSE_PROMETHEUS_URL", config.PrometheusURL, config.Environment, false) } if config.UnraidURL != "" { validateURL(&fields, "PULSE_UNRAID_URL", config.UnraidURL, config.Environment, false) } if config.ContainerSourceID != "" && !uuidPattern.MatchString(config.ContainerSourceID) { fields = append(fields, "PULSE_CONTAINER_SOURCE_ID must be a UUID") } if config.AuthMode != "oidc" && config.AuthMode != "mock" { fields = append(fields, "PULSE_AUTH_MODE must be oidc or mock") } if config.OIDCIssuer != "" { validateURL(&fields, "PULSE_OIDC_ISSUER", config.OIDCIssuer, config.Environment, true) } if config.OIDCRedirectURL != "" { validateURL(&fields, "PULSE_OIDC_REDIRECT_URL", config.OIDCRedirectURL, config.Environment, true) } if config.Environment == Production { for key, value := range map[string]string{ "PULSE_PUBLIC_URL": config.PublicURL, "PULSE_DATABASE_URL": config.DatabaseURL, "PULSE_OIDC_ISSUER": config.OIDCIssuer, "PULSE_OIDC_CLIENT_ID": config.OIDCClientID, "PULSE_OIDC_CLIENT_SECRET": config.OIDCClientSecret, "PULSE_OIDC_REDIRECT_URL": config.OIDCRedirectURL, } { if strings.TrimSpace(value) == "" { fields = append(fields, key+" is required in production") } } if config.AuthMode != "oidc" { fields = append(fields, "PULSE_AUTH_MODE=mock is forbidden in production") } if len(config.OIDCRoleMapping) == 0 { fields = append(fields, "PULSE_OIDC_ROLE_MAPPING is required in production; without it no identity can be granted a role") } if config.BreakGlassEnabled { fields = append(fields, "PULSE_BREAK_GLASS_ENABLED must remain false in production until secure initialization exists") } } if len(fields) > 0 { return &ValidationError{Fields: fields} } return nil } func (c Config) String() string { return fmt.Sprintf("Config{environment=%s, public_url=%s, database_url=%s, oidc_issuer=%s, oidc_client_id=%s, oidc_client_secret=%s, unraid_api_token=%s, notification_webhook_url=%s, notification_webhook_token=%s}", c.Environment, redacted(c.PublicURL), redacted(c.DatabaseURL), redacted(c.OIDCIssuer), redacted(c.OIDCClientID), redacted(c.OIDCClientSecret), redacted(c.UnraidAPIToken), redacted(c.NotificationWebhookURL), redacted(c.NotificationWebhookToken)) } func (c Config) Redacted() Config { c.DatabaseURL = redacted(c.DatabaseURL) c.OIDCClientSecret = redacted(c.OIDCClientSecret) c.UnraidAPIToken = redacted(c.UnraidAPIToken) c.NotificationWebhookToken = redacted(c.NotificationWebhookToken) return c } func redacted(value string) string { if strings.TrimSpace(value) == "" { return "" } return "" } func validateDatabaseURL(fields *[]string, raw string) { if err := ValidateDatabaseURL(raw); err != nil { *fields = append(*fields, err.Error()) } } // ValidateDatabaseURL reports whether raw is a usable PostgreSQL URL. It is exported so // services that take only the database URL from the environment — pulse-agent, which // must not require the API's public URL or OIDC settings — apply the same rule as the // full configuration loader instead of inventing a second one. func ValidateDatabaseURL(raw string) error { parsed, err := url.Parse(raw) if err != nil || (parsed.Scheme != "postgres" && parsed.Scheme != "postgresql") || parsed.Host == "" { return errors.New("PULSE_DATABASE_URL must be a PostgreSQL URL") } return nil } // ValidateURL is the reusable strict URL boundary for narrowly scoped runtime // components. The full application validation keeps field-specific messages; callers // such as pulse-agent need the same HTTPS/absolute-url policy without copying it. func ValidateURL(raw string, requireHTTPS bool) error { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return errors.New("must be an absolute URL") } if requireHTTPS && parsed.Scheme != "https" { return errors.New("must use https") } return nil } func validateURL(fields *[]string, key, raw string, environment Environment, requireHTTPS bool) { parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { *fields = append(*fields, key+" must be an absolute URL") return } if requireHTTPS && environment == Production && parsed.Scheme != "https" { *fields = append(*fields, key+" must use https in production") } } func contains(values []string, target string) bool { for _, value := range values { if value == target { return true } } return false } // uuidPattern bounds identifiers that must reference a database row. var uuidPattern = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$") // maxAllowedNetworks mirrors the bound enforced by the probe network policy. const maxAllowedNetworks = 32 // parseAllowedNetworks reads a comma-separated CIDR list. An invalid or // unbounded list fails startup rather than silently widening or narrowing what // probes may reach. func parseAllowedNetworks(raw string) ([]string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil, nil } entries := strings.Split(trimmed, ",") networks := make([]string, 0, len(entries)) for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" { continue } if _, err := netip.ParsePrefix(entry); err != nil { return nil, errors.New("PULSE_PROBE_ALLOWED_NETWORKS entries must be CIDR prefixes") } networks = append(networks, entry) } if len(networks) == 0 || len(networks) > maxAllowedNetworks { return nil, fmt.Errorf("PULSE_PROBE_ALLOWED_NETWORKS must contain between 1 and %d CIDR prefixes", maxAllowedNetworks) } return networks, nil } // knownRoles bounds the Pulse role names accepted in PULSE_OIDC_ROLE_MAPPING. It // mirrors the roles defined in internal/auth without importing that package, so // configuration stays free of runtime dependencies. var knownRoles = []string{"viewer", "operator", "editor", "administrator"} // parseRoleMapping reads a comma-separated "claim=role" list mapping identity // provider group claim values onto Pulse roles, for example // "pulse-admin=administrator,pulse-staff=viewer". An empty value yields a nil map, // which means no group grants access. func parseRoleMapping(raw string) (map[string]string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil, nil } mapping := make(map[string]string) for _, entry := range strings.Split(trimmed, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } claim, role, found := strings.Cut(entry, "=") claim = strings.TrimSpace(claim) role = strings.ToLower(strings.TrimSpace(role)) if !found || claim == "" || role == "" { return nil, errors.New("PULSE_OIDC_ROLE_MAPPING entries must use claim=role") } if !contains(knownRoles, role) { return nil, errors.New("PULSE_OIDC_ROLE_MAPPING role must be one of " + strings.Join(knownRoles, ", ")) } if _, duplicate := mapping[claim]; duplicate { return nil, errors.New("PULSE_OIDC_ROLE_MAPPING contains a duplicate claim") } mapping[claim] = role } if len(mapping) == 0 { return nil, errors.New("PULSE_OIDC_ROLE_MAPPING must contain at least one claim=role entry") } return mapping, nil }