Let an operator authenticate with a seeded key, and reset the schema
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Failing after 19s
CI / test (pull_request) Successful in 5m34s

- 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
This commit is contained in:
Niklas Ye
2026-10-09 14:56:13 +02:00
parent 44b2eb2cc3
commit 9029d48584
46 changed files with 1445 additions and 655 deletions
+37 -34
View File
@@ -5,17 +5,21 @@ import (
"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:
// 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
// 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
@@ -26,25 +30,6 @@ type Config struct {
// 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
@@ -70,6 +55,20 @@ type Config struct {
// 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
@@ -133,24 +132,12 @@ func Load() Config {
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"),
@@ -158,7 +145,10 @@ func Load() Config {
NotifyRepeat: duration("TERDUT_NOTIFY_REPEAT", 15*time.Minute),
DisablePasswordLogin: !boolean("TERDUT_PASSWORD_LOGIN", true),
OIDC: loadOIDC(),
OperatorKey: strings.TrimSpace(os.Getenv("TERDUT_OPERATOR_KEY")),
TrustedProxies: integer("TERDUT_TRUSTED_PROXIES", 1),
OIDC: loadOIDC(),
OperatorMode: boolean("TERDUT_OPERATOR_MODE", false),
}
@@ -187,6 +177,9 @@ func loadOIDC() OIDC {
// 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 {
@@ -226,6 +219,16 @@ func str(env, def string) string {
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)