Backup route sync: jetstream watcher replacing manual domain-map var
closedFollow-up from Task 7 spec review (backup-MX bug d43eebe).
Problem
The backup Stalwart’s domain map (Relay arms + RCPT allowRelaying) is rendered from the Ansible var sovrn_backup_domain_routes, requiring a manual –tags stalwart-backup-bootstrap re-run on every onboard/retire. This reintroduces the control-plane<->backup coupling the at.sovrn.domain.route jetstream design exists to avoid. Safe for v1 (empty map converges CLOSED = reject; stale map bounces, never open-relays) but operator-driven and availability-limiting.
Fix spec
Extend the stalwart_backup role with a new unit (e.g. sovrn-route-sync.service, long-lived consumer or timer): small Go binary (e.g. cmd/route-sync) subscribing to jetstream at.sovrn.domain.route, filtering authorDid == postmaster DID, verifying resolveHandle(postmaster.at.
No cheaper correct v1 mechanism: a control-plane push would cement the coupling; the manual var is the interim.
Also required before live traffic (Task 8)
Runbook paragraph (relay-drain-verify.md or deployment.md ops): on every onboard/retire, update sovrn_backup_domain_routes + re-run the bootstrap tag; document empty-map-CLOSED and stale-map-bounce semantics. Fold into Task 8 docs work.
2 Comments
Poll-Based Backup Relay Domain Sync — Implementation Plan
Goal: Replace the manual
sovrn_backup_domain_routesmap with a 60-second poll: each cell exposes its hosted-domain list over a bearer-authenticated endpoint, and a small Go service on the pri-20 relay merges the lists and applies domain→cell routing to Stalwart.Architecture: Cells read their own DB (
store.ListDomainsByStatus) and serveGET /backup/domains(bearer token; path deliberately not under/internal/*, which Caddy blocks at the edge). A newcmd/backup-route-syncservice polls every configured cell, caches last-known-good per cell on disk (survives the cell outage it exists for), merges withactive > degraded > verifyingprecedence, and applies the FULL relay config (routes, schedule, strategy, RCPT relaying) viainternal/stalwart.Client. The existingat.sovrn.domain.routerecord stays as the on-protocol source of truth but is not consumed by the relay in v1.Tech Stack: Go (stdlib
net/http,crypto/subtle),internal/stalwartJMAP client,internal/store, viper config, Ansible/systemd, SQLite.Decision log (confirmed with operator)
/backup/domains.active,degraded,verifying(permissive — pri-20 covers mid-provisioning).active > degraded > verifying; ties break by configured cell order; conflicts logged./metricsin v1).sovrn_backup_domain_routes, and the map-hash statefile are removed.File Structure
Create -
internal/appview/backup_domains.go— hosted-domain HTTP handler (+_test.go) -internal/routesync/cellclient.go— fetch one cell’s list (+_test.go) -internal/routesync/merge.go— merge/precedence (+_test.go) -internal/routesync/express.go— Stalwart Expression builders (+_test.go) -internal/routesync/apply.go— full relay JMAP apply (+_test.go) -internal/routesync/state.go— per-cell last-known-good cache (+_test.go) -internal/routesync/poller.go— poll loop + staleness (+_test.go) -cmd/backup-route-sync/main.go— service entrypoint -deployment/roles/stalwart_backup/tasks/routesync.yml-deployment/roles/stalwart_backup/templates/sovrn-route-sync.service.j2-deployment/roles/stalwart_backup/templates/route-sync.toml.j2Modify -
config.go,config_test.go—[backupdomains] tokenfile-router.go— mountGET /backup/domains-deployment/roles/sovrnd/templates/sovrn.toml.j2-deployment/inventory/group_vars/all/sovrn.yml— dropsovrn_backup_domain_routes; add token + sync vars -deployment/inventory/host_vars/mxb.eu.sovrn.at/vars.yml-deployment/roles/stalwart_backup/tasks/bootstrap.yml,relay.yml-deployment/roles/stalwart/files/bootstrap-stalwart.sh— remove relay phase (option A) -deployment/scripts/provision-shared-secrets,ensure-bootstrap-secrets-deployment/playbooks/site.yml-docs/runbooks/relay-drain-verify.md,docs/deployment.md,docs/adr/0009-cell-architecture.md,docs/08-security-compliance.mdPhases
A = control plane + sync service (locally testable). B = deployment + docs.
Task 1:
[backupdomains]config + shared token secretFiles: Modify
config.go,config_test.go;deployment/roles/sovrnd/templates/sovrn.toml.j2;deployment/scripts/provision-shared-secrets;deployment/scripts/ensure-bootstrap-secrets;deployment/inventory/group_vars/all/sovrn.yml;deployment/roles/sovrn_secrets/tasks/main.yml.TestBackupDomainsTokenFileBinds(t.SetenvSOVRN_BACKUPDOMAINS_TOKENFILE; assertcfg.BackupDomains.TokenFile).go test . -run TestBackupDomainsTokenFileBinds -v.BackupDomainsConfig{TokenFile string mapstructure:"tokenfile"}, addBackupDomainstoConfig, append"backupdomains.tokenfile"tobindEnv. Empty disables the endpoint.[backupdomains] tokenfileinsovrn.toml.j2;sovrn_backup_domains_tokeninprovision-shared-secrets+required_shared_keys; materialize${sovrn_secrets_dir}/backup-domains-token0600 insovrn_secrets; var ingroup_vars/all/sovrn.yml.feat: backupdomains token config and shared secret.Task 2:
GET /backup/domainshandlerFiles: Create
internal/appview/backup_domains.go,backup_domains_test.go; modifyrouter.go.celland only active/degraded/verifying;BackupDomainsPathnot prefixed/internal/.ListDomainsByStatus(active, degraded, verifying); JSON{cell,updatedAt,domains:[{name,status}]}.topmux only whencfg.BackupDomains.TokenFile != ""; fatal if set but unreadable; cell =cfg.CellSelfHost()fallbackcfg.Mail.Hostname.go test ./....feat: cell hosted-domains endpoint for backup relay.Task 3: Cell client + merge
Files: Create
internal/routesync/cellclient.go,merge.go(+tests).active > degraded > verifying; tie-break by cell order; conflict returned.Fetch(ctx, httpClient, cell, token)andMerge(cells, per).feat: backup relay cell client and domain merge.Task 4: Stalwart relay apply (option A)
Files: Create
internal/routesync/express.go,apply.go(+tests).if rcpt_domain == 'example.com'/then 'cell-mx1-eu-sovrn-at'/elsefirst route; relayingelse "false"; apply call order routes→schedule→strategy→relaying→reload; route create tolerates primaryKeyViolation.routeName,StrategyExpr,RelayingExpr,Apply(ctx, *stalwart.Client, RelayMap)(routes Relay no-auth,remoteschedule TTL + 5m/15m/1h/3h/8h/24h/48h/72h ladder, singleton patches, ReloadSettings).feat: Go backup relay config apply.Task 5: Poller, cache, service entrypoint
Files: Create
internal/routesync/state.go,poller.go(+tests),cmd/backup-route-sync/main.go.State(Load/Save temp+rename 0640),Poller(60s ticker, immediate apply on start, SIGHUP force-pull, retain-on-failure indefinitely), andmain.go(flags/envCELLS,POLL_INTERVAL=60s,TOKEN_FILE,STALWART_URL,STALWART_USER,ADMIN_CREDS_FILE,STATE_PATH,LISTEN=127.0.0.1:8091;GET /healthz200 when every cell seen within 3×interval else 503; structured slog only — no /metrics).go test ./internal/routesync/... ./cmd/....feat: backup route sync poller service.Task 6: Ansible — ship service, drop the manual map
Files: Create
deployment/roles/stalwart_backup/tasks/routesync.yml,templates/sovrn-route-sync.service.j2,templates/route-sync.toml.j2. Modifytasks/bootstrap.yml,tasks/relay.yml,files/bootstrap-stalwart.sh,group_vars/all/sovrn.yml,host_vars/mxb.eu.sovrn.at/vars.yml,playbooks/site.yml.sovrn_backup_domain_routes; add token/sync vars.mail_backuphosts (mirror sovrnd rolego build+ GLIBC assert + copy + patchelf); install config + unit (After=stalwart.service,Restart=on-failure), enable+start.relayphase frombootstrap-stalwart.sh; remove relay invocation frombootstrap.yml/site.yml; deleterelay.yml+ map-hash statefile.routesync.ymlasserts cells non-empty; rendersroute-sync.toml;no_logon token-adjacent tasks.python3 deployment/scripts/test-deployand ansible--checkonmxb.deploy: backup route sync service replaces manual domain map.Task 7: Docs, runbook, ADR, bug close-out
Files: Modify
docs/runbooks/relay-drain-verify.md,docs/deployment.md,docs/adr/0009-cell-architecture.md,docs/08-security-compliance.md.at.sovrn.domain.routeremains the public on-protocol record.docs: poll-based backup relay domain sync.Implemented: poll-based backup relay domain sync (supersedes the jetstream watcher)
Design (operator-approved): cells serve their hosted-domain list over an authenticated endpoint; the relay polls it and owns the full Stalwart relay config. No ATProto/jetstream dependency, no control-plane push, no manual domain map.
Commits (base
bf9cc8e9)493169bcfeat: backupdomains token config and shared secret115c5c62feat: cell hosted-domains endpoint for backup relaye73d843dfeat: backup relay cell client and domain merge3bd6b255feat: Go backup relay config apply857bda23feat: backup route sync poller servicecfece1c0deploy: backup route sync service replaces manual domain map280932e4fix: route-sync uses the recovery admin secret15d3fb46docs: poll-based backup relay domain syncd6f5aa71docs: correct route-sync auth comment in group_vars31dc2f41fix: identify backup-domains endpoint by cell mail hostnameWhat shipped
GET /backup/domainson sovrnd (bearer auth;active|degraded|verifying; not under/internal/; identity = cell mail hostname; mounted only whenbackupdomains.tokenfileset).sovrn_backup_domains_token→/etc/sovrn/secrets/backup-domains-token(0600).cmd/backup-route-sync: 60s poll, durable per-cell last-known-good (/var/lib/sovrn-route-sync/state.json), retain-forever on cell failure, mergeactive>degraded>verifying(cell-order tie-break, conflicts logged), applies only on canonical change. Owns per-cell Relay routes,remoteschedule TTL/backoff, outbound-strategy arms, andMtaStageRcpt.allowRelayingelse=false. Loopback/healthz+ JSON logs; no/metrics.mail_backuphosts; auth = recovery admin (sovrn_stalwart_user+sovrn_stalwart_secretfile).sovrn_backup_domain_routes,sovrn_backup_relay_state,stalwart_backup/tasks/relay.yml, and the shellrelayphase.at.sovrn.domain.routerecords are still written (on-protocol source of truth) but not consumed by the relay in v1.Verification (all green)
SOVRN_INTEGRATION=0 go test ./...;go vet ./...;go test -race(routesync + cmd);python3 deployment/scripts/test-deploy(23 tests);bash -nbootstrap script;ansible-playbook --syntax-check; no stale references.Residual risk — MUST verify live (Task 8)
GET https://<cell>/backup/domainsvia Caddy with the shared token;sovrn_backup_cellsentries are cell mail/MX hostnames and must serve the app vhost over HTTPS (invariant documented)./get+/set, schedule name query+set, strategy/relaying singleton updates,ReloadSettings; nonotUpdated/notCreated; confirm the inferredprimaryKeyViolationtype string.allowRelaying = !is_empty(authenticated_as)⇒ unauthenticated port 25 denied).GLIBC_2.41ceiling +patchelfinterpreter fix on the trixie relay host.Migration: existing vaults must run
just provision-shared-secretsonce (addssovrn_backup_domains_token) beforejust update.