docs: add the Zoraxy deployment guide and systemd unit
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.
This commit is contained in:
parent
9a9888fa7b
commit
f58d33aee1
4 changed files with 271 additions and 10 deletions
23
README.md
23
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://<app-host>: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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
41
deploy/myday.service
Normal file
41
deploy/myday.service
Normal file
|
|
@ -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 <myday-user> -g <myday-group> <app-dir>
|
||||
# install -m 0640 -o <myday-user> -g <myday-group> config.yaml <app-dir>/config.yaml
|
||||
# 3. systemctl daemon-reload && systemctl enable --now myday
|
||||
#
|
||||
# Replace every <PLACEHOLDER> 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=<myday-user>
|
||||
Group=<myday-group>
|
||||
WorkingDirectory=<app-dir>
|
||||
# binary path, e.g. /usr/local/bin/myday (no inline comments — systemd keeps them)
|
||||
ExecStart=<binary>
|
||||
Environment=MYDAY_CONFIG=<app-dir>/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=<app-dir>
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
206
docs/deploy-zoraxy.md
Normal file
206
docs/deploy-zoraxy.md
Normal file
|
|
@ -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://<app-host>: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 <user>@<app-host>
|
||||
# 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 `<PLACEHOLDER>`:
|
||||
|
||||
```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 <myday-user> -g <myday-group> /opt/myday
|
||||
install -m 0640 -o <myday-user> -g <myday-group> 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=<binary>`, 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 |
|
||||
Loading…
Reference in a new issue