Skip to content

Setup Wizard

Purpose. First-boot configuration in the browser for people who would rather not write an answers file. Opening http://<hostname>/ (or http://<ip>/ before DNS resolves) on a server whose setup is still pending shows the wizard — plain HTTP, because the bootstrap Caddyfile has auto_https off and HTTPS starts only once setup applies; nothing else is reachable until it finishes.

Behaviour.

  • Seven steps: hostname → TLS → first domain → owner → AI (optional) → apply → DNS records. Each of the first four is exactly one call to the setup endpoints (PUT /setup/hostname, PUT /setup/tls, POST /setup/domain, POST /setup/owner) — except that the TLS screen also carries the POP3 switch, recorded by a second call, PUT /setup/protocols, which is not a step: leaving it alone keeps POP3 on, and GET /setup reports it as pop3; apply is POST /setup/complete, which renders and validates the daemon configuration and rolls back if any daemon rejects it. The wizard keeps no state of its own: reloading the page resumes at the first step the API has not recorded (GET /setup), and the result is byte-for-byte the same as mailctl install --answers (Headless install (answers file)).

  • TLS modes: automatic (Let’s Encrypt, needs a contact email and ports 80/443 reachable), bring-your-own (paste the PEM chain and key), or none. None is for a stack whose mail ports are published on loopback only — a dev stack, CI: with no TLS the only way a client can sign in to IMAP and POP3 is by sending its password in the clear, and a server doing that on the internet is what CERT-Bund scans for and reports to the hosting provider. GET /setup reports publicBind — whether PUBLISH_BIND is anything but loopback — and when it is, PUT /setup/tls refuses { mode: "none" } with 400 plaintext_auth_exposed unless the body carries allowPlaintextAuth: true. The wizard shows the escape as a checkbox the person must tick, and from then on the apply step, Settings and mailctl doctor (a ✖) say the server takes passwords in clear text on 143 and 587, and on 110 while POP3 is on, for as long as the mode is in use.

  • The owner step pre-fills admin@<first domain> and, by default, also creates that mailbox.

  • AI is optional and skipped by default. The step renders the served catalogue (GET /setup/ai/providers) — never a list compiled into the SPA — takes a model, an endpoint and a key, offers localOnly, and offers the connection test once a configuration is saved. Reading the catalogue is GET /setup/ai/providers, reading the stored configuration GET /setup/ai, writing PUT /setup/ai, testing POST /setup/ai/test, undoing DELETE /setup/ai; all five are unauthenticated while setup is pending and answer 409 afterwards, exactly like the steps above, and they read and write the same per-tenant ai_provider_config the admin AI page uses (AI providers). Skip for now writes nothing at all, which leaves AI off — the state every install ships in. localOnly is enforced at write time here as everywhere else: the endpoint is resolved and refused if any address it answers with is public.

  • The picker itself is one component (apps/admin/src/components/ai-provider-form.tsx), shared with the admin AI page rather than reimplemented, so the catalogue, the local-only rule and the “an empty key field keeps the stored key” rule cannot drift between the two screens.

  • The final step lists the DNS records to publish, and the list depends on the configuration that was just applied. SetupService.complete() calls requiredDnsRecords(…, dnsRecordOptions(applied.config, { serverIp6: hostIpv6(cfg) })) — so while IPv6 is on it also lists the AAAA and the IPv6 PTR — rather than passing no options, so the wizard shows the same conditional set the domain page does — the alternative is the silent-smaller-answer failure dnsRecordOptions exists to prevent, which once had the wizard configure a relay and then print an SPF record that ignored it. Always: A and PTR for the mail host (when PUBLIC_IP is known), MX, SPF for the domain, DKIM — the first domain’s key is generated during apply — DMARC, the _imaps/_submission SRV rows, and a second SPF for the mail host itself (forwarded mail leaves as SRS0=…@<hostname>). On an ACME install it also carries autoconfig and autodiscover CNAMEs — there is no webmail row, because the mail client is one host for the whole server, webmail.<the server's own domain>, published once by hand (Install step 7) — and — unless MTA-STS is none — the _mta-sts TXT, the mta-sts CNAME and the _smtp._tls TLS-RPT TXT. If a relay is configured, its SPF term is folded into both SPF rows (v=spf1 mx include:… -all for the domain, v=spf1 a include:… -all for the mail host), independently of the TLS mode; a relay that declares no term instead makes those rows carry a ⚠ warning that the record as printed hard-fails the server’s own mail. Copy buttons throughout; Sign in and verify DNS then lands on the domain page after login, where Verify now checks the records live (DNS records & live verification).

  • Once state = complete the wizard route redirects to /login; the setup endpoints answer 409 (setup is one-shot), and GET /setup answers only { "state": "complete" }. The final step keeps what it read while setup was pending, which is where its sign-in link on https://<hostname> comes from.

  • The wizard is exercised in CI, in a browser, on every run. apps/admin/e2e/setup/wizard.spec.ts walks all seven steps against the served SPA and is what performs first boot for the whole smoke job — the API smoke’s own headless setup is conditional on setup=pending and finds the work already done. It runs as its own Playwright project (pnpm e2e:setup) before the smoke, because a stack can only be set up once and whoever goes first is the only one who sees a wizard.

Configuration. None. The wizard is part of the web image and is served by Caddy; the API’s /api/v1/health tells the SPA whether setup is pending.

Limits & edge cases. The wizard’s DNS step is a checklist (there is no session yet); verification runs in the domain’s DNS & routing section, where Sign in and verify DNS lands after sign-in. GET /setup has no flag for the AI step because nothing in it is required, so a reload lands on that step rather than past it — it costs a click of Skip for now, where skipping over it would hide the only place the wizard offers AI at all. Per-domain AI policy and each mailbox’s opt-in are not offered here: they live in the admin UI and belong to people who do not exist yet at first boot. If the browser tab is closed mid-way, just reopen / — progress is on the server.