package config import ( "encoding/json" "errors" "fmt" "net/url" "os" "strings" "time" ) type Config struct { Addr string // DSN is the Postgres connection string, e.g. // postgres://terdut:secret@host:5432/terdut?sslmode=require. Required: // unlike the SQLite path it replaced there is no sensible default, and a // server that silently came up against the wrong database would be worse // than one that refuses to start. DSN string ArchiveAfter time.Duration // StaleAfter is how long a firing alert may go without a refreshing webhook // before the sweeper treats it as resolved. It must exceed Alertmanager's // repeat_interval (default 4h), which is what refreshes the alert. StaleAfter time.Duration // DeadmanMatchers selects the alerts that are heartbeats rather than // problems: receiving one opens no incident, and the absence of one does. // // ";" separates matchers, "," the label conditions within one, "=" is exact // equality — `alertname=Watchdog,cluster=prod; alertname=Heartbeat`. Every // matcher must name an alertname. See api.ParseDeadmanConfig. DeadmanMatchers string // DeadmanTimeout is how long a heartbeat may go unheard before its switch is // declared dead. It must be *shorter* than the Alertmanager repeat_interval // of the route carrying the heartbeat — the opposite of StaleAfter, and the // reason a dead man's switch usually wants a route of its own. Zero disables // dead man's switch handling entirely. DeadmanTimeout time.Duration // DeadmanSeverity is the severity a dead man's switch incident opens at. // These incidents have no member alerts to derive one from. DeadmanSeverity string // NtfyURL is the ntfy server push notifications are published to. Empty // disables notifications entirely. NtfyURL string // NtfyToken is an optional bearer token for an access-controlled ntfy. NtfyToken string // NtfyFallbackTopic receives incidents that open with nobody on call. NtfyFallbackTopic string // PublicURL is the base URL a phone uses to reach this server, used for the // link and the Acknowledge button inside a notification. Without it // notifications carry neither. PublicURL string // NotifyRepeat is how long an incident may sit unacknowledged before it is // notified again. Zero disables reminders. NotifyRepeat time.Duration // DisablePasswordLogin refuses signing in, or signing up, with a password. // It is how an install moves to SSO only, and turning it back off is the way // in when the identity provider is down. Stated negatively so that the zero // Config, which is what a test or a new caller builds, keeps passwords working. DisablePasswordLogin bool // OIDC configures single sign-on. The zero value, with no Issuer, is off. OIDC OIDC } // OIDC is the single sign-on configuration. Groups from the provider decide // who may sign in, which teams they belong to, and whether they administer the // install, in the manner of Grafana's org and role mapping. type OIDC struct { // Issuer is the provider's issuer URL. Discovery is fetched from // /.well-known/openid-configuration. For Authentik this is the // application's issuer, e.g. https://auth.example.com/application/o/terdut/. // Empty turns single sign-on off. Issuer string ClientID string ClientSecret string // Name is what the sign-in button calls the provider. Name string // Scopes to request. The groups claim normally needs "profile" on Authentik. Scopes []string // UsernameClaim, EmailClaim and GroupsClaim name the ID token claims read. UsernameClaim string EmailClaim string GroupsClaim string // TrustEmail links a sign-in to an existing local user by email even when the // provider does not vouch that the address is verified. Authentik reports // email_verified false unless told otherwise, and an install that runs its // own provider has already decided that its addresses can be trusted. TrustEmail bool // AllowedGroups gates sign-in: somebody in none of them is refused, however // well the provider authenticated them. Empty admits everybody the provider // authenticates, and access control is left to the provider. AllowedGroups []string // AdminGroup grants the system administrator flag while the user is in it. AdminGroup string // GroupMappings grants team roles. A user in Group gets Role in Team. GroupMappings []GroupMapping // SessionMaxAge is the hard ceiling on a session made by an SSO login. The // login is the only moment groups are re-read, so this is how long a change // in the provider may take to reach terdut. SessionMaxAge time.Duration // parseErr is a malformed TERDUT_OIDC_GROUP_MAPPINGS, reported by Validate: // Load cannot fail, and a mapping that was silently dropped would grant // less access than the operator wrote down. parseErr error } // GroupMapping grants Role in Team to members of Group. type GroupMapping struct { Group string `json:"group"` Team string `json:"team"` Role string `json:"role"` } // Enabled reports whether single sign-on is configured. func (o OIDC) Enabled() bool { return o.Issuer != "" } func Load() Config { addr := os.Getenv("TERDUT_ADDR") if addr == "" { addr = ":8080" } deadmanMatchers := os.Getenv("TERDUT_DEADMAN_MATCHERS") if deadmanMatchers == "" { deadmanMatchers = "alertname=Watchdog" } deadmanSeverity := os.Getenv("TERDUT_DEADMAN_SEVERITY") if deadmanSeverity == "" { deadmanSeverity = "critical" } return Config{ Addr: addr, DSN: os.Getenv("TERDUT_DB_DSN"), ArchiveAfter: duration("TERDUT_ARCHIVE_AFTER", 7*24*time.Hour), StaleAfter: duration("TERDUT_STALE_AFTER", 6*time.Hour), DeadmanMatchers: deadmanMatchers, DeadmanTimeout: duration("TERDUT_DEADMAN_TIMEOUT", 15*time.Minute), DeadmanSeverity: deadmanSeverity, NtfyURL: os.Getenv("TERDUT_NTFY_URL"), NtfyToken: os.Getenv("TERDUT_NTFY_TOKEN"), NtfyFallbackTopic: os.Getenv("TERDUT_NTFY_FALLBACK_TOPIC"), PublicURL: os.Getenv("TERDUT_PUBLIC_URL"), NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute), DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true), OIDC: loadOIDC(), } } func loadOIDC() OIDC { o := OIDC{ Issuer: strings.TrimSpace(os.Getenv("TERDUT_OIDC_ISSUER")), ClientID: os.Getenv("TERDUT_OIDC_CLIENT_ID"), ClientSecret: os.Getenv("TERDUT_OIDC_CLIENT_SECRET"), Name: str("TERDUT_OIDC_NAME", "SSO"), Scopes: list("TERDUT_OIDC_SCOPES", "openid profile email"), UsernameClaim: str("TERDUT_OIDC_USERNAME_CLAIM", "preferred_username"), EmailClaim: str("TERDUT_OIDC_EMAIL_CLAIM", "email"), GroupsClaim: str("TERDUT_OIDC_GROUPS_CLAIM", "groups"), TrustEmail: boolean("TERDUT_OIDC_TRUST_EMAIL", false), AllowedGroups: list("TERDUT_OIDC_ALLOWED_GROUPS", ""), AdminGroup: os.Getenv("TERDUT_OIDC_ADMIN_GROUP"), SessionMaxAge: duration("TERDUT_OIDC_SESSION_MAX_AGE", 12*time.Hour), } if raw := strings.TrimSpace(os.Getenv("TERDUT_OIDC_GROUP_MAPPINGS")); raw != "" { if err := json.Unmarshal([]byte(raw), &o.GroupMappings); err != nil { o.parseErr = fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS: %w", err) } } return o } // Validate reports a configuration the server should refuse to start with. // Single sign-on is the only part that can be inconsistent: a half-configured // provider would come up and then fail every login, which is harder to notice // than not starting. func (c Config) Validate() error { o := c.OIDC if o.parseErr != nil { return o.parseErr } if !o.Enabled() { if c.DisablePasswordLogin { return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in") } if len(o.GroupMappings) > 0 || o.AdminGroup != "" || len(o.AllowedGroups) > 0 { return errors.New("TERDUT_OIDC_* group settings are set but TERDUT_OIDC_ISSUER is not") } return nil } if u, err := url.Parse(o.Issuer); err != nil || u.Scheme == "" || u.Host == "" { return fmt.Errorf("TERDUT_OIDC_ISSUER %q is not a URL", o.Issuer) } if o.ClientID == "" || o.ClientSecret == "" { return errors.New("TERDUT_OIDC_CLIENT_ID and TERDUT_OIDC_CLIENT_SECRET are required with TERDUT_OIDC_ISSUER") } if c.PublicURL == "" { return errors.New("TERDUT_PUBLIC_URL is required with TERDUT_OIDC_ISSUER: it is the base of the redirect URI") } if o.SessionMaxAge <= 0 { return errors.New("TERDUT_OIDC_SESSION_MAX_AGE must be positive") } for i, m := range o.GroupMappings { if m.Group == "" || m.Team == "" { return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: group and team are required", i) } if m.Role != "owner" && m.Role != "member" { return fmt.Errorf("TERDUT_OIDC_GROUP_MAPPINGS[%d]: role must be owner or member, got %q", i, m.Role) } } if c.DisablePasswordLogin && len(o.GroupMappings) == 0 && o.AdminGroup == "" { return errors.New("TERDUT_PASSWORD_LOGIN=false with no OIDC group grants leaves nobody able to do anything") } return nil } func str(env, def string) string { if s := strings.TrimSpace(os.Getenv(env)); s != "" { return s } return def } // list reads a comma- or space-separated env var. func list(env, def string) []string { s := os.Getenv(env) if strings.TrimSpace(s) == "" { s = def } return strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) } // boolean reads a true/false env var. An unrecognised value takes the default, // so the two flags read this way (password login on, trusting email off) both // fail towards the cautious setting. func boolean(env string, def bool) bool { switch strings.ToLower(strings.TrimSpace(os.Getenv(env))) { case "true", "1", "yes": return true case "false", "0", "no": return false } return def } // duration reads a time.ParseDuration-formatted env var. An unset or // unparseable value falls back to def rather than failing startup: a typo in one // tuning knob should not take the server down. func duration(env string, def time.Duration) time.Duration { if s := os.Getenv(env); s != "" { if d, err := time.ParseDuration(s); err == nil { return d } } return def }