9029d48584
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
265 lines
9.7 KiB
Go
265 lines
9.7 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
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
|
|
}
|