NextMyDay/internal/core/setup/render.go
Claus Lohmar 6d421b61fd feat(setup): gate first run with a printed setup code and a first-run page
The real topology is an LXC container (11020) behind Proxmox + Zoraxy, so
the SSH tunnel is impractical: it lands on the container gateway, not
loopback. D-025 is re-revised and D-044 adds the general gate.

- core/setup: a Gate (gate.go) holding the 128-bit setup code (generated at
  first run, or pinned by config `setup_code`) plus a signed unlock cookie
  (`myday_setup`) that carries a keyed one-way FINGERPRINT of the code, never
  the code itself. The code is single-use (retired on the first success) and
  the comparison ignores case/separators.
- The unlock action (`POST /setup?do=setup&action=unlock`) is CSRF-protected
  (A15), rate-limited (the /setup A3 class) and audited; a wrong or absent
  code gets the same generic 403 page.
- The door (server.Deps.SetupGate): in setup mode the wizard is served to
  EITHER the direct tunnel (gate a) OR an unlocked request (gate b); `GET /`
  and an un-unlocked `GET /setup` render a friendly first-run page with the
  code form — never a bare 404 — and every other route stays 404.
- cmd/myday prints an unmistakable startup banner: setup mode, the wizard
  URL, the code, where to read it (journalctl / pct exec 11020) and the
  restart that adopts the persisted secrets.
- httpx.Signer.Fingerprint backs the unlock cookie; config gains `setup_code`.

Tests cover the boilerplate at `/` and `/setup` (200, not 404), the unlock
(code → wizard; wrong/absent → generic, 429 under the limit), single-use, the
CSRF requirement, access-log redaction, and that a non-/setup route is still
404. Gate green (build · vet · test -race); live-smoked against the binary.
2026-10-15 06:33:13 +00:00

134 lines
5.8 KiB
Go

package setup
import (
"html"
"net/http"
"git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
)
// renderHTML writes the wizard page inside the core shell (SPEC-009 §4). The
// door renders no dynamic HTML itself (SPEC-003 §4); the wizard is the hand-off
// target for the `setup` workflow.
//
// F-2: the bootstrap renders secrets and ceremony state — never cache it.
func renderHTML(w http.ResponseWriter, status int, page web.Page) error {
w.Header().Set("Cache-Control", "no-store")
return web.Render(w, status, page)
}
// writeError writes a terse plain-text error.
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(status)
_, _ = w.Write([]byte(msg + "\n"))
}
// redirect sends the operator back to the wizard (post/redirect/get).
func redirect(w http.ResponseWriter, r *http.Request, path string) {
http.Redirect(w, r, path, http.StatusSeeOther)
}
// wizardPage renders the current step inside the shell. The wizard runs before
// any identity exists, so the page is a GUEST screen: no nav (SPEC-009 §4).
// Every form carries the signed pre-session CSRF token (A15).
func wizardPage(st step, token, domain string) web.Page {
c := html.EscapeString(token)
return web.Page{
Title: "MyDay setup",
Guest: true,
CSRF: token,
Content: web.Markup(`<h1>MyDay setup</h1>
<p>Bring this instance to life: the domain, the top admin, then mail and locale.</p>` +
stepBody(st, c, domain)),
}
}
// boilerplatePage renders the D-044 first-run page — what `GET /` and
// `GET /setup` show in setup mode when the wizard has not been unlocked. It is
// deliberately friendly: the point is that the domain must NOT look "down".
//
// It is a GUEST screen (no identity, no nav) with the setup-code entry form.
// The form submits over POST, so the code never rides the URL (and is never
// access-logged — the `code` parameter is redacted even if it ever did, A8).
func boilerplatePage(token, flash string) web.Page {
c := html.EscapeString(token)
kind := ""
if flash != "" {
kind = "error"
}
return web.Page{
Title: "MyDay is not set up yet",
Guest: true,
CSRF: token,
Flash: flash,
FlashKind: kind,
Content: web.Markup(`<h1>MyDay is not set up yet</h1>
<p>This instance is waiting for its first-run setup. Open the setup wizard with the one-time
setup code that MyDay printed to its log when it started.</p>
<form method="post" action="/setup?do=setup&amp;action=unlock">
<input type="hidden" name="csrf" value="` + c + `">
<label>Setup code <input name="code" autocomplete="off" autocapitalize="off" spellcheck="false" autofocus required></label>
<button type="submit">Unlock setup</button>
</form>
<section aria-label="where to find the code">
<h2>Where is the code?</h2>
<p>It is in MyDay's startup log:</p>
<ul>
<li>in the container: <code>journalctl -u myday</code></li>
<li>from the Proxmox host: <code>pct exec 11020 -- journalctl -u myday</code></li>
</ul>
<p>Look for the line <code>setup code</code>.</p>
</section>`),
}
}
// stepBody renders the section for one step.
func stepBody(st step, c, domain string) string {
switch st {
case stepDomain:
return `<section aria-label="domain"><h2>1. Domain</h2>` +
`<form method="post" action="/setup?do=setup&amp;action=domain.save">` +
`<input type="hidden" name="csrf" value="` + c + `">` +
`<label>Base URL <input name="base_url" type="url" value="` +
html.EscapeString(domain) + `" placeholder="https://myday.example.com" required></label>` +
`<button type="submit">Continue</button></form></section>`
case stepAdmin:
return `<section aria-label="admin"><h2>2. The top admin</h2>` +
`<form method="post" action="/setup?do=setup&amp;action=admin.create">` +
`<input type="hidden" name="csrf" value="` + c + `">` +
`<label>Display name <input name="display_name" autocomplete="name" required></label>` +
`<label>Email <input name="email" type="email" autocomplete="email" required></label>` +
`<button type="submit">Create the admin</button></form></section>`
case stepEnroll:
// Step 2 enrolls TOTP only (D-041): a passkey registered over loopback
// would bind RP ID 127.0.0.1 and be dead at the public domain. The
// admin adds a passkey at first login, via the nudge (SPEC-006 §8).
return `<section aria-label="enroll"><h2>2. Enroll the admin</h2>` +
`<p>Add an authenticator app. A passkey can be added later, at first sign-in.</p>` +
`<section aria-label="totp"><h3>Authenticator app</h3>` +
`<form method="post" action="/setup?do=setup&amp;action=admin.totp.begin">` +
`<input type="hidden" name="csrf" value="` + c + `">` +
`<button type="submit" class="secondary">Set up authenticator</button></form>` +
`<form method="post" action="/setup?do=setup&amp;action=admin.totp.verify">` +
`<input type="hidden" name="csrf" value="` + c + `">` +
`<label>Code <input name="code" inputmode="numeric" autocomplete="one-time-code"></label>` +
`<button type="submit">Verify code</button></form></section></section>`
default: // stepSettings
return `<section aria-label="settings"><h2>3. Mail · locale · timezone</h2>` +
`<form method="post" action="/setup?do=setup&amp;action=finish">` +
`<input type="hidden" name="csrf" value="` + c + `">` +
`<label>SMTP host <input name="mail.host"></label>` +
`<label>SMTP port <input name="mail.port" inputmode="numeric"></label>` +
`<label>SMTP username <input name="mail.username"></label>` +
`<label>SMTP password <input name="mail.password" type="password"></label>` +
`<label>From address <input name="mail.from" type="email"></label>` +
`<label>Locale <input name="app.locale" value="en-GB"></label>` +
`<label>Timezone <input name="app.timezone" value="Europe/London"></label>` +
`<button type="submit">Finish setup</button></form></section>`
}
}