From 915d49d663d63d41ee0bc7ff5c01880cf83db201 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 30 Aug 2026 07:22:20 -0700 Subject: [PATCH] hermes: boot-persistent quadlet units for the gateway, dashboard, and webui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trio ran as bare compose containers and died silently at the Aug 26 reboot — nothing restarts compose containers at boot. Each now has a boot-persistent unit (WantedBy=default.target; the v1 quadlet generator on this host cannot enable generated units). The deploy handler retires the compose-era containers only while the units are not yet active, then (re)starts each unit. Units run the image under a localhost/hermes-agent:latest alias tag (the quadlet create path on this host cannot resolve registry-prefixed refs from local storage); the deploy handler tags first. The gateway's :8090 publish is dropped — nothing consumes it; the public vhost now routes to the dashboard's login-gated UI on 9119. --- .../hermes/quadlet/hermes-dashboard.container | 31 ++++++++++++ .../hermes/quadlet/hermes-webui.container | 50 +++++++++++++++++++ dankclaw/hermes/quadlet/hermes.container | 43 ++++++++++++++++ scripts/deploy-remote.sh | 28 +++++++++++ 4 files changed, 152 insertions(+) create mode 100644 dankclaw/hermes/quadlet/hermes-dashboard.container create mode 100644 dankclaw/hermes/quadlet/hermes-webui.container create mode 100644 dankclaw/hermes/quadlet/hermes.container diff --git a/dankclaw/hermes/quadlet/hermes-dashboard.container b/dankclaw/hermes/quadlet/hermes-dashboard.container new file mode 100644 index 0000000..f6adbd7 --- /dev/null +++ b/dankclaw/hermes/quadlet/hermes-dashboard.container @@ -0,0 +1,31 @@ +# Quadlet unit: Hermes web dashboard (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/hermes-dashboard.container +# Unit name: hermes-dashboard.service (container name: hermes-dashboard) +# +# Management UI for the agent (config, API keys, sessions). Published on +# 9119 so Caddy's internal listener can reverse-proxy it; the public vhost +# hermes.franksdank.cloud terminates Authentik auth first, so this port is +# reachable from the LAN but gated twice (Authentik + its own login). +# Shares /data with the gateway: same agent state, two processes. + +[Unit] +Description=Hermes web dashboard container + +[Container] +ContainerName=hermes-dashboard +Image=localhost/hermes-agent:latest +Pull=never +Network=dankclaw_default +Environment=HERMES_DATA_DIR=/data +Environment=HERMES_HOME=/data +Volume=/home/dank/hermes-data:/data +PublishPort=9119:9119 +Exec=bash -c 'hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build' + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/dankclaw/hermes/quadlet/hermes-webui.container b/dankclaw/hermes/quadlet/hermes-webui.container new file mode 100644 index 0000000..b2e1ad0 --- /dev/null +++ b/dankclaw/hermes/quadlet/hermes-webui.container @@ -0,0 +1,50 @@ +# Quadlet unit: Hermex webui (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/hermes-webui.container +# Unit name: hermes-webui.service (container name: hermes-webui) +# +# 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 (git-ignored). + +[Unit] +Description=Hermes webui (Hermex) container + +[Container] +ContainerName=hermes-webui +Image=localhost/hermes-agent:latest +Pull=never +Network=dankclaw_default +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +Environment=HERMES_HOME=/data +Environment=HERMES_WEBUI_HOST=0.0.0.0 +Environment=HERMES_WEBUI_PORT=8787 +Environment=HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD} +# Headless host: never show the first-run wizard (a profile already +# exists in /data from the gateway). +Environment=HERMES_WEBUI_SKIP_ONBOARDING=1 +Volume=/home/dank/hermes-data:/data +Volume=/home/dank/hermes-webui:/webui +PublishPort=127.0.0.1:8787:8787 +Exec=python3 /webui/bootstrap.py --foreground +# JSON exec form on purpose: this quadlet generator joins a bare HealthCmd +# into ONE argv element with \x20 escapes and then shell-executes it — the +# python one-liner's quoting would be mangled. A JSON array is passed through +# verbatim. Start period covers first-boot pip installs into /webui/.venv. +HealthCmd=["python3","-c","import urllib.request;urllib.request.urlopen('http://127.0.0.1:8787/health')"] +HealthInterval=30s +HealthTimeout=10s +HealthRetries=5 +HealthStartPeriod=600s + +[Service] +Restart=always +RestartSec=5 +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env + +[Install] +WantedBy=default.target diff --git a/dankclaw/hermes/quadlet/hermes.container b/dankclaw/hermes/quadlet/hermes.container new file mode 100644 index 0000000..8f32fc7 --- /dev/null +++ b/dankclaw/hermes/quadlet/hermes.container @@ -0,0 +1,43 @@ +# Quadlet unit: Hermes agent gateway (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/hermes.container +# Unit name: hermes.service (container name: hermes) +# +# The messaging gateway (Signal adapter -> the host signal-cli daemon). The +# image's default s6 entrypoint would run the interactive TUI, which exits +# without a TTY; Exec= below replaces it with the headless gateway command, +# spliced by the v1 quadlet generator as trailing args after the image ref +# (same splice pattern proven live on authentik-worker). The gateway drops to +# the unprivileged hermes user (uid 10000) at runtime; /data is world-writable +# on purpose so that unmapped uid can write agent.log and its state there. +# SIGNAL_HTTP_URL uses the gateway alias, not the host LAN IP: from container +# space on this network only DNAT'd published ports accept connections to the +# LAN address; un-forwarded host ports refuse instantly (verified live +# 2026-08-30). The alias resolves to the network's own gateway IP. + +[Unit] +Description=Hermes agent gateway container +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=hermes +Image=localhost/hermes-agent:latest +Pull=never +Network=dankclaw_default +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +Environment=HERMES_DATA_DIR=/data +Environment=HERMES_HOME=/data +Environment=SIGNAL_HTTP_URL=http://host.containers.internal:8080 +Environment=SIGNAL_ACCOUNT=${SIGNAL_ACCOUNT} +Environment=SIGNAL_ALLOWED_USERS=${SIGNAL_ALLOWED_USERS} +Volume=/home/dank/hermes-data:/data +Exec=bash -c 'hermes gateway run --force -v' + +[Service] +Restart=always +RestartSec=5 +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env + +[Install] +WantedBy=default.target diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 99484d7..ff31c42 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -566,6 +566,33 @@ deploy_authentik_outpost() { echo "WARN: authentik-outpost did not come up active" >&2 } +deploy_hermes() { + # The hermes trio (gateway, dashboard, webui) shares one image and the + # /data volume and deploys as one unit: install the units, retire the + # compose-era containers holding the claimed names while the units are not + # yet active, then restart each unit. An explicit hermes deploy is a + # deliberate action, so restarting an already-running trio is intended. + # Boot persistence comes from [Install] in each unit file (generated units + # cannot be enabled by this host's v1 quadlet generator). + local unit + mkdir -p "$quadlet_unit_dir" + # The units run the image under a localhost/-aliased tag: this host's + # quadlet path can fail to resolve registry-prefixed refs from local + # storage at create time. Tag first, then (re)create via deploy. + podman tag docker.io/nousresearch/hermes-agent:latest localhost/hermes-agent:latest 2>/dev/null || true + for unit in hermes hermes-dashboard hermes-webui; do + cp "dankclaw/hermes/quadlet/$unit.container" "$quadlet_unit_dir/" + done + systemctl --user daemon-reload + if ! systemctl --user is-active --quiet hermes.service; then + podman stop hermes hermes-dashboard hermes-webui 2>/dev/null || true + podman rm hermes hermes-dashboard hermes-webui 2>/dev/null || true + fi + systemctl --user restart hermes.service + systemctl --user restart hermes-dashboard.service + systemctl --user restart hermes-webui.service +} + deploy_anubis() { # Anubis is the bot-challenge proxy in front of the guarded vhosts. # The image lives on ghcr.io; pull once, then the unit runs it from @@ -660,6 +687,7 @@ deploy_service() { frank-default-small) deploy_llm_small ;; frank-default-npu) deploy_llm_npu ;; frank-default-sightsound) deploy_llm_sightsound ;; + hermes|hermes-dashboard|hermes-webui) deploy_hermes ;; *) deploy_compose_service "$service" ;; esac }