diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..2e7bc54 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,255 @@ +# Deployment + +Self-hosted on a single Linux box (Proxmox LXC / VM / bare metal). Four layers, innermost to outermost: + +1. **Node app** (`adapter-node`) — listens on `127.0.0.1:3000` +2. **Caddy** (internal) — listens on LAN IP, Yggdrasil IPv6, NetBird IP, and `127.0.0.1` (for Tor). HTTP only — no TLS. +3. **Tor / Yggdrasil / NetBird** — overlay transports that connect peers to Caddy +4. **External reverse proxy** (off-box, e.g. at the network edge) — does TLS termination for the public hostname; forwards to LAN IP:8080 + +``` + ┌─ Internet ──→ TLS proxy ──┐ + │ │ + Tor peer ───┼─ onion ──→ tor daemon ──┤ + │ ├──→ Caddy:8080 ──→ Node:3000 +Yggdrasil peer ─┼─ ygg ──→ ygg IPv6 ──┤ + │ │ + NetBird peer ──┼─ WG ──→ netbird IP ──┘ +``` + +All non-TLS: the external proxy terminates TLS, Tor encrypts its own path, Yggdrasil/NetBird are encrypted overlays. Caddy and the node app speak plain HTTP. + +--- + +## 1. Node app + +`systemd` unit at `/etc/systemd/system/buildfor-life-budget.service`: + +```ini +[Unit] +Description=B4L Budget (SvelteKit adapter-node) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +User=budget +WorkingDirectory=/opt/buildfor-life-budget +EnvironmentFile=/opt/buildfor-life-budget/.env +ExecStart=/usr/bin/node build/index.js +Restart=on-failure +RestartSec=5 + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/buildfor-life-budget/uploads +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +`/opt/buildfor-life-budget/.env` must contain: + +``` +PORT=3000 +HOST=127.0.0.1 +ORIGIN=https://budget.example.com +PROTOCOL_HEADER=x-forwarded-proto +HOST_HEADER=x-forwarded-host +BODY_SIZE_LIMIT=26214400 +DATABASE_URL=postgresql://budget_app:...@127.0.0.1:5432/buildfor_life_budget +UPLOADS_DIR=/var/lib/buildfor-life-budget/uploads +FAVICON_FETCH_ENABLED=true +``` + +`ORIGIN` stays the public HTTPS URL — SvelteKit uses this for CSRF and cookie checks. If you hit the app via Tor/Yggdrasil/NetBird with a different Host header, either: +- set `csrf.checkOrigin: false` in `svelte.config.js`, or +- access each transport via a Host that matches `ORIGIN` (point DNS / hosts-file entries at the transport IPs). + +The second approach is cleaner — all transports see the same `Host: budget.example.com` and SvelteKit doesn't care which IP the connection came from. + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now buildfor-life-budget.service +``` + +--- + +## 2. Internal Caddy + +Package: `sudo apt install caddy` (or use the official repo for latest). + +`/etc/caddy/Caddyfile`: + +```caddyfile +{ + # Off — TLS is handled by the upstream external proxy. + auto_https off + admin off +} + +# Listen on every transport-facing IP. Same backend. +# LAN IP · Yggdrasil IPv6 · NetBird IP · localhost (for Tor) +http://192.168.10.5:8080, +http://[200:abcd:1234:...]:8080, +http://100.64.0.5:8080, +http://127.0.0.1:8080 { + + # Forward real client info so SvelteKit's PROTOCOL_HEADER/HOST_HEADER work. + # The external TLS proxy should set X-Forwarded-Proto=https; default to http for + # the overlays (Tor/Ygg/NetBird) which do their own encryption. + @fromTls header X-Forwarded-Proto https + header @fromTls X-Forwarded-Proto https + + reverse_proxy 127.0.0.1:3000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + header_up X-Forwarded-Host {host} + } + + encode zstd gzip + request_body { + max_size 26MB + } + + log { + output file /var/log/caddy/budget.log { + roll_size 50mb + roll_keep 10 + } + format console + } +} +``` + +Replace the three IPs with the real values: +- **LAN IP** — `ip -4 -br addr show | grep -v lo` on the host. +- **Yggdrasil IPv6** — `yggdrasilctl getSelf | grep -i address` (starts with `200:` or `201:`). +- **NetBird IP** — `netbird status | grep 'NetBird IP'` (usually `100.64.0.0/10` range). + +```bash +sudo systemctl enable --now caddy +sudo systemctl reload caddy # after edits +``` + +**Firewall** — the box should accept `:8080` inbound only on those four interfaces. With `ufw`: + +```bash +sudo ufw allow in on eth0 from 192.168.10.0/24 to any port 8080 proto tcp # LAN +sudo ufw allow in on tun-ygg to any port 8080 proto tcp # Yggdrasil +sudo ufw allow in on wt0 to any port 8080 proto tcp # NetBird (interface name varies) +# Tor connects via 127.0.0.1 — no firewall rule needed +sudo ufw deny 8080/tcp # block the rest +``` + +--- + +## 3. Tor onion service + +Package: `sudo apt install tor`. Append to `/etc/tor/torrc`: + +``` +HiddenServiceDir /var/lib/tor/budget/ +HiddenServicePort 80 127.0.0.1:8080 +HiddenServiceVersion 3 +``` + +```bash +sudo systemctl restart tor +sudo cat /var/lib/tor/budget/hostname +``` + +The printed `.onion` is the address. Protect the contents of `/var/lib/tor/budget/` — anyone with the private key can impersonate this service. Back them up offline. + +--- + +## 4. Yggdrasil + +Package: official `.deb` from [yggdrasil-network.github.io](https://yggdrasil-network.github.io/installation-linux.html). + +After install, Yggdrasil picks a stable IPv6 address from the `200::/7` range. To see it: + +```bash +sudo yggdrasilctl getSelf +``` + +No Yggdrasil-side config needed for this app — peers who have your Yggdrasil address can reach `http://[200:...]:8080` once they're on the network. + +Add trusted peers in `/etc/yggdrasil.conf`'s `Peers` block, or rely on public peers listed at [publicpeers.neilalexander.dev](https://publicpeers.neilalexander.dev/). + +--- + +## 5. NetBird + +Package: official from `pkgs.netbird.io`. Set up: + +```bash +sudo netbird up --setup-key +sudo netbird status +``` + +Note the IP under `NetBird IP` — that's what Caddy binds to. NetBird manages WireGuard tunnels; any peer in the same NetBird network can hit that IP. + +Keep the daemon running via the included `netbird.service`. + +--- + +## 6. External reverse proxy (TLS termination) + +This sits on your network edge, not on the app box. Example Caddyfile on the edge: + +```caddyfile +budget.example.com { + reverse_proxy 192.168.10.5:8080 { + header_up X-Forwarded-Proto https + header_up X-Forwarded-Host {host} + header_up Host {host} + } +} +``` + +Caddy auto-provisions a Let's Encrypt cert. If you use nginx/Traefik instead, forward the same headers so the internal Caddy and the SvelteKit app see the correct `https` protocol and public host. + +--- + +## Update / redeploy + +On the app box: + +```bash +cd /opt/buildfor-life-budget +sudo -u budget git pull +sudo -u budget npm ci --omit=dev=false +sudo -u budget npm run build +sudo -u budget npm run db:push # only if schema changed +sudo systemctl restart buildfor-life-budget +``` + +Caddy config does not need reloading unless IPs change. + +--- + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| External URL 502 from the edge proxy | Internal Caddy not up, or LAN firewall blocking `:8080` from the edge. | +| `.onion` loads but forms return 403 | CSRF origin mismatch — either configure a hosts-file entry for the onion pointing to `budget.example.com`, or disable `csrf.checkOrigin` in `svelte.config.js`. | +| Yggdrasil peer can't reach the address | Both sides must be on Yggdrasil; check `yggdrasilctl getPeers` on both ends. | +| NetBird IP changes after reconnect | Setup key vs reusable key — use a fixed/dedicated peer config so the IP stays stable. | +| SvelteKit sees HTTP for TLS-terminated traffic | External proxy not sending `X-Forwarded-Proto: https` header. | +| Scheduler doesn't tick | Systemd unit restart killed the in-process interval; check logs for `[scheduler] recurring bills started`. | + +--- + +## Security notes + +- The internal Caddy MUST NOT be reachable from the internet directly. The four-IP bind list keeps it on private transports only; verify with `ss -tlnp | grep :8080` that no `0.0.0.0:8080` shows up. +- `.onion` key files and NetBird setup keys are secrets — back them up to an offline medium, not to the repo. +- PostgreSQL should bind to `127.0.0.1` only (`listen_addresses = 'localhost'` in `postgresql.conf`). +- File uploads under `UPLOADS_DIR` are served via auth-checked endpoints only — never expose that directory statically.