hermes: the webui was LAN-facing, not loopback — and Dankclaw's compose is gone

Two things, found while checking whether the hermes trio still needed migrating.

1. **The phone-facing webui was exposed to the LAN.** Its unit runs with
   Network=host, so there is no Podman port forwarder and the app's own bind is
   the exposure. The compose service it replaced published
   `127.0.0.1:8787:8787` (loopback); the unit bound `HERMES_WEBUI_HOST=0.0.0.0`,
   and the LAN address answered 200 — password-gated, but on every interface,
   which is not the documented "SSH tunnel is the sole ingress" posture. It now
   binds 127.0.0.1, which serves the tunnel just as well (sshd dials
   localhost:8787 from the host). The unit comment claiming "LAN-facing as
   before" was wrong about "before", and is corrected.

2. **Dankclaw's compose.yaml is retired.** Auditing ownership showed every
   running container on that host reports a PODMAN_SYSTEMD_UNIT label — the
   hermes trio's units have owned their containers since 2026-09-12 — so the
   three service blocks left in compose.yaml were dead weight that could only
   create a duplicate container fighting a unit for a name. deploy_hermes was
   already fully unit-based. The file is staged in .AGENTTRASH/; the planner
   plans nothing for it, an unknown service on that target now errors instead of
   silently no-oping through `podman-compose`, and DANKCLAW_COMPOSE_SERVICES is
   empty.

