hermes-webui is Network=host, so 8787 is a bare host-netns bind, and netavark DNATs only *published* ports. From Caddy's bridge netns 192.168.0.143:8787 and 10.89.1.1:8787 are both refused, while host.containers.internal:8787 (169.254.1.2) answers 200 — measured live 2026-09-16 from inside the Caddy container. The dashboard's published 9119 answers on that same LAN IP, which is what made the old upstream look sound. The vhost comment asserted "192.168.0.143:8787 is open" from that bridge; it is not, so the claim is replaced with the measurement, and the hermes-webui quadlet comment carries the same correction. The seam guarding this invariant accepted any numeric upstream, so the LAN IP passed it; it now pins the measured-working form and fails on the LAN IP (teeth verified by substitution).
116 lines
5.6 KiB
Bash
116 lines
5.6 KiB
Bash
#!/usr/bin/env bash
|
|
# Seam: hermes-webui (the Hermex server) deployment integrity.
|
|
# The webui is the phone-facing API for the Hermes agent. It must stay
|
|
# loopback-bound so the SSH tunnel is its only ingress, keep its password in
|
|
# the host .env, and share the gateway data volume.
|
|
#
|
|
# It reads the QUADLET UNIT, which is the live source of truth. It used to read
|
|
# dankclaw/compose.yaml, and that is exactly how the bind drifted: the compose
|
|
# service published 127.0.0.1:8787:8787 (loopback), the unit that replaced it
|
|
# bound 0.0.0.0 with only the password in front, and the test kept passing
|
|
# because it was checking the file nobody ran any more.
|
|
|
|
load helpers
|
|
|
|
UNIT="$REPO_ROOT/dankclaw/hermes/quadlet/hermes-webui.container"
|
|
ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example"
|
|
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
|
DEPLOY="$REPO_ROOT/scripts/deploy.sh"
|
|
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
|
|
|
webui_service_yaml() {
|
|
# Normalize the unit's key=value lines into the same shape the assertions
|
|
# below always used, so the contract reads the same as it did for compose.
|
|
[ -f "$UNIT" ] || { echo "missing $UNIT" >&2; return 1; }
|
|
grep -E '^(Image|Exec|Environment|Volume|HealthCmd|PublishPort)=' "$UNIT"
|
|
}
|
|
|
|
@test "hermes-webui runs the webui bootstrap in foreground mode" {
|
|
local y
|
|
y=$(webui_service_yaml)
|
|
# The unit runs the localhost-aliased tag (this host's quadlet path cannot
|
|
# resolve registry-prefixed refs from local storage); the deployer tags it
|
|
# from the upstream name, which is what keeps the alias fresh.
|
|
grep -q 'Image=localhost/hermes-agent:latest' <<< "$y"
|
|
grep -q 'podman tag docker.io/nousresearch/hermes-agent:latest localhost/hermes-agent:latest' \
|
|
"$REPO_ROOT/scripts/deploy-remote.sh"
|
|
grep -q 'bootstrap.py' <<< "$y"
|
|
grep -q -- '--foreground' <<< "$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; }
|
|
# This unit is host-networked, so Caddy cannot reach it by container name.
|
|
# The app's bind is 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.
|
|
#
|
|
# The upstream must be netavark's HOST ADDRESS, not the host's LAN IP.
|
|
# 8787 is a bare host-netns bind and netavark DNATs only *published* ports,
|
|
# so from Caddy's bridge netns 192.168.0.143:8787 and 10.89.1.1:8787 are
|
|
# both refused, while host.containers.internal:8787 (169.254.1.2) answers
|
|
# 200 — measured 2026-09-16 from inside the Caddy container. The dashboard's
|
|
# published 9119 is reachable on the LAN IP precisely because it IS
|
|
# published. Pin the measured-working form so reverting to the LAN IP cannot
|
|
# pass silently.
|
|
local upstream
|
|
upstream=$(grep -A 6 '^hermes\.franksdank\.cloud {' "$caddy" | grep -oE 'reverse_proxy [^ {]+' | head -1)
|
|
[ -n "$upstream" ] || { echo "no hermes vhost upstream in the Caddyfile"; return 1; }
|
|
grep -qE '^reverse_proxy (host\.containers\.internal|169\.254\.1\.2):8787$' <<< "$upstream" \
|
|
|| { echo "hermes upstream is '$upstream', not netavark's host address (host.containers.internal:8787)"; 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" {
|
|
local y
|
|
y=$(webui_service_yaml)
|
|
grep -q 'Environment=HERMES_HOME=/data' <<< "$y"
|
|
grep -q 'Volume=/home/dank/Containers/hermes/data:/data' <<< "$y"
|
|
grep -q 'Volume=/home/dank/Containers/hermes/webui:/webui' <<< "$y"
|
|
}
|
|
|
|
@test "hermes-webui has a healthcheck that probes /health" {
|
|
local y
|
|
y=$(webui_service_yaml)
|
|
grep -q 'HealthCmd=' <<< "$y"
|
|
grep -q '/health' <<< "$y"
|
|
}
|
|
|
|
@test "dankclaw .env.example defines the webui password placeholder" {
|
|
env_vars_defined "$ENV_EXAMPLE" | grep -qx 'HERMES_WEBUI_PASSWORD'
|
|
}
|
|
|
|
@test "planner maps a hermes-webui unit edit to the hermes service" {
|
|
# Any file under dankclaw/hermes/** selects the `hermes` service, because
|
|
# the trio (gateway, dashboard, webui) deploys as one unit-set — see
|
|
# deploy_hermes, which installs all three units and restarts them together.
|
|
# The three names are aliases of that handler, not separate deployments.
|
|
# (This test used to assert that a *compose* edit narrowed to hermes-webui;
|
|
# Dankclaw has had no compose file since 2026-09-16.)
|
|
run python3 "$PLANNER" --target dankclaw \
|
|
--paths dankclaw/hermes/quadlet/hermes-webui.container
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "hermes" ]
|
|
# And an unrelated service must not be dragged in.
|
|
! grep -qE 'caddy|prometheus|frank-large' <<< "$output"
|
|
}
|
|
|
|
@test "remote deployer accepts and dispatches hermes-webui" {
|
|
grep -q 'dankclaw:hermes-webui' "$REMOTE_DEPLOY"
|
|
# Dispatched to the unit handler, never to compose: deploy_hermes installs
|
|
# the quadlets and retires compose-era containers.
|
|
grep -q 'hermes|hermes-dashboard|hermes-webui) deploy_hermes' "$REMOTE_DEPLOY"
|
|
# deploy_hermes installs the trio with one loop over the unit names.
|
|
grep -q 'for unit in hermes hermes-dashboard hermes-webui' "$REMOTE_DEPLOY"
|
|
grep -q 'dankclaw/hermes/quadlet/$unit.container' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "top-level deployer accepts hermes-webui as a Dankclaw service" {
|
|
grep -q 'dankclaw:hermes-webui' "$DEPLOY"
|
|
}
|