From f58d33aee13c0523b1fcf4718f5795eb76a0d09e Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Thu, 15 Oct 2026 06:15:41 +0000 Subject: [PATCH] docs: add the Zoraxy deployment guide and systemd unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the D-043 deployment: the browser -> Zoraxy (10.1.0.10, TLS) -> app (0.0.0.0:8080, http) topology, the config.yaml, the Zoraxy ProxyHost headers (X-Forwarded-For + X-Forwarded-Proto), the firewall rule, the tunnel-only bootstrap, systemd, and the misconfig warnings. - config.example.yaml: trusted_proxies: ["10.1.0.10"] with the silent-failure and firewall caveats. - deploy/myday.service: the systemd unit (Type=simple, Restart=on-failure, User/WorkingDirectory/ExecStart, After=network-online.target). - docs/deploy-zoraxy.md: the full walkthrough. - README.md: the Deployment section points at the guide. Docs/deploy only — no code change. --- README.md | 23 +++-- config.example.yaml | 11 ++- deploy/myday.service | 41 +++++++++ docs/deploy-zoraxy.md | 206 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 deploy/myday.service create mode 100644 docs/deploy-zoraxy.md diff --git a/README.md b/README.md index 6f17547..6371e03 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,25 @@ Only **after** the top admin exists should you put MyDay behind a reverse proxy ## 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): +> **Full walkthrough:** [`docs/deploy-zoraxy.md`](docs/deploy-zoraxy.md) — topology · config · +> the Zoraxy ProxyHost · the firewall rule · the tunnel-only bootstrap · systemd · the misconfig +> warnings. This section is the summary. + +The reference deployment terminates **TLS at [Zoraxy](https://github.com/tobychui/zoraxy)** at +**`10.1.0.10`**; 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 +- **Proxy** — Zoraxy terminates TLS and forwards to `http://:8080`, setting `X-Forwarded-For` and `X-Forwarded-Proto`. +- **`trusted_proxies`** — `["10.1.0.10"]` (Zoraxy). The app **honours `X-Forwarded-Proto`** from a + trusted proxy 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. A **wrong value fails silently** — no `Secure` cookie, no HSTS. - **`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. +- **Firewall** — **`:8080` MUST be reachable only from `10.1.0.10`.** 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 @@ -112,6 +118,7 @@ working. Boot a productive instance from the **written `config.yaml`**, not from | [`PROJECT_PLAN.md`](PROJECT_PLAN.md) | the plan & decision log of record | | [`AGENT.md`](AGENT.md) | instructions for AI agents working here | | [`docs/specs/`](docs/specs/) | implementation specs | +| [`docs/deploy-zoraxy.md`](docs/deploy-zoraxy.md) | the Zoraxy deployment guide (D-043) | | [`config.example.yaml`](config.example.yaml) | configuration template | --- diff --git a/config.example.yaml b/config.example.yaml index 9872aca..a458a5e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,8 +17,15 @@ admin_recovery: false # D-033 — set true + restart to email user 1 an enrolme server: listen: "0.0.0.0:8080" # D-043 — any bind is allowed (no setup-mode refusal) base_url: "https://myday.sechpoint.app" # the PUBLIC origin: invite / recovery links - trusted_proxies: # XFF + X-Forwarded-Proto are honoured from these (A6/D-043) - - "127.0.0.1" # Zoraxy's address (127.0.0.1 if it runs on this host) + # Zoraxy's address — the ONLY peer whose forwarding headers are honoured (A6/D-043). + # Zoraxy MUST set `X-Forwarded-For` (the setup gate's "no XFF" test relies on it) + # and `X-Forwarded-Proto`. A WRONG VALUE HERE FAILS SILENTLY: `X-Forwarded-Proto` + # is then ignored, the session cookie loses `Secure` (and with it the `__Host-` + # prefix and HSTS), and the audit log records the proxy — not the user — as the + # client. FIREWALL :8080 TO 10.1.0.10 ONLY: any other host that can reach the + # port bypasses Zoraxy and its TLS, and is served plain HTTP directly. + trusted_proxies: + - "10.1.0.10" # Zoraxy (TLS terminates here) # ── app ────────────────────────────────────────────────────────────────────── app: diff --git a/deploy/myday.service b/deploy/myday.service new file mode 100644 index 0000000..04c6082 --- /dev/null +++ b/deploy/myday.service @@ -0,0 +1,41 @@ +# MyDay — systemd unit (D-043: the app runs behind Zoraxy; TLS terminates there). +# +# INSTALL (as root): +# 1. build the static, CGO-free binary: +# go build -trimpath -o /usr/local/bin/myday ./cmd/myday +# 2. create the app dir (config.yaml + data/ live there) and drop in the config: +# install -d -o -g +# install -m 0640 -o -g config.yaml /config.yaml +# 3. systemctl daemon-reload && systemctl enable --now myday +# +# Replace every below (path placeholders must resolve to ABSOLUTE +# paths). `config.yaml` is gitignored — NEVER commit it (D-010). See +# docs/deploy-zoraxy.md for the full deployment walkthrough. + +[Unit] +Description=MyDay — Sechpoint employee app +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User= +Group= +WorkingDirectory= +# binary path, e.g. /usr/local/bin/myday (no inline comments — systemd keeps them) +ExecStart= +Environment=MYDAY_CONFIG=/config.yaml +Restart=on-failure +RestartSec=5s + +# The binary is static and CGO-free; at runtime the app writes only config.yaml, +# data/ and the SQLite files inside WorkingDirectory (the wizard persists the +# generated secrets there). ReadWritePaths keeps `ProtectSystem=strict` from +# breaking those writes. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths= + +[Install] +WantedBy=multi-user.target diff --git a/docs/deploy-zoraxy.md b/docs/deploy-zoraxy.md new file mode 100644 index 0000000..d891d78 --- /dev/null +++ b/docs/deploy-zoraxy.md @@ -0,0 +1,206 @@ +# Deploying MyDay behind Zoraxy + +> **Status:** the reference deployment for **D-043**. The app is never the TLS +> endpoint; **[Zoraxy](https://github.com/tobychui/zoraxy) terminates TLS** and +> relays plain HTTP to the app. This document is operator-facing — it changes no +> code. The decision of record is `PROJECT_PLAN.md` **D-043** / **D-025 (revised)**; +> the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7**. + +--- + +## 1. Topology + +``` +browser ──https──▶ Zoraxy 10.1.0.10 ──http──▶ app 0.0.0.0:8080 + (TLS public) terminates TLS (plain HTTP, trusted proxy) + sets X-Forwarded-For + + X-Forwarded-Proto +``` + +- **Zoraxy** — `10.1.0.10`, holds the certificate for `myday.sechpoint.app`, + terminates TLS, and forwards to the app over plain HTTP. +- **App** — listens on `0.0.0.0:8080` over **HTTP** (`server.listen`). Because the + app is not the TLS endpoint it must **honour the proxy's `X-Forwarded-Proto`** to + know a request arrived over `https` (that is what turns on the `Secure` + + `__Host-` session cookie and HSTS). +- **Firewall** — `:8080` is reachable **only from `10.1.0.10`** (see §4). The port + is the app's whole attack surface: anything that can reach it bypasses both + Zoraxy **and its TLS**. + +--- + +## 2. The app config (`config.yaml`) + +Copy `config.example.yaml` → `config.yaml` (gitignored — **never commit it**, D-010) +and set the three server keys: + +```yaml +server: + listen: "0.0.0.0:8080" + base_url: "https://myday.sechpoint.app" # the PUBLIC origin (invite / recovery links) + trusted_proxies: + - "10.1.0.10" # Zoraxy +``` + +- **`listen`** — any bind is allowed (there is **no** setup-mode bind refusal, + D-025 revised). Bind `0.0.0.0:8080`; the firewall narrows who can reach it. +- **`base_url`** — the public origin. It is the host the browser actually visits, + so it must be `https://` (WebAuthn derives its Relying Party ID from it). +- **`trusted_proxies`** — the peers whose `X-Forwarded-For` / `X-Forwarded-Proto` + are honoured (A6/D-043). + +> **It must be the address Zoraxy connects *from*, not the public hostname.** +> `trusted_proxies` matches the **TCP peer** of the app's connection. With Zoraxy +> on `10.1.0.10`, that peer is `10.1.0.10`. (If Zoraxy ran on the *same* host you +> would use `127.0.0.1` instead — that is the only case where loopback is right.) + +--- + +## 3. The Zoraxy ProxyHost + +Create a **Proxy Host** (reverse proxy rule) in Zoraxy: + +| Field | Value | +|---|---| +| Domain / hostname | `myday.sechpoint.app` | +| TLS | on (Zoraxy's certificate for that host) | +| Upstream / target | `http://:8080` (plain HTTP) | +| `X-Forwarded-For` | **set / forwarded** | +| `X-Forwarded-Proto` | **set / forwarded** (`https`) | + +Zoraxy sets `X-Forwarded-For` on a normal HTTP proxy rule; confirm it **and** +`X-Forwarded-Proto` in the rule's header settings, then verify end-to-end (§7). +The app deliberately **requires** both: + +- `X-Forwarded-Proto: https` is what makes the session cookie `Secure` + (`__Host-myday_session`) and adds HSTS (AC-13). +- The **absence of `X-Forwarded-For`** is the proof the setup gate uses that a + request is the direct SSH tunnel, not a proxy relay (§6, D-025 revised). + +--- + +## 4. Firewall — lock `:8080` to Zoraxy + +Allow `:8080/tcp` **only** from `10.1.0.10`. + +**ufw:** + +```bash +ufw allow from 10.1.0.10 to any port 8080 proto tcp +# the default incoming policy (deny) drops every other source +``` + +**nftables:** + +```nft +table inet myday_guard { + chain input { + type filter hook input priority filter; policy accept; + tcp dport 8080 ip saddr != 10.1.0.10 drop + } +} +``` + +> If you run a host firewall, this rule is the load-bearing one: without it the +> app's plain-HTTP port is directly reachable and its TLS hardening (cookie +> `Secure`, HSTS, the tunnel gate) is bypassed. + +--- + +## 5. Tunnel-only bootstrap (before the first admin exists) + +The **first user becomes the top admin**, so the initial `/setup` wizard is served +**only over a direct SSH tunnel** — never through the proxy. This is enforced +**per request** (D-025 revised / SPEC-003 §7): in setup mode the app serves a +request **only** if its **TCP peer is loopback *and* it carries no +`X-Forwarded-For`** — i.e. the tunnel. Everything else is **404**. + +```bash +# from your workstation — tunnel to the app's loopback port on the server +ssh -L 8080:127.0.0.1:8080 @ +# then, in a browser on your workstation: +# http://127.0.0.1:8080/setup +``` + +- Open the app over **`localhost` / `127.0.0.1`** — a loopback origin is the only + one the browser treats as a secure context for **WebAuthn** dialogs. +- The wizard creates the top admin (**Passkey + TOTP**; the TOTP secret is sealed + with the generated `totp_encryption_key`) and writes `config.yaml` + (`setup: false`, the base URL, mail/locale, and the secrets). +- A proxy **cannot** reach a fresh instance: even a same-host proxy presents a + loopback peer but always adds `X-Forwarded-For`, which gives it away → 404. + +### The restart — adopting the persisted secrets + +The **secrets are generated at startup** while any value is still `CHANGE_ME…` +(so the wizard's ceremony is never signed with a shipped, public key). The wizard +then **persists those same values** into `config.yaml`. So once setup is complete: + +```bash +sudo systemctl restart myday +``` + +The restart **adopts the written `config.yaml`** — the generated keys are now on +disk, so the admin's TOTP secret (sealed with that key) keeps working. **Boot the +productive instance from the written `config.yaml`, never from +`config.example.yaml`** (its placeholders fail closed in productive mode by +design). After the restart, `/setup` is **404** and the app serves normally. + +--- + +## 6. systemd + +Build, install the unit, and start it. The unit is +[`deploy/myday.service`](../deploy/myday.service) — replace every ``: + +```bash +# 1. build the static, CGO-free binary +go build -trimpath -o /usr/local/bin/myday ./cmd/myday + +# 2. the app dir holds config.yaml and data/ (SQLite) +install -d -o -g /opt/myday +install -m 0640 -o -g config.yaml /opt/myday/config.yaml + +# 3. the unit +install -m 0644 deploy/myday.service /etc/systemd/system/myday.service +systemctl daemon-reload +systemctl enable --now myday +journalctl -u myday -f +``` + +The unit sets `WorkingDirectory=` to the app dir (where `config.yaml` and `data/` +live), `User=`, `ExecStart=`, and `After=network-online.target`; see the +file's header comment for the placeholders. + +--- + +## 7. Verify, and the misconfig warnings + +```bash +# from anywhere — the public origin +curl -sI https://myday.sechpoint.app/ | grep -i '^strict-transport-security' +# expect: Strict-Transport-Security: max-age=… (HSTS — proves X-Forwarded-Proto was honoured) +``` + +| Symptom | Cause | Fix | +|---|---|---| +| Session cookie has **no `Secure`**, **no `__Host-`** prefix, and **no HSTS** | `trusted_proxies` is **empty or wrong**, so `X-Forwarded-Proto` is ignored (fails *silently*) | Set `server.trusted_proxies: ["10.1.0.10"]` and restart | +| Client IP in the audit log is the **proxy**, not the user | same — `X-Forwarded-For` is not trusted | as above | +| `/` and `/home` return **404** while bootstrapping | **by design** — in setup mode only `/setup` (and static assets) is served | complete the wizard; the app serves normally once productive | +| `/setup` returns **404** even via the proxy | **by design** — the gate is tunnel-only | bootstrap over the SSH tunnel (§5), *then* expose it | + +> **The load-bearing deployment requirement:** Zoraxy **must set +> `X-Forwarded-For`**. The setup gate's "no XFF ⇒ it's the tunnel" test depends on +> it, and a missing header would re-open first-visitor-wins. `X-Forwarded-Proto` +> is what keeps the cookie `Secure` over the proxy. + +--- + +## See also + +| | | +|---|---| +| [`PROJECT_PLAN.md`](../PROJECT_PLAN.md) | D-043 · D-025 (revised) | +| [`docs/specs/spec-003-core-door.md`](specs/spec-003-core-door.md) | §7 the loopback/setup gate · §9 AC-7 / AC-10 / AC-13 | +| [`config.example.yaml`](../config.example.yaml) | the commented config template | +| [`deploy/myday.service`](../deploy/myday.service) | the systemd unit |