Let an operator authenticate with a seeded key, and reset the schema
- 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:
+37
-34
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user