docs: document the Zoraxy deployment (README + config example)

- config.example.yaml: listen 0.0.0.0:8080, base_url https://myday.sechpoint.app,
  trusted_proxies = Zoraxy; a comment stating :8080 must be firewalled to Zoraxy
  only and that the bootstrap is tunnel-only (D-025/D-043).
- README: a Deployment section (TLS at the proxy, the app on 0.0.0.0:8080, the
  firewall rule) and the tunnel-only bootstrap (ssh -L 8080:127.0.0.1:8080 →
  http://127.0.0.1:8080/setup, the one path a proxy request cannot take).
This commit is contained in:
Claus Lohmar 2026-10-15 05:40:38 +00:00
parent f0927fb0f0
commit 9a9888fa7b
2 changed files with 50 additions and 17 deletions

View file

@ -41,29 +41,56 @@ go build -o myday ./cmd/myday
## ⚠️ First run — do NOT expose it publicly ## ⚠️ First run — do NOT expose it publicly
The **first user becomes the top admin** (the root of the chain). So the instance bootstraps The **first user becomes the top admin** (the root of the chain). So the instance bootstraps
**loopback-only**, running a **`/setup` wizard** until that admin exists. through a **`/setup` wizard** that is reachable **only over a direct SSH tunnel**, until that admin
exists.
```bash ```bash
# on the server it listens on 127.0.0.1:8080 only (config.example.yaml) # from your workstation — tunnel to the app's port on the server
ssh -L 8080:127.0.0.1:8080 user@server # from your workstation ssh -L 8080:127.0.0.1:8080 user@server
# then open http://127.0.0.1:8080/setup and complete the wizard # then open http://127.0.0.1:8080/setup and complete the wizard
``` ```
> **Loopback is enforced per request, not just at bind.** While in setup mode the app answers **404** > **The bootstrap is tunnel-only, enforced per request** (D-025 revised). While in setup mode the app
> to any request whose client is not loopback — so a reverse proxy must **not** sit in front of a > answers **404** to any request that is not the direct tunnel — i.e. a request whose **TCP peer is
> fresh instance, even one bound to `127.0.0.1`. Bootstrap over the SSH tunnel (above) first. > not loopback**, **or which carries an `X-Forwarded-For`** header. So a reverse proxy (even one on
> the same host) **cannot** reach a fresh instance: a same-host proxy presents a loopback peer but
> always adds `X-Forwarded-For`, which gives it away. Bootstrap over the SSH tunnel (above) **first**
> — then expose the instance behind the proxy.
> **Reach the app over `localhost` / `127.0.0.1` — not a hostname.** With an `https://` `base_url` > **Reach the app over `localhost` / `127.0.0.1` — not a hostname.** Only a **loopback** origin
> the CSRF and session cookies are marked **`Secure`**, so they are **not** sent over plain > counts as a trustworthy/secure context for the browser's **WebAuthn** dialogs. So for first run and
> `http://`; and only a **loopback** origin counts as a trustworthy/secure context for the > any human testing, open the app at **`http://localhost:8080`** or **`http://127.0.0.1:8080`** (via
> browser's **WebAuthn** dialogs. So for first run and any human testing, open the app at > the tunnel above) — never at a bare server hostname. Over plain http the cookies are sent without
> **`http://localhost:8080`** or **`http://127.0.0.1:8080`** (via the tunnel above) — never at a > `Secure` (needed by the bootstrap); behind the proxy (https) they are hardened — see *Deployment*.
> bare server hostname.
Only **after** the top admin exists should you put MyDay behind a reverse proxy and expose it. Only **after** the top admin exists should you put MyDay behind a reverse proxy and expose it.
--- ---
## Deployment — behind Zoraxy (TLS at the proxy)
The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychui/zoraxy)**; the app
itself speaks **plain HTTP**. Config (`config.example.yaml` → `config.yaml`, D-043):
- **App** — `server.listen: "0.0.0.0:8080"`; the app binds any address (there is **no** setup-mode
bind refusal, D-025 revised).
- **Proxy** — Zoraxy holds the certificate and forwards to `127.0.0.1:8080`, setting
`X-Forwarded-For` and `X-Forwarded-Proto`.
- **`base_url`** — `https://myday.sechpoint.app`: the public origin, used in invite/recovery links.
The app **honours `X-Forwarded-Proto`** from a trusted proxy (`server.trusted_proxies` = Zoraxy) to
decide a request is `https`, and then sets the session cookie **`Secure` + `__Host-`** and sends
**HSTS**. Over plain http (the tunnel) it keeps the bare, non-`Secure` cookie so the bootstrap works.
- **Firewall** — **`:8080` MUST be reachable only from Zoraxy.** Anything else that can reach the
port bypasses the proxy **and its TLS**; the app would then serve plain HTTP to that client.
```bash
# the tunnel-only bootstrap — the one path a proxy request cannot take
ssh -L 8080:127.0.0.1:8080 user@server
# open http://127.0.0.1:8080/setup (before the first admin exists)
```
---
## What `/setup` writes — and the restart ## What `/setup` writes — and the restart
The wizard **persists `config.yaml`** (`setup: false`, the base URL, mail/locale, and the secrets) The wizard **persists `config.yaml`** (`setup: false`, the base URL, mail/locale, and the secrets)

View file

@ -4,15 +4,21 @@
# Settings live HERE, not in the database (D-035). # Settings live HERE, not in the database (D-035).
# ── setup & recovery ───────────────────────────────────────────────────────── # ── setup & recovery ─────────────────────────────────────────────────────────
setup: true # D-015 / D-026 — while true the app serves ONLY /setup (loopback-only) setup: true # D-015 / D-026 — while true the app serves ONLY /setup.
# D-025 (revised): the wizard is reachable ONLY over the
# direct SSH tunnel — a request relayed by a proxy (a
# loopback peer carrying X-Forwarded-For) is 404'd.
admin_recovery: false # D-033 — set true + restart to email user 1 an enrolment link admin_recovery: false # D-033 — set true + restart to email user 1 an enrolment link
# ── server ─────────────────────────────────────────────────────────────────── # ── server ───────────────────────────────────────────────────────────────────
# D-043 (deployment behind Zoraxy): TLS terminates at the PROXY, so the app
# speaks plain HTTP and binds 0.0.0.0:8080. FIREWALL :8080 TO ZORAXY ONLY —
# anything else that can reach the port bypasses the proxy and its TLS.
server: server:
listen: "127.0.0.1:8080" # D-025 — loopback during setup; bind the public iface only after listen: "0.0.0.0:8080" # D-043 — any bind is allowed (no setup-mode refusal)
base_url: "https://myday.example.com" # used in invite / recovery links base_url: "https://myday.sechpoint.app" # the PUBLIC origin: invite / recovery links
trusted_proxies: # XFF is resolved from these (A6) trusted_proxies: # XFF + X-Forwarded-Proto are honoured from these (A6/D-043)
- "127.0.0.1" - "127.0.0.1" # Zoraxy's address (127.0.0.1 if it runs on this host)
# ── app ────────────────────────────────────────────────────────────────────── # ── app ──────────────────────────────────────────────────────────────────────
app: app: