- PROJECT_PLAN: register D-044, re-revise D-025 to "tunnel OR setup code",
update A13, O-8, §5.1, §12 and the change log.
- SPEC-003 §4/§7/§9/§12: the gate now serves the wizard to the tunnel OR an
unlocked request; AC-7/AC-10 reworded ("refused the wizard" — the boilerplate
may still answer `/` and `/setup`), AC-14 added.
- SPEC-005 §3/§4/§6/§9: the boilerplate, the unlock action, the code rules and
the ACs; AC-7 added for single-use/rate-limit/redaction.
- deploy-zoraxy §1/§3/§5/§7 + README: the LXC/Proxmox topology, how to read the
code (journalctl · pct exec 11020), the code gate replacing the tunnel-only
bootstrap (the tunnel stays as gate a); firewall and Zoraxy sections kept.
248 lines
11 KiB
Markdown
248 lines
11 KiB
Markdown
# Deploying MyDay behind Zoraxy
|
|
|
|
> **Status:** the reference deployment for **D-043** / **D-044**. 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 (re-revised 2026-10-15b)**;
|
|
> the gate's behaviour is `docs/specs/spec-003-core-door.md` **§7** and
|
|
> `docs/specs/spec-005-setup-wizard.md` **§3/§4**.
|
|
|
|
---
|
|
|
|
## 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 │ runs inside LXC
|
|
▼ container 11020 (Proxmox)
|
|
```
|
|
|
|
- **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).
|
|
- **Container** — MyDay runs in an **LXC container (id `11020`)** under Proxmox. This is why
|
|
the first-run gate is a **setup code** and not just an SSH tunnel: SSH/`pct enter` lands on the
|
|
container **gateway**, not loopback, so a tunnel to `127.0.0.1:8080` cannot be formed (§5).
|
|
- **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).
|
|
- `X-Forwarded-For` is what keeps the setup gate's tunnel test (gate a, §5) honest:
|
|
a request with **no** `X-Forwarded-For` from a loopback peer is the direct tunnel,
|
|
not a proxy relay.
|
|
|
|
---
|
|
|
|
## 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. First-run bootstrap — the setup code (no tunnel needed)
|
|
|
|
The **first user becomes the top admin**, so the initial `/setup` wizard is gated **per request**
|
|
— **never first-visitor-wins** (D-025 · D-044). There are **two** ways in:
|
|
|
|
| Gate | Who | Needs a code? |
|
|
|---|---|---|
|
|
| **(a) the direct SSH tunnel** — a loopback TCP peer **with no `X-Forwarded-For`** | a workstation that can tunnel to the app's loopback port | no |
|
|
| **(b) the printed SETUP CODE** | anyone who can read the server log, over the public domain | yes |
|
|
|
|
**The setup code is the general gate for this topology.** MyDay generates a **128-bit** code at
|
|
first start and prints it in an unmistakable **startup banner**:
|
|
|
|
```
|
|
======================================================================
|
|
MyDay is NOT set up yet — no admin exists.
|
|
======================================================================
|
|
Open the setup wizard at:
|
|
https://myday.sechpoint.app/setup
|
|
SETUP CODE: 9f3a-1b2c-4d5e-6f70-8192-a3b4-c5d6-e7f8
|
|
Read the code again:
|
|
journalctl -u myday (in the container)
|
|
pct exec 11020 -- journalctl -u myday (from the Proxmox host)
|
|
```
|
|
|
|
**Read the code** (in the LXC container, or from Proxmox):
|
|
|
|
```bash
|
|
# inside the container
|
|
journalctl -u myday | grep -i 'setup code'
|
|
|
|
# from the Proxmox host (container 11020)
|
|
pct exec 11020 -- journalctl -u myday | grep -i 'setup code'
|
|
```
|
|
|
|
Then **open `https://myday.sechpoint.app/setup`** and enter the code. Until the code is entered:
|
|
|
|
- `GET /` and `GET /setup` show a **friendly first-run page** (with the code form) — **never a bare
|
|
404**, so the domain does not look "down";
|
|
- **every other route** answers **404**;
|
|
- the code is **single-use** and the entry form is **rate-limited** (wrong code → a generic 403).
|
|
|
|
> **No tunnel required.** The old guidance ("bootstrap over an SSH tunnel first") no longer
|
|
> applies: in LXC a tunnel to `127.0.0.1:8080` cannot be formed (it lands on the container
|
|
> gateway), which is exactly why D-044 replaced it with the code. The tunnel gate (a) still works
|
|
> and needs no code, if you have a route to the app's loopback port.
|
|
|
|
The wizard creates the top admin (**TOTP**; the passkey is added at first sign-in on the real
|
|
origin — D-041) and writes `config.yaml` (`setup: false`, the base URL, mail/locale, and the
|
|
secrets).
|
|
|
|
### 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, and the
|
|
**setup code is gone** (`GET /setup` → 404). **Boot the productive instance from the written
|
|
`config.yaml`, never from `config.example.yaml`** (its placeholders fail closed in productive mode
|
|
by design).
|
|
|
|
> **Pinning your own code.** Set `setup_code:` in `config.yaml` to override the generated one
|
|
> (then protect the file). Leave it blank to have MyDay generate and print a fresh code each boot.
|
|
|
|
---
|
|
|
|
## 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 |
|
|
| `GET /` and `GET /setup` show a **first-run page** with a code box | **by design** — MyDay is not set up yet (D-044) | read the setup code (§5) and enter it; complete the wizard |
|
|
| Any **other** route returns **404** while bootstrapping | **by design** — only `/` and `/setup` are served in setup mode (SPEC-005 §3) | complete the wizard; the app serves normally once productive |
|
|
| The setup code is **rejected** (403) | it is **single-use** (already spent) or mistyped | restart MyDay to print a fresh code, or pin `setup_code:` in `config.yaml` (§5) |
|
|
| `/setup` returns **404** after the wizard | **by design** — setup is complete | re-enter only via `setup: true` + restart |
|
|
|
|
> **The load-bearing deployment requirement:** Zoraxy **must set
|
|
> `X-Forwarded-For`**. It is what distinguishes a proxy relay from the direct
|
|
> tunnel (gate a): a proxied request with **no** `X-Forwarded-For` would look like
|
|
> the tunnel and open the wizard without a code. (The **setup code** still closes
|
|
> first-visitor-wins for anything the proxy *does* mark.)
|
|
> `X-Forwarded-Proto` is what keeps the cookie `Secure` over the proxy.
|
|
|
|
---
|
|
|
|
## See also
|
|
|
|
| | |
|
|
|---|---|
|
|
| [`PROJECT_PLAN.md`](../PROJECT_PLAN.md) | D-043 · D-025 (re-revised) · D-044 |
|
|
| [`docs/specs/spec-003-core-door.md`](specs/spec-003-core-door.md) | §7 the setup gate · §9 AC-7 / AC-10 / AC-13 / AC-14 |
|
|
| [`docs/specs/spec-005-setup-wizard.md`](specs/spec-005-setup-wizard.md) | §3/§4 the bootstrap + the setup code |
|
|
| [`config.example.yaml`](../config.example.yaml) | the commented config template |
|
|
| [`deploy/myday.service`](../deploy/myday.service) | the systemd unit |
|