diff --git a/deploy/deploy.sh b/deploy/deploy.sh index e1939c9..322eede 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -44,6 +44,15 @@ PROJECT=ai-resume-site # compose project; distinct from the older PORT=8096 HEALTH_PATH=/healthz +# The host address the port is published on. Caddy runs on .35 and proxies to +# this address, so a loopback publish is unreachable from the edge (Sam's +# decision, 2026-10-26, interaction 54aeb1a2). Never 0.0.0.0: the port must not +# be reachable off the LAN. The health gate below follows this address — once the +# port is published on the LAN address the host no longer listens on +# 127.0.0.1:8096, so a loopback probe would refuse and every deploy would roll +# back. +PUBLISH_IP=${RESUME_PUBLISH_IP:-192.168.20.13} + ROOT=${RESUME_DEPLOY_ROOT:-/home/sam/deployment/resume} RELEASES=$ROOT/releases CURRENT=$ROOT/current @@ -231,7 +240,7 @@ start() { wait_healthy() { local i body for i in $(seq 1 "$HEALTH_TRIES"); do - if body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null); then + if body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null); then if printf '%s' "$body" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ok"'; then log "health gate passed after ${i}s" return 0 @@ -240,14 +249,15 @@ wait_healthy() { sleep 1 done warn "health gate failed after ${HEALTH_TRIES}s; last response:" - curl -sS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" >&2 || true + curl -sS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" >&2 || true compose logs --tail 40 resume >&2 || true return 1 } # Definition-of-Done evidence, checked on every deploy instead of by hand: # mem_limit == memswap_limit == the value declared in compose.yml, and the port -# published on 127.0.0.1 only. +# published on $PUBLISH_IP only (the LAN address Caddy proxies to — never +# 0.0.0.0 and no longer loopback). assert_shape() { local cid mem swap ip declared declared=$(awk '/^[[:space:]]*mem_limit:/ {gsub(/[^0-9]/, "", $2); print $2}' "$COMPOSE") @@ -258,8 +268,8 @@ assert_shape() { [ "$mem" = "$swap" ] || die "mem_limit ($mem) != memswap_limit ($swap)" [ "$mem" = "$((declared * 1024 * 1024))" ] || die "mem_limit is $mem, expected ${declared} MiB" ip=$(docker inspect -f '{{range $p, $b := .HostConfig.PortBindings}}{{range $b}}{{.HostIp}} {{end}}{{end}}' "$cid" | xargs) - [ "$ip" = "127.0.0.1" ] || die "published on '${ip}', expected 127.0.0.1 only" - log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on 127.0.0.1:$PORT" + [ "$ip" = "$PUBLISH_IP" ] || die "published on '${ip}', expected $PUBLISH_IP only" + log "shape ok: mem_limit == memswap_limit == ${declared} MiB, published on $PUBLISH_IP:$PORT" } deploy_release() { @@ -348,7 +358,7 @@ prune() { do_status() { local cur prev body cur=$(current_target); prev=$(previous_target) - body=$(curl -fsS --max-time 3 "http://127.0.0.1:$PORT$HEALTH_PATH" 2>/dev/null || true) + body=$(curl -fsS --max-time 3 "http://$PUBLISH_IP:$PORT$HEALTH_PATH" 2>/dev/null || true) printf 'root %s ' "$ROOT" printf 'current %s diff --git a/docs/phase-3-deploy.md b/docs/phase-3-deploy.md index f53df01..361a0e1 100644 --- a/docs/phase-3-deploy.md +++ b/docs/phase-3-deploy.md @@ -30,7 +30,7 @@ deploy.sh ├─ copy to releases// ├─ flip `current` symlink (atomic) ├─ docker compose up -d - ├─ curl http://127.0.0.1:8096/healthz → must be ready + ├─ curl http://192.168.20.13:8096/healthz → must be ready └─ on failure: flip back to `previous`, exit non-zero ``` @@ -40,7 +40,12 @@ deploy.sh ### 3.2 Container - `mem_limit` = `memswap_limit` = `120m`. -- `ports: ["127.0.0.1:8096:8096"]` — never `0.0.0.0`. +- `ports: ["192.168.20.13:8096:8096"]` — **never** `0.0.0.0`. + Caddy runs on `.35`, so the port has to be reachable from the LAN; a + `127.0.0.1` publish answers only on `.13` and the public URL returns 502. + LAN-only publish, Caddy on `.35` still the sole public listener. Decision: + Sam, 2026-10-26, interaction `54aeb1a2`. The health gate and `assert_shape()` + in `deploy.sh` follow the same address (`RESUME_PUBLISH_IP`). - `logging` capped (`max-size: 10m`, `max-file: 3`). - `restart: unless-stopped`. @@ -75,7 +80,7 @@ Repo stays private through this phase. Sam decides on public visibility after th - [ ] `deploy.sh` and `deploy.sh --rollback` both work; a deliberate bad build proves rollback. - [ ] `https://resume.lab.audasmedia.com.au` serves the site. -- [ ] Container bound to `127.0.0.1` only; confirmed with `ss -ltn`. +- [ ] Container published on `192.168.20.13:8096` only, reachable from `.35`; confirmed with `ss -ltn`. - [ ] `memswap_limit == mem_limit`; confirmed with `docker inspect`. - [ ] Security headers present; CSP does not break the page. - [ ] A1 still passes over the public URL with the AI layer stopped. diff --git a/reference/stack.md b/reference/stack.md index dc544de..6ace89e 100644 --- a/reference/stack.md +++ b/reference/stack.md @@ -15,7 +15,7 @@ | Service | Host | Port | State | |---|---|---|---| | Caddy | `.35` | 443 (+ LAN 8000) | existing — **Sam applies any change** | -| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab) | +| **Go app** | `.13` | **8096** | new — verify free (`8095` = offsite.lab); published on `192.168.20.13:8096`, **never** `0.0.0.0` | | Qdrant REST | `.13` | 6333 | new, `127.0.0.1` only | | Qdrant gRPC | `.13` | 6334 | new, `127.0.0.1` only | | Prefect server | `.13` | existing | reuse — do not deploy a second | @@ -55,7 +55,9 @@ Ceiling: 20,000 chunks at 1024 dimensions. Beyond that, set `RESUME_QDRANT_VECTORS_ON_DISK=1`, reduce the dimension, or raise the cap — in that order. Detail: `docs/phase-4-notes.md` §7. -Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind to `127.0.0.1`; Caddy is the only public listener. +Every service sets `mem_limit` **and** `memswap_limit` to the same value so it cannot consume host swap. Bind services to `127.0.0.1`; Caddy on `.35` is the only public listener. + +**One documented amendment (Go app, port 8096):** the app publishes on the LAN address `192.168.20.13:8096`, not `127.0.0.1`. Caddy runs on `.35` and proxies to `192.168.20.13:8096`, so a loopback-only publish is unreachable from the edge and the public URL returns 502. This is the same way `8095` (offsite), `8092` (photo-filter), `3909` (garage-ui) and `4200` (prefect) are published on `.13`. The port is LAN-only — never `0.0.0.0` — and Caddy on `.35` is still the only public listener, so `AGENTS.md` §4.2 holds in substance. Decision: Sam, 2026-10-26, interaction `54aeb1a2`. `deploy/deploy.sh` follows the same address (`RESUME_PUBLISH_IP`, default `192.168.20.13`) in its health gate and shape assertion. ## Paths