Stalwart bootstrap as a checked-in JSON apply plan (incl. sovrnd service account)
closedReplace deployment/roles/stalwart/files/bootstrap-stalwart.sh (482 lines of curl/jq JMAP calls) with a checked-in, auditable JSON plan that stalwart-cli apply consumes. It encodes every step needed to bootstrap a Stalwart cell, including the sovrnd service account.
Source material
- Start from scratch, or from
stalwart-cli snapshotof mx99.eu.sovrn.at. mx99 is a smoke-test box only, so the snapshot is just a starting point for further edits, not a source of truth. Strip IDs, defaults and anything environment-specific. - Use
stalwart-cli describe <Object>against 0.16.23 to confirm property names and shapes.
Format (aim: readable in code review)
- The plan source lives in the repo as pretty-printed JSON, e.g.
deployment/stalwart/cell.plan.jsonandrelay.plan.json, or split per concern (listeners.json,acme.json,accounts.json, …). One operation per array element, with a"_comment"or adjacent README explaining each block. - A build step compacts it to NDJSON (
jq -c '.[]'), becauseapplyreads one operation per line. - Per-host values (hostname, mail domain, ACME directory/contact) are placeholders filled from Nix options. Secrets are never in the file or the Nix store: fill them at runtime in the converge oneshot, e.g.
jq --rawfilefrom the Colmena key file. - Validate with
stalwart-cli apply --dry-runas a flake check, so malformed plans fail in CI.
What the plan must encode (current script behavior)
- Tracer: Stdout (journal), so Vector ships
_SYSTEMD_UNIT=stalwart.service. - Bootstrap/SystemSettings: default domain, hostname, plaintext auth stays off, no DKIM generation (only the relay DKIM-signs), no bootstrap TLS cert request.
- Listeners:
reconcilewithscope.- Cell: smtp/25, submissions/465, submission/587, imap/143, imaps/993.
- Relay: :25 only.
- Keep sieve/4190 and http/8080; drop https/443 and pop3s/995.
- Metrics: Prometheus exporter enabled. Needs
ReloadSettingsafterwards (verified live: without it the endpoint stays dark), either as a plan op or a follow-up call. - ACME: AcmeProvider (Http01, directory as the natural key; it has no
nameproperty), and domaincertificateManagement = Automaticwith explicit SANs[hostname](an empty list orders autodiscover/mta-sts names and the order fails). - sovrnd service account (new): a dedicated account with full admin permissions + impersonate, so sovrnd stops authenticating as the recovery admin. Confirm the 0.16 permission/role names (e.g. an admin role + the impersonate permission) via
describe. The password comes from a Colmena key at runtime. Updatesovrn.toml[stalwart] username/secretfileto match.
Wire-shape gotchas already learned (keep in the README)
- Map
fields ( contact,subjectAlternativeNames) are sets on the wire ({"value": true}), not arrays. - In bootstrap mode,
Bootstrap/getserves stale compiled defaults after a successful set. - Prefer
upsert/reconcilewith explicitmatchOneverywhere, so re-applies converge instead of duplicating objects.
Done when applying the plan twice to a fresh Stalwart gives the same state as the old script (listeners, tracer, metrics, cert issued, sovrnd can provision a domain + account via its service account), the second apply reports no changes, and bootstrap-stalwart.sh can be deleted.
2 Comments
From 9f24376: the plan must also pin the admin web UI. Stalwart’s registry
Applicationobject defaults toresourceUrl = https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip, fetched on every start: unpinned, and it blocks startup up to 60s offline. Upsert it withresourceUrl = config.sovrn.services.stalwart.webuiUrl(file:// into the nixpkgs-pinned webui bundle) and disableautoUpdateFrequency. Confirm the natural key and field shapes withstalwart-cli describe Application.Done (pending review)
Started from
stalwart-cli snapshotof mx99 (taken through an SSH tunnel to its loopback :8080; nothing installed on the box). The snapshot was reviewed and cut down to sovrn’s own settings. Stalwart’s built-in defaults (spam data, ~40 settings singletons) and ACME state (the issued Certificate) are left out.Plans (
nix/stalwart/, JSON arrays with a_commentper block, composed indefault.nix): -common.plan.json: - Stdout tracer (reconcile) - Prometheus metrics - admin web UIApplicationpinned tofile://<nixpkgs webui-1.0.11>/webui.zip(was GitHublatest, fetched every start) -cell.plan.json: - AcmeProvider (matchOndirectory, Http01) - mail domain with Automatic certs and an explicit SAN of the hostname, DKIM/DNS Manual - SystemSettings - listeners (reconcile): smtp/submission/submissions/imap/imaps/sieve, plus http on 127.0.0.1:8080 -dev.plan.json: dev’s domain, identity, unprivileged listeners, plaintext IMAP. -roles.plan.json: Stalwart’s four standard roles (verbatim from mx99, 0.16.23) plus theAuthenticationdefault role assignments. -sovrnd.plan.json: thesovrndservice account (sovrnd@<mail domain>, password is$secretsovrnd-stalwart-password) and its role: - extends User + Tenant Administrator - sys* CRUD on Tenant/Domain/Account/AppPassword/DkimSignature/Directory/MtaRoute/MtaDeliverySchedule - Get/Update on Jmap/MtaOutboundStrategy/MtaStageRcpt/SenderAuth/Authentication - Action create/get andactionReloadSettings, Task get/query -impersonate,authenticate- NOT System AdministratorRendering and applying -
nix/scripts/stalwart-render-plan.sh(pkgs.sovrn.stalwart-render-plan):@var@tokens in keys and values,{"$secret": name}from files at converge time,_commentstripped, a hard error on anything unresolved. - Module (sovrn.services.stalwart.plan.{files,vars,secrets}) plus the read-onlyplan.converge=stalwart-converge [URL]. - The cell role sets the cell composition and vars (hostname = host.name, mail domain sovrn.at, LE prod, [email protected]). The service is still not enabled (ba3c79f).Dev:
serve-stalwart.shrenders the same dev composition with the same renderer.just -f Justfile.nix devruns sovrnd as[email protected](passworddata/dev/stalwart/sovrnd-password), so missing permissions surface in dev.Verified - Plan check
checks.stalwart-plan(seconds): a real apply ×2 of the cell and dev compositions against the pinned Stalwart in the build sandbox, with a local pebble as the ACME CA. The second apply must create nothing, and a misspelled property fails it. (--dry-runwas not enough: it accepts unknown properties.) - VM testchecks.stalwart:stalwart-convergein recovery mode twice (idempotent), then normal mode: - exactly the cell listeners (25/465/587/143/993/4190, 8080 on loopback, no 443⁄995) -sovrndreads Domain, is forbidden from Tracer/Certificate - the plan’s ACME provider gets the certificate from pebble - Integration suite as[email protected], against a dev-composition Stalwart: everything passes except two tests that fail identically as the recovery admin, so they’re unrelated to the role: -TestIntegrationAppViewUI(home page title assertion) -TestIntegrationOIDCBearerAuth(ReloadSettings request times out after the Authentication directory switch) - Manually: sovrnd creates tenant/domain/account. A master-user login (alice@dom%[email protected]+ sovrnd’s password) yields Alice’s JMAP session with Sieve. Denied for sovrnd: SieveSystemScript, MtaHook, NetworkListener, Certificate, Tracer.Findings (0.16.23) 1. A declaratively created DB has no roles at all. Default roles come from Bootstrap, which this flow skips; without them users authenticate but get 403. Hence
roles.plan.json. 2. Stalwart refuses to grant permissions the granting account lacks. So sovrnd’s role must contain everything the roles it hands out contain (User, Tenant Administrator). 3. Creating an AcmeProvider registers with the CA synchronously duringapply, so apply needs outbound access and a contact domain the CA accepts (LE rejects.test). 4. Account has no natural key:matchOn ["name","domainId"]is required, or a re-apply tries to create a duplicate. 5. Stalwart rejectslocalhostasdefaultHostname(needs an FQDN). 6. A fresh DB’s first recovery start takes about 40s: the default Application still points at GitHub until the plan replaces it. After converge, startup is seconds. 7. Stalwart also downloads ASN/Geo data from GitHub (sapics/ip-location-db) at startup, another unpinned runtime fetch (follow-up).Out of scope, handed on: relay plan to b45a76f; sovrn.toml
[stalwart] username/secretfileto 63eb0d9; boot wiring to ba3c79f.