The hermes-webui seam now reads the unit — it read compose, which is exactly how
the bind drifted while the test kept passing against a file nobody ran.
This commit is contained in:
Frank
2026-09-16 07:22:20 -07:00
parent 06d3502a2b
commit aaa1f82313
5 changed files with 83 additions and 228 deletions
-147
View File
@@ -1,147 +0,0 @@
# Dankclaw stack — AI, search, development & observability services.
# Deploy: cd /home/dank/serverAppConfigs && podman-compose -f dankclaw/compose.yaml up -d
# See ../docs/Dankclaw/ and ../docs/Infrastructure/ for service documentation.
services:
# --- SearXNG stack (gluetun + searxng + valkey) --------------------------
# These three services are now boot-persistent Quadlet units, not compose
# services. They live in searxng/quadlet/ and install to
# ~/.config/containers/systemd/ on the host. searxng reuses gluetun's netns
# (Network=gluetun.container) so its traffic exits the WireGuard tunnel.
# Deploy with: scripts/deploy-searxng-quadlet.sh dankclaw
# See ../docs/Dankclaw/SearXNG.md, Gluetun.md, Valkey.md.
# --- AI image generation (ROCm GPU backend, gfx1151) ---------------------
# ComfyUI is a boot-persistent Quadlet unit since 2026-09-16:
# comfyui/quadlet/comfyui.container, installed by deploy-remote.sh. It left
# compose after the container it had started went stale — created before the
# GPU declarations existed, then restarted by `restart: unless-stopped`
# without /dev/kfd or /dev/dri until it had failed 9,146 times on
# "No HIP GPUs are available". Built from Containerfile.comfyui (AMD's
# gfx1151 base), models and data under /home/dank/comfyui-data.
# See ../docs/Dankclaw/ComfyUI.md.
# --- Chat UI (open-webui) ------------------------------------------------
# Now a boot-persistent Quadlet unit (quadlet, slirp4netns, port 3005), not
# a compose service. See open-webui/quadlet/open-webui.container and
# ../docs/Dankclaw/OpenWebUI.md.
hermes:
image: docker.io/nousresearch/hermes-agent:latest
container_name: hermes
network_mode: host
restart: unless-stopped
# The image's default s6 entrypoint runs the interactive TUI, which exits
# without a TTY and crash-loops the container. Override it to run the
# headless gateway (the documented Docker mode). The gateway drops to the
# unprivileged hermes user (uid 10000), so the data dir must be writable by it.
entrypoint: ["bash", "-c", "hermes gateway run --force -v"]
environment:
HERMES_DATA_DIR: /data
# Point hermes's config/.env at the mounted data dir (persistent).
HERMES_HOME: /data
# Signal adapter -> the signal-cli daemon, a HOST process listening on
# :8080. The service runs with network_mode: host below, so it dials
# loopback directly; from a bridge network only DNAT'd published ports
# accept connections to the host LAN IP, and un-forwarded ports refuse
# instantly (verified live 2026-08-30).
SIGNAL_HTTP_URL: "http://127.0.0.1:8080"
SIGNAL_ACCOUNT: "${SIGNAL_ACCOUNT}"
SIGNAL_ALLOWED_USERS: "${SIGNAL_ALLOWED_USERS}"
ports:
- "8090:8090"
volumes:
- /home/dank/hermes-data:/data
# No healthcheck: 'hermes gateway run' starts the messaging gateway (Signal
# SSE), not the :8090 web dashboard, so there is no HTTP endpoint to probe.
# The container's running state + the daemon's SSE connection are the signal.
# --- Hermes web dashboard (management UI: config, API keys, sessions) -----
# A separate process from the Signal gateway (the gateway entrypoint does not
# start the dashboard). Bound to the LAN with basic auth: the username + a
# scrypt password_hash live in the git-ignored /home/dank/hermes-data config.
# Port 9119 is published so a browser on the LAN can reach it.
hermes-dashboard:
image: docker.io/nousresearch/hermes-agent:latest
container_name: hermes-dashboard
restart: unless-stopped
entrypoint: ["bash", "-c", "hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build"]
environment:
HERMES_DATA_DIR: /data
HERMES_HOME: /data
ports:
- "9119:9119"
volumes:
- /home/dank/hermes-data:/data
# --- Hermes web UI (Hermex server; phone-facing API for the agent) --------
# Runs the open-source hermes-webui (github.com/nesquena/hermes-webui) in
# foreground mode: bootstrap.py creates .venv inside the mounted checkout,
# installs WebUI + agent deps, then execs server.py as a single foreground
# process. It reads HERMES_HOME=/data, so it drives the SAME agent state
# (memory, skills, sessions) as the gateway container.
# Loopback-only publish: the SSH tunnel (phone -> Dankclaw:22) is the sole
# ingress. The password gates Hermex logins and lives in the host .env.
hermes-webui:
image: docker.io/nousresearch/hermes-agent:latest
container_name: hermes-webui
restart: unless-stopped
entrypoint: ["bash", "-c", "python3 /webui/bootstrap.py --foreground"]
environment:
HERMES_HOME: /data
# Container-internal bind: 0.0.0.0 so Podman's port forwarder (which
# dials the container's bridge IP) can reach it. The *host-side*
# publish below stays on 127.0.0.1, so loopback-only ingress holds.
HERMES_WEBUI_HOST: 0.0.0.0
HERMES_WEBUI_PORT: "8787"
HERMES_WEBUI_PASSWORD: "${HERMES_WEBUI_PASSWORD}"
# Headless host: never show the first-run wizard (a profile already
# exists in /data from the gateway).
HERMES_WEBUI_SKIP_ONBOARDING: "1"
ports:
- "127.0.0.1:8787:8787"
volumes:
- /home/dank/hermes-data:/data
# rw on purpose: bootstrap.py creates /webui/.venv in the repo root.
- /home/dank/hermes-webui:/webui
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health')"]
interval: 30s
timeout: 10s
retries: 5
# First boot pip-installs WebUI + agent deps into a fresh venv.
start_period: 600s
# --- Git hosting (Gitea) -------------------------------------------------
# Now boot-persistent Quadlet units (private bridge network, DNS enabled),
# not compose services. See gitea/quadlet/ and ../docs/Dankclaw/Gitea.md.
# --- Observability -------------------------------------------------------
# Now boot-persistent Quadlet units, not compose services: prometheus,
# grafana, loki, alloy and node-exporter install from
# dankclaw/observability/quadlet/ and are dispatched by deploy_observability
# in scripts/deploy-remote.sh. They left compose on 2026-09-11, after a host
# reboot proved that compose-only containers do not come back on their own —
# the same gap that had already cost the fleet Caddy and Pi-hole.
#
# The three HTTP UIs publish on 127.0.0.1 only. Off-host access is through
# the guarded franksdank.cloud vhosts (Caddy reaches these containers by
# name over this network) and the host's own homepage status timer probes
# them over loopback. See
# serverAppConfigs-docs/Infrastructure/Observability.md.
# --- Authentik (SSO / OIDC provider) -------------------------------------
# Now boot-persistent Quadlet units, not compose services: authentik-
# postgres -> authentik-server -> authentik-worker start in dependency order
# via ~/.config/containers/systemd/*.container on the host (repo source:
# dankclaw/authentik/quadlet/; deploy-remote.sh deploy_authentik installs
# and starts them). Redis was removed in 2025.10 (sessions/caching moved to
# PostgreSQL). The Caddyfile's forward_auth vhosts proxy to
# authentik-server:9000 over the dankclaw_default network (A2A DNS).
# See ../docs/Infrastructure/Authentik.md.
# Caddy (the homelab's single reverse proxy) left compose on 2026-08-26:
# it is boot-persistent via its Quadlet unit (dankclaw/caddy/quadlet/
# caddy.container), installed by deploy_caddy in scripts/deploy-remote.sh.
# The rendered Caddyfile, certs, and config still live under
# /home/dank/Containers/caddy (rootless-writable).
@@ -14,9 +14,13 @@
# daemon binds loopback-only in the host network namespace (see
# chrome-devtools-protocol.container). Only processes sharing that namespace
# can drive the browser; a bridge-network client could not reach it at all.
# The server therefore listens on 0.0.0.0:8787 of the host netns (LAN-facing as
# before); access control is the password gate, and the SSH tunnel
# (phone -> Dankclaw:22) remains the sole off-network ingress path.
# The server binds LOOPBACK (HERMES_WEBUI_HOST=127.0.0.1). Host networking
# means there is no Podman port forwarder, so the bind IS the exposure: the
# compose service this replaced published 127.0.0.1:8787:8787, and the first
# version of this unit bound 0.0.0.0 instead, which put the phone-facing API on
# the whole LAN with only the password in front of it (measured 2026-09-16:
# 192.168.0.143:8787 answered 200). The SSH tunnel (phone -> Dankclaw:22) dials
# localhost:8787 from the host, so loopback serves it exactly as well.
[Unit]
Description=Hermes webui (Hermex) container
@@ -27,7 +31,7 @@ Image=localhost/hermes-agent:latest
Pull=never
Network=host
EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env
Environment=HERMES_WEBUI_HOST=0.0.0.0
Environment=HERMES_WEBUI_HOST=127.0.0.1
Environment=HERMES_WEBUI_PORT=8787
Environment=HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD}
Environment=HERMES_HOME=/data
+16 -19
View File
@@ -125,21 +125,12 @@ FRANKBOOK_SERVICES = {
"readarr",
}
DANKCLAW_COMPOSE_SERVICES = {
"comfyui",
"hermes",
"hermes-dashboard",
"hermes-webui",
"prometheus",
"grafana",
"loki",
"alloy",
"node-exporter",
"authentik-postgres",
"authentik-server",
"authentik-worker",
"caddy",
}
DANKCLAW_COMPOSE_SERVICES = set()
# Dankserver's compose file is still the source of truth for its media stack;
# Dankclaw's is gone (deleted 2026-09-16). Every service on Dankclaw is a
# Quadlet unit now — the last compose services were the hermes trio, whose units
# have owned their containers since 2026-09-12 and whose compose blocks were
# dead weight that could only create a duplicate container.
DANKSERVER_COMPOSE_SERVICES = {
"mariadb",
@@ -198,8 +189,14 @@ def _yaml_services(contents: str | None) -> dict[str, Any]:
def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]:
"""Return only compose services whose YAML definitions or dependencies changed."""
"""Return only compose services whose YAML definitions or dependencies changed.
A target with no compose file (Dankclaw since 2026-09-16) plans nothing:
its services are Quadlet units, selected by their own paths.
"""
path = f"{target}/compose.yaml"
if not _git_show(repo, head, path) and not _git_show(repo, base, path):
return set()
old_doc = _yaml_document(_git_show(repo, base, path))
new_doc = _yaml_document(_git_show(repo, head, path))
old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {}
@@ -264,9 +261,9 @@ def services_for_path(path: str, target: str) -> set[str]:
if parts[0] == "dankclaw":
if len(parts) >= 2 and parts[1] == "compose.yaml":
# Revision-aware callers replace this broad fallback with the
# service-level YAML comparison in compose_changes().
_add(services, *DANKCLAW_COMPOSE_SERVICES)
# Dankclaw has no compose file since 2026-09-16: every service is a
# Quadlet unit, each selected by its own path. A change (or a
# deletion) of this path therefore restarts nothing.
return services
if len(parts) >= 2 and parts[1] == "Caddyfile":
_add(services, "caddy")
+8 -3
View File
@@ -382,10 +382,15 @@ deploy_compose_service() {
echo "WARN: $service is recorded in dankserver/compose.yaml; production container management is host-specific and no restart was attempted."
return 0
fi
if [[ "$TARGET" == dankclaw ]]; then
# Dankclaw has no compose file since 2026-09-16: every service is a
# Quadlet unit with its own handler above. Reaching this means the
# dispatcher is missing a case, and the old fallback (`podman-compose
# up -d`) would now be a silent no-op or a duplicate container.
echo "no compose services on dankclaw; $service has no quadlet handler" >&2
return 2
fi
case "$service" in
hermes|hermes-dashboard|hermes-webui)
podman-compose -f dankclaw/compose.yaml up -d --no-deps "$service"
;;
*)
echo "unknown compose service: $service" >&2
return 2
+51 -55
View File
@@ -2,59 +2,69 @@
# 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, share the gateway data volume, and deploy as an isolated
# compose service that cannot drag the gateway or inference along with it.
# 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
COMPOSE="$REPO_ROOT/dankclaw/compose.yaml"
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() {
python3 - "$COMPOSE" <<'PYEOF'
import sys, yaml
with open(sys.argv[1]) as fh:
data = yaml.safe_load(fh)
svc = (data.get("services") or {}).get("hermes-webui")
assert svc, "dankclaw/compose.yaml: no hermes-webui service"
print(yaml.safe_dump(svc))
PYEOF
# 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)
grep -q 'nousresearch/hermes-agent' <<< "$y"
# 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 is loopback-bound and password-gated" {
@test "hermes-webui binds loopback, not the LAN" {
local y
y=$(webui_service_yaml)
# Host-side publish stays on loopback: the SSH tunnel is the only ingress.
grep -q '127.0.0.1:8787' <<< "$y"
grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y"
# Container-internal bind must be 0.0.0.0 or Podman's port forwarder
# (which dials the bridge IP) resets every connection.
grep -q 'HERMES_WEBUI_HOST: 0.0.0.0' <<< "$y"
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; }
}
@test "hermes-webui shares the gateway data volume and mounts the webui checkout" {
local y
y=$(webui_service_yaml)
grep -q 'HERMES_HOME: /data' <<< "$y"
grep -q '/home/dank/hermes-data:/data' <<< "$y"
grep -q '/home/dank/hermes-webui:/webui' <<< "$y"
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 'healthcheck' <<< "$y"
grep -q 'HealthCmd=' <<< "$y"
grep -q '/health' <<< "$y"
}
@@ -62,43 +72,29 @@ PYEOF
env_vars_defined "$ENV_EXAMPLE" | grep -qx 'HERMES_WEBUI_PASSWORD'
}
# Revision-aware planner: a change to only the hermes-webui service block must
# plan hermes-webui and nothing else (the gateway is unchanged).
webui_fixture_repo() {
local dir="$1"
git init -q "$dir"
git -C "$dir" config user.email t@t && git -C "$dir" config user.name t
mkdir -p "$dir/dankclaw"
cat > "$dir/dankclaw/compose.yaml" <<'EOF'
services:
hermes:
image: docker.io/nousresearch/hermes-agent:latest
hermes-webui:
image: docker.io/nousresearch/hermes-agent:latest
ports:
- "127.0.0.1:8787:8787"
EOF
git -C "$dir" add -A && git -C "$dir" commit -qm base
# Touch only the webui service block (its port line is unique in the file).
sed -i 's|127.0.0.1:8787:8787|127.0.0.1:8788:8787|' "$dir/dankclaw/compose.yaml"
git -C "$dir" commit -qam head
}
@test "planner narrows a hermes-webui compose edit to hermes-webui only" {
local dir base head
dir=$(mktemp -d)
webui_fixture_repo "$dir"
base=$(git -C "$dir" rev-parse HEAD~1)
head=$(git -C "$dir" rev-parse HEAD)
run python3 "$PLANNER" --target dankclaw --repo "$dir" \
--base "$base" --head "$head" --paths dankclaw/compose.yaml
@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-webui" ]
[ "$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"
awk '/^deploy_compose_service\(\)/,/^}/' "$REMOTE_DEPLOY" | grep -q 'hermes-webui'
# 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" {