diff --git a/serverAppConfigs-docs/Dankclaw/Hermes-WebUI.md b/serverAppConfigs-docs/Dankclaw/Hermes-WebUI.md index d2c7661..46bfb01 100644 --- a/serverAppConfigs-docs/Dankclaw/Hermes-WebUI.md +++ b/serverAppConfigs-docs/Dankclaw/Hermes-WebUI.md @@ -116,13 +116,23 @@ Before first deploy, set a strong `HERMES_WEBUI_PASSWORD` in the live - The `/webui` mount is **rw on purpose**: `bootstrap.py` creates `.venv` in the repo root. Do not flip it to `:ro`. -- The webui binds `0.0.0.0:8787` today, so the SSH tunnel is an access path for - the phone, not a security boundary: any LAN host that can route to - `192.168.0.143` reaches the API and needs only `HERMES_WEBUI_PASSWORD`. The - webui can run agent commands, so treat that password as a credential with - command-execution blast radius. To make loopback-only binding true again, set - `HERMES_WEBUI_HOST=127.0.0.1` in the unit and require the tunnel for every - client — that is an exposure change to decide deliberately, not a silent fix. +- The webui binds `0.0.0.0:8787`, and **two consumers depend on that**: the SSH + tunnel (which dials the host's `localhost`) *and* Caddy, which fronts + `hermes.franksdank.cloud` with `reverse_proxy 192.168.0.143:8787` — a host + address rather than a container name, because this unit is host-networked and + there is no port forwarder to bridge the gap (`dankclaw/Caddyfile` records the + same constraint). So the SSH tunnel is an access path for the phone, not a + security boundary: any LAN host that can route to `192.168.0.143` reaches the + API and needs only `HERMES_WEBUI_PASSWORD`. The webui can run agent commands, + so treat that password as a credential with command-execution blast radius. + To make loopback-only binding true again you must set + `HERMES_WEBUI_HOST=127.0.0.1` **and** give Caddy another way in (a + host-gateway route or a bridge-network unit) — that is an exposure change to + decide deliberately, not a silent fix. It was tried on 2026-09-16 as a silent + fix and broke the vhost with a 502 while the whole e2e suite stayed green; + `tests/e2e/specs/17-hermes-vhost.spec.ts` now asserts that vhost reaches its + app through the guard chain, so a dead upstream fails a gate instead of + hiding. - The webui and the gateway share one data root (`/data` is a bind mount of `/home/dank/Containers/hermes/data`). A config change made on one side takes effect when the other restarts (same caveat as the dashboard). diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index aa42895..ec0450b 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -24,7 +24,12 @@ export default defineConfig({ }, { name: "standard", - testMatch: /(10|12|13|16)-.*\.spec\.ts/, + // NOTE: specs are matched by NUMBER PREFIX, not globbed. A spec file + // whose prefix is not listed in any project silently never runs — a + // green suite with a test that does not exist. 17- joined on 2026-09-16 + // (the hermes vhost, whose upstream is a host address rather than a + // container name, so it needs its own pass condition). + testMatch: /(10|12|13|16|17)-.*\.spec\.ts/, use: { storageState: ".auth/qa-e2e.json" }, dependencies: ["setup"], }, diff --git a/tests/e2e/specs/17-hermes-vhost.spec.ts b/tests/e2e/specs/17-hermes-vhost.spec.ts new file mode 100644 index 0000000..75d9cd8 --- /dev/null +++ b/tests/e2e/specs/17-hermes-vhost.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +// hermes.franksdank.cloud fronts the Hermex webui, which is a HOST-NETWORKED +// container: Caddy cannot reach it by container name, so the vhost's upstream +// is the host's LAN address (`reverse_proxy 192.168.0.143:8787` in +// dankclaw/Caddyfile) and the app must therefore bind beyond loopback. +// +// That coupling broke on 2026-09-16: the unit's bind was tightened to +// 127.0.0.1 for a stricter posture, which killed the upstream and left the +// vhost returning 502 — and the whole e2e suite stayed green, because no spec +// covered this hostname. It is the only guarded vhost whose upstream is a host +// address rather than a container name, so it needs its own assertion. +// +// The app's own password login is expected once the guard chain passes (unlike +// the SSO-silent vhosts in 13-sso), so this spec asserts only what a broken +// upstream would violate: no 5xx, no gate page, and no Caddy error page. + +const VHOST = "https://hermes.franksdank.cloud/"; + +test("hermes vhost reaches its app through the guard chain", async ({ page }) => { + test.setTimeout(180_000); + + // An unreachable upstream is a 502 from Caddy; nothing else in the chain + // produces one, which is what makes this the load-bearing assertion. + const resp = await page.request.get(VHOST, { maxRedirects: 5 }); + expect(resp.status()).toBeLessThan(500); + + await page.goto(VHOST); + await expect + .poll( + async () => !(await page.locator("body").innerText()).includes("Making sure you're not a bot"), + { timeout: 90_000 }, + ) + .toBe(true); + + const body = await page.locator("body").innerText(); + // Caddy's own error text must never be what a user sees here. + expect(body).not.toContain("Bad Gateway"); + expect(body).not.toContain("502"); + // The guard's error surface, as opposed to the app's own login. + expect(body).not.toContain("Powered by authentik"); + expect(body).not.toContain("Making sure you're not a bot"); +}); + +test("hermes vhost does not leak app bytes to an unauthenticated raw client", async ({ + request, +}) => { + // Same rule as 15-unauth-fetch: a raw client solves no challenge, so it must + // get the gate, not the application. + const resp = await request.get(VHOST, { maxRedirects: 0 }); + expect(resp.status()).toBeLessThan(500); + const body = await resp.text().catch(() => ""); + expect(body).not.toContain("Powered by authentik"); +}); diff --git a/tests/e2e_scaffold.bats b/tests/e2e_scaffold.bats index b105adb..2481c1b 100755 --- a/tests/e2e_scaffold.bats +++ b/tests/e2e_scaffold.bats @@ -46,3 +46,24 @@ PY return 1 fi } + +@test "every e2e spec is matched by a playwright project" { + # Playwright projects match specs by NUMBER PREFIX, not by glob, so a spec + # whose prefix is not listed in any project silently never runs: the suite + # reports green while the test does not exist. That happened on 2026-09-16 + # with 17-hermes-vhost.spec.ts — written to catch a real regression (a + # loopback-only bind that killed hermes.franksdank.cloud's upstream while + # every gate stayed green), and invisible until the prefix was registered. + local cfg="$REPO_ROOT/tests/e2e/playwright.config.ts" + python3 - "$cfg" <<'PYEOF' +import pathlib, re, sys +cfg = pathlib.Path(sys.argv[1]).read_text() +patterns = [re.compile(p) for p in re.findall(r"testMatch:\s*/(.+?)/,", cfg)] +assert patterns, "no testMatch patterns found in the playwright config" +specs = sorted(pathlib.Path(sys.argv[1]).parent.joinpath("specs").glob("*.spec.ts")) +assert specs, "no spec files found" +unmatched = [s.name for s in specs if not any(p.search(s.name) for p in patterns)] +assert not unmatched, f"specs matched by no project (they will never run): {unmatched}" +print(f"{len(specs)} specs, all matched by {len(patterns)} projects") +PYEOF +} diff --git a/tests/hermes_webui.bats b/tests/hermes_webui.bats index fd18fe3..c41db53 100644 --- a/tests/hermes_webui.bats +++ b/tests/hermes_webui.bats @@ -38,19 +38,22 @@ webui_service_yaml() { grep -q -- '--foreground' <<< "$y" } -@test "hermes-webui binds loopback, not the LAN" { - local y +@test "hermes-webui's bind serves the address Caddy fronts it on" { + local y caddy y=$(webui_service_yaml) + caddy="$REPO_ROOT/dankclaw/Caddyfile" grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y" \ || { echo "password gate missing"; return 1; } - # With Network=host there is no port forwarder: the app's own bind IS the - # exposure. The compose service this replaced published 127.0.0.1:8787:8787, - # and an early version of the unit bound 0.0.0.0 instead — which answered - # 200 on 192.168.0.143:8787 for anyone on the LAN (measured 2026-09-16). - grep -q 'Environment=HERMES_WEBUI_HOST=127.0.0.1' <<< "$y" \ - || { echo "webui must bind loopback (HERMES_WEBUI_HOST=127.0.0.1)"; return 1; } - ! grep -q 'HERMES_WEBUI_HOST=0.0.0.0' <<< "$y" \ - || { echo "webui must not bind 0.0.0.0 on a host network"; return 1; } + # This unit is host-networked, so Caddy cannot reach it by container name: + # hermes.franksdank.cloud proxies to the host's LAN address. The app's bind + # is therefore load-bearing for that vhost — a loopback-only bind was tried + # on 2026-09-16 and broke it (502), while host networking means there is no + # port forwarder to bridge the gap. + local upstream + upstream=$(grep -A 6 '^hermes\.franksdank\.cloud {' "$caddy" | grep -oE 'reverse_proxy [0-9.]+:[0-9]+' | head -1) + [ -n "$upstream" ] || { echo "no hermes vhost upstream in the Caddyfile"; return 1; } + grep -q 'Environment=HERMES_WEBUI_HOST=0.0.0.0' <<< "$y" \ + || { echo "webui must bind all interfaces: $upstream cannot reach loopback"; return 1; } } @test "hermes-webui shares the gateway data volume and mounts the webui checkout" {