Public source validation / validate (push) Failing after 3m8s
467 lines
18 KiB
Go
467 lines
18 KiB
Go
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 "<unset>"
|
|
}
|
|
return "<set>"
|
|
}
|
|
|
|
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
|
|
}
|