From fed307e75cfda1d3d8946d530ca4b883a92a9296 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Sun, 26 Jul 2026 00:45:58 -0700 Subject: [PATCH] dev: serve at dev.thermograph.org behind basic auth, bound to loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev was mesh-only: bound to 10.10.0.2, no DNS record, no Caddy site. A public record now exists for it, so it gets a real front door instead of a name that resolves to a timeout. Three things keep that acceptable on a box that also runs Forgejo and its CI runner, and all three are load-bearing together: - the stack binds LOOPBACK, so the site block is the only route in — not even the mesh reaches the app directly, which is what makes the guard total rather than decorative, - HTTP basic auth, so a stumbled-upon URL is not a running build, - X-Robots-Tag: noindex, so dev never competes with thermograph.org for the same content in a search index. The credential in Caddyfile.vps1 is a bcrypt hash, not a password. --- infra/deploy/Caddyfile.vps1 | 54 ++++++++++++++++++++--- infra/deploy/RUNBOOK-vps1-vps2-cutover.md | 11 ++--- infra/deploy/env-topology.sh | 16 ++++--- infra/deploy/provision-dev.sh | 15 ++++--- infra/docker-compose.dev.yml | 12 ++--- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/infra/deploy/Caddyfile.vps1 b/infra/deploy/Caddyfile.vps1 index 113e5db..77bbbac 100644 --- a/infra/deploy/Caddyfile.vps1 +++ b/infra/deploy/Caddyfile.vps1 @@ -5,13 +5,7 @@ # git.thermograph.org -> Forgejo (see deploy/forgejo/caddy-git.conf) # dashboard.thermograph.org -> Grafana (see observability/caddy-grafana.conf) # -# and hosts the **dev** environment, which is deliberately ABSENT from this -# file. Dev has no site block, no DNS record and no certificate: it publishes on -# the WireGuard address only (http://10.10.0.2:8137) and is reachable from the -# mesh and nowhere else. Dev runs whatever branch is in flight, on the same box -# as Forgejo and its CI — putting it behind a public hostname would be handing -# the internet an unreviewed build. If you ever need it reachable from a phone, -# join the phone to the mesh; do not add a site block here. +# and hosts the **dev** environment at dev.thermograph.org (below). # # beta.thermograph.org is NOT here either — beta moved to vps2, next to prod. # That is the one edit in this file most likely to be made by mistake: a "beta" @@ -54,6 +48,52 @@ http://75.119.132.91 { redir https://emigriffith.dev{uri} permanent } +# Dev. Publicly resolvable, deliberately NOT publicly usable. +# +# Dev runs whatever branch is in flight — including unreviewed ones — on the +# same box as Forgejo and its CI runner. Three things make that acceptable, and +# all three are load-bearing: +# +# 1. The stack binds LOOPBACK (deploy/env-topology.sh sets DEV_BIND_ADDR to +# 127.0.0.1), so this site block is the only route in. Not even the mesh +# reaches the app directly. +# 2. basic auth, so a stumbled-upon URL is not a running build. +# 3. X-Robots-Tag: noindex, so dev never competes with thermograph.org in a +# search index the way a second copy of the same content otherwise would. +# +# Only :8137 is proxied. The dev overlay does not publish the frontend at all +# (docker-compose.dev.yml `ports: !reset null`) — the backend reverse-proxies to +# it internally, so one upstream serves the whole site here, unlike vps2 where +# Caddy path-splits across two ports. +# +# The credential below is a bcrypt hash, not a password. Rotate with: +# caddy hash-password --plaintext '' +dev.thermograph.org { + encode zstd gzip + + header { + X-Robots-Tag "noindex, nofollow" + } + + basic_auth { + dev $2a$14$LU5sNyxbop3HOsjhXB6ZrOQiveqhbcYVE6.Wi0bydAv4QhNpj4HMC + } + + reverse_proxy 127.0.0.1:8137 { + health_uri /healthz + health_interval 15s + health_timeout 3s + health_status 2xx + } + + log { + output file /var/log/caddy/dev.log { + roll_size 20MiB + roll_keep 5 + } + } +} + # Optional: redirect www -> apex. Add the www CNAME/A record first, then # uncomment. # www.emigriffith.dev { diff --git a/infra/deploy/RUNBOOK-vps1-vps2-cutover.md b/infra/deploy/RUNBOOK-vps1-vps2-cutover.md index fdb379a..408464b 100644 --- a/infra/deploy/RUNBOOK-vps1-vps2-cutover.md +++ b/infra/deploy/RUNBOOK-vps1-vps2-cutover.md @@ -317,13 +317,14 @@ SERVICE=all BACKEND_IMAGE_TAG= FRONTEND_IMAGE_TAG= \ infra/deploy/deploy-dev.sh ``` -**Verify it is mesh-only.** This is the security-relevant check of the whole -cutover — vps1 is a public box and dev runs unreviewed branches: +**Verify Caddy is the only way in.** This is the security-relevant check of the +whole cutover — vps1 is a public box and dev runs unreviewed branches: ``` -ss -ltnp | grep 8137 # MUST show 10.10.0.2:8137, never 0.0.0.0:8137 -curl -fsS -o /dev/null -w '%{http_code}\n' http://10.10.0.2:8137/healthz # from the mesh -curl --max-time 5 http://75.119.132.91:8137/healthz # MUST fail/refuse +ss -ltnp | grep 8137 # MUST show 127.0.0.1:8137, never 0.0.0.0 +curl --max-time 5 http://75.119.132.91:8137/healthz # MUST fail/refuse +curl -o /dev/null -w '%{http_code}\n' https://dev.thermograph.org/ # 401 without creds +curl -o /dev/null -w '%{http_code}\n' -u dev: https://dev.thermograph.org/healthz # 200 ``` Also confirm dev did **not** get the fleet's shared production credentials — diff --git a/infra/deploy/env-topology.sh b/infra/deploy/env-topology.sh index 77b6c82..7e4ae38 100644 --- a/infra/deploy/env-topology.sh +++ b/infra/deploy/env-topology.sh @@ -176,12 +176,16 @@ thermograph_topology() { # instance lives on vps2 and holds real user data. TG_DB_NAME=thermograph TG_DB_USER=thermograph - # Mesh-only. vps1 is a public VPS, so the dev overlay must not publish on - # 0.0.0.0 the way it did on the operator's LAN box — dev runs whatever - # branch is in flight, including unreviewed ones. Reachable at - # http://10.10.0.2:8137 from anything on the WireGuard mesh, and from - # nowhere else. There is deliberately no dev DNS record and no Caddy site. - TG_BIND_ADDR=10.10.0.2 + # LOOPBACK ONLY. vps1 is a public VPS and dev runs whatever branch is in + # flight, including unreviewed ones, so the stack must never publish on + # 0.0.0.0 the way it did on the operator's LAN box. + # + # It was briefly mesh-only (10.10.0.2). It is now fronted by Caddy at + # dev.thermograph.org — TLS, HTTP basic auth, and X-Robots-Tag: noindex + # (see deploy/Caddyfile.vps1) — so the only reachable path is through that + # site block, and binding loopback is what guarantees it: nothing can + # reach the app without passing the password, not even from the mesh. + TG_BIND_ADDR=127.0.0.1 # Dev NEVER layers common.yaml — that file is the internet-facing fleet's # shared production credential set (both S3 keypairs, the VAPID signing # key, REGISTRY_TOKEN, the metrics token), and dev runs whatever branch is diff --git a/infra/deploy/provision-dev.sh b/infra/deploy/provision-dev.sh index 2ef56a8..1a2c776 100755 --- a/infra/deploy/provision-dev.sh +++ b/infra/deploy/provision-dev.sh @@ -13,9 +13,10 @@ # - It renders dev.yaml ALONE, never layering common.yaml (the fleet's shared # production credentials). vps1 also runs Forgejo and its CI, and dev runs # whatever branch is in flight — see the long note in render-secrets.sh. -# - It is MESH-ONLY: published on 10.10.0.2:8137 (wg0), never 0.0.0.0. No DNS -# record, no Caddy site, no TLS. Anything that can reach it is already on -# the WireGuard mesh. +# - It binds LOOPBACK, never 0.0.0.0. Caddy fronts it at dev.thermograph.org +# with TLS, basic auth and X-Robots-Tag: noindex (deploy/Caddyfile.vps1). +# Binding loopback is what makes that guard total rather than decorative: +# the site block is the only route in. # # Idempotent; re-run it after changing the branch or repointing the remote. set -euo pipefail @@ -70,10 +71,10 @@ Deploy it: SERVICE=all BACKEND_IMAGE_TAG=sha- FRONTEND_IMAGE_TAG=sha- \\ $APP_DIR/infra/deploy/deploy-dev.sh -Reach it (mesh only): - http://${TG_BIND_ADDR}:8137 +Reach it: + https://dev.thermograph.org (basic auth; see deploy/Caddyfile.vps1) -Confirm it is NOT publicly exposed — this must show ${TG_BIND_ADDR}:8137 and -never 0.0.0.0:8137: +Confirm the app itself is NOT directly exposed — this must show +${TG_BIND_ADDR}:8137 and never 0.0.0.0:8137, so Caddy is the only way in: ss -ltnp | grep 8137 EOF diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index cfebe28..1f04222 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -22,12 +22,12 @@ # unreviewed branch is in flight to the entire internet — with no Caddy, no # TLS and no auth in front of it. # -# So the default is 127.0.0.1 (safe anywhere, including a laptop running -# `make dev-up`), and deploy.sh sets DEV_BIND_ADDR to the environment's mesh -# address from deploy/env-topology.sh — 10.10.0.2 on vps1. Dev is then -# reachable at http://10.10.0.2:8137 from anything on the WireGuard mesh and -# from nowhere else. There is deliberately no dev DNS record and no Caddy -# site block for it. +# So the default is 127.0.0.1 — safe anywhere, including a laptop running +# `make dev-up` — and on vps1 deploy/env-topology.sh keeps it there. Caddy +# fronts the stack at dev.thermograph.org with TLS, HTTP basic auth and +# X-Robots-Tag: noindex (deploy/Caddyfile.vps1). Loopback is what makes that +# guard total: the site block is the only route in, so nothing reaches the +# app without the password — not even from the WireGuard mesh. # 2. frontend's port publish is dropped entirely -- dev has no Caddy to reach it # directly, so it stays compose-internal-only, reached solely through # backend's own reverse-proxy fallback (THERMOGRAPH_FRONTEND_BASE_INTERNAL,