Files
terdut-server/internal/config/config.go
T
Niklas Ye f344bb707b Graph the metric behind each alert on the incident page
Each alert carries a generatorURL pointing at the Prometheus rule that fired
it. The server now parses that link, queries the same Prometheus for the
rule's expression and draws it under the alert, with the current value
beside it. The browser never talks to Prometheus, so the CSP stays at
connect-src 'self'.

The URL comes out of a webhook payload, so whoever holds an integration key
chooses it. Querying it is therefore opt-in and bounded by an allowlist,
TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as
several, IPs, CIDRs). The check runs on the address the dialer is about to
use, a name that resolves to loopback or link-local is refused even when it
matches, and redirects and proxy variables are ignored.

A worker takes a snapshot when an incident opens and another when it
closes, so the graph outlives Prometheus' retention. It finds its work in
the database rather than being triggered from the webhook, so ingestion is
untouched and a restart loses nothing. While an incident is open the
metrics endpoint also queries live, cached for 15 seconds.

The rule expression keeps its threshold, so Prometheus returns data only
while the alert condition holds; "no data" for the current value means the
condition cleared. Stripping the threshold needs a PromQL parser and is left
out.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 14:10:34 +02:00

288 lines
11 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/prom"
)
// MinOperatorKeyLength is the shortest TERDUT_OPERATOR_KEY accepted: it is a
// bearer credential with instance reach, so a short one is refused outright.
const MinOperatorKeyLength = 32
type Config struct {
Addr string
// DSN is the Postgres connection string, e.g.
// postgres://terdut:secret@host:5432/terdut?sslmode=require. Required:
// 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
// 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
// OperatorKey, when set, is the credential of the instance-scoped service
// account "terdut-operator", created or re-keyed at every start. It is how
// terdut-operator gets in without a bootstrap handshake: the operator
// generates the key, hands it to the server here, and uses it as its bearer
// token. Empty means no such account is managed.
OperatorKey string
// TrustedProxies is how many reverse proxies sit in front of the server and
// append to X-Forwarded-For. The per-address rate limits take the client
// address that many entries from the right, because everything further left
// is whatever the client chose to send. 0 ignores the header and uses the
// connection's own address.
TrustedProxies int
// OIDC configures single sign-on. The zero value, with no Issuer, is off.
OIDC OIDC
// OperatorMode declares this install gitops-managed: writes to teams,
// escalation policies, dead man's switches and integrations from a human
// (a session or a user's own API key) are refused, while a service
// account's are not. Deploy-time and restart-required, like the rest of
// "where this server is plugged in" — it is a statement about who owns
// this install's configuration, not a per-request toggle.
OperatorMode bool
// PrometheusAllowedHosts lists the Prometheus servers this install may query
// for the metrics behind an alert, matched against the host in each alert's
// generatorURL. Entries are host names (with "*" and a leading "**" as
// wildcards), IPs or CIDR ranges, optionally with a port; see prom.Allowlist.
// Empty turns the feature off, which is the default: the URL comes from a
// webhook payload, so querying it is opt-in.
PrometheusAllowedHosts []string
// PrometheusPreroll is how far before an alert started its graph begins.
PrometheusPreroll time.Duration
// PrometheusTimeout bounds one request to a Prometheus.
PrometheusTimeout time.Duration
}
// 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
// <Issuer>/.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
// 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
}
// 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"
}
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),
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),
OperatorKey: strings.TrimSpace(os.Getenv("TERDUT_OPERATOR_KEY")),
TrustedProxies: integer("TERDUT_TRUSTED_PROXIES", 1),
OIDC: loadOIDC(),
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
PrometheusAllowedHosts: list("TERDUT_PROMETHEUS_ALLOWED_HOSTS", ""),
PrometheusPreroll: duration("TERDUT_PROMETHEUS_PREROLL", time.Hour),
PrometheusTimeout: duration("TERDUT_PROMETHEUS_TIMEOUT", 10*time.Second),
}
}
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),
}
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 {
if c.OperatorKey != "" && len(c.OperatorKey) < MinOperatorKeyLength {
return fmt.Errorf("TERDUT_OPERATOR_KEY must be at least %d characters", MinOperatorKeyLength)
}
if _, err := prom.ParseAllowlist(c.PrometheusAllowedHosts); err != nil {
return fmt.Errorf("TERDUT_PROMETHEUS_ALLOWED_HOSTS: %w", err)
}
o := c.OIDC
if !o.Enabled() {
if c.DisablePasswordLogin {
return errors.New("TERDUT_PASSWORD_LOGIN=false without TERDUT_OIDC_ISSUER leaves no way to sign in")
}
if 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")
}
// Team grants are no longer visible here: they live on each team's own
// oidc_member_group/oidc_owner_group columns, set by that team's owner, not
// in config Validate can see at startup. The one thing left to guard against
// is an install nobody can administer at all.
if c.DisablePasswordLogin && o.AdminGroup == "" {
return errors.New("TERDUT_PASSWORD_LOGIN=false with no TERDUT_OIDC_ADMIN_GROUP leaves nobody able to administer the install")
}
return nil
}
func str(env, def string) string {
if s := strings.TrimSpace(os.Getenv(env)); s != "" {
return s
}
return def
}
// integer reads a non-negative int env var; anything else takes the default.
func integer(env string, def int) int {
if s := os.Getenv(env); s != "" {
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n >= 0 {
return n
}
}
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
}