{ config, ... }: # Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix, # see its git history / b5fa599 / 713d91d for the terra->jupiter->mars # lineage). mars is dedicated to this one service, on-site, with no big # data array of its own — unlike jupiter it has nothing under /mnt/data, so # state lives on the local OS disk and the shared dropbox rides jupiter's # samba share as a CIFS client instead of being served locally. # # Runs the OFFICIAL published image (docker.io/nousresearch/hermes-agent — # real and actively maintained, contrary to what the checked-out repo's own # README/docker-compose.yml suggested; verified directly on Docker Hub) as a # plain podman container. It never sets HERMES_MANAGED or writes .managed, so # Hermes fully self-manages config.yaml, profiles, memories and skills at # runtime — no redeploy needed except to bump the pinned digest below. # # Security posture: # - Only two paths reachable: its own local state dir, and the small # shared "dropbox" below (via the jupiter samba mount) for darman to # hand files to Hermes — nothing else on jupiter's array is reachable # if a command goes wrong or gets injected via Telegram/tool output. # - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT # TELEGRAM_ALLOWED_USERS. # - Runs as a rootful podman container (services/containers.nix) with its # OWN numeric uid/gid — not darman, who is in the "hermes" group for # host-level debugging only (`podman exec -it hermes-agent hermes ...`). # # Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on # jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth # provider registered, and 0.0.0.0 (not loopback) is required so neptun's # Caddy can reach it over tailscale0 — reachability itself stays LAN-closed # (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a # trustedInterface, services/vpn/tailscale.nix). Public route: neptun's # hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this # over the tailnet. mars runs no Caddy of its own (single-purpose box), so # there is no LAN vhost — reach the dashboard directly via mars's tailnet # name (mars.orbit.sol:9119) or LAN IP:9119 for local debugging. # # Uses upstream's generic self-hosted OIDC plugin, same Authentik # application as before (slug `hermes`) — the client ID/secret didn't need # to change since the public redirect URI (hermes.mgaction.town) didn't. # # Data migration: this starts with a FRESH state dir. jupiter's instance was # itself reset to fresh on 2026-08-21 (see its old hermes-agent.nix), so # there was nothing irreplaceable to carry forward; if that turns out to be # wrong, jupiter's old data is backed up at # /mnt/data/AppData/hermes.bak-2026-08-21 and can be rsynced into # ${hermesHome} below before the first switch on mars. let stateDir = "/var/lib/hermes"; hermesHome = "${stateDir}/.hermes"; # Shared drop-in folder: darman can put files here from any host. Lives on # jupiter's array (reachable at /mnt/jupiter, the samba mount below) rather # than locally, so it's the same physical location it always was — only # the container reading it moved. Mounted under /opt/data so it falls # inside Hermes's own sealed write-safe root (HERMES_WRITE_SAFE_ROOT= # /opt/data) rather than a path its own tooling would treat as untrusted. dropboxDir = "/mnt/jupiter/AppData/hermes-dropbox"; # Pinned by digest (captured 2026-08-21 via `podman image inspect # docker.io/nousresearch/hermes-agent:latest --format '{{.Digest}}'` on # jupiter) rather than floating `:latest`, so a redeploy is reproducible — # bumping Hermes is an explicit edit here, not silent drift on next pull. hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259"; # Kept identical to jupiter's instance purely so nothing else needs to # change if state ever gets migrated over. hermesUid = "986"; hermesGid = "983"; in { # Browsing convenience (ssh access to the bind-mounted local state) — does # NOT touch the container, which keeps using HERMES_UID/GID above # regardless of what's declared here. users.groups.hermes.gid = 983; users.users.darman.extraGroups = [ "hermes" ]; systemd.tmpfiles.rules = [ "d ${stateDir} 0750 root hermes -" ]; # podman requires the bind-mount source to already exist (no auto-create), # and the dropbox lives on the CIFS mount below — mkdir there works fine # over cifs, no server-side (jupiter) config needed. systemd.services.hermes-agent-prepare-dirs = { description = "Create Hermes state dirs before the container starts"; before = [ "podman-hermes-agent.service" ]; wantedBy = [ "podman-hermes-agent.service" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; serviceConfig.Type = "oneshot"; script = '' mkdir -p ${hermesHome} mkdir -p ${dropboxDir} ''; }; virtualisation.oci-containers.containers.hermes-agent = { image = hermesImage; autoStart = true; # Host networking: Hermes only long-polls Telegram outbound, no inbound # ports to publish (same reasoning as clonarr on jupiter). extraOptions = [ "--network=host" ]; # Upstream's own documented single-mount pattern (docker/docker-compose.yml): # ~/.hermes:/opt/data. volumes = [ "${hermesHome}:/opt/data" "${dropboxDir}:/opt/data/dropbox" ]; environment = { HERMES_UID = hermesUid; HERMES_GID = hermesGid; TZ = "Europe/Berlin"; # HERMES_TIMEZONE is the highest-priority source hermes_time.py checks # (ahead of config.yaml's `timezone` key) — the container has no host # /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in # 9403122 on jupiter; carried forward here). HERMES_TIMEZONE = "Europe/Berlin"; # Dashboard + Authentik OIDC gate — see the file-level comment above. HERMES_DASHBOARD = "1"; HERMES_DASHBOARD_HOST = "0.0.0.0"; # must be tailscale0-reachable, not just loopback HERMES_DASHBOARD_OIDC_ISSUER = "https://auth.mgaction.town/application/o/hermes/"; HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6"; # uvicorn's proxy_headers=True (web_server.py) only trusts # X-Forwarded-Proto from forwarded_allow_ips, which defaults to # 127.0.0.1 — neptun's Caddy reaches this over the tailnet (a real # routed IP), so without this the dashboard sees the raw scheme (http) # and builds an http:// redirect_uri that Authentik rejects against its # registered https:// one. Safe to trust any peer here: 9119 is already # scoped to loopback + tailscale0 only (no LAN firewall rule), so # nothing untrusted can reach this process to begin with. FORWARDED_ALLOW_IPS = "*"; }; environmentFiles = [ config.sops.templates."hermes-agent.env".path ]; cmd = [ "gateway" "run" ]; }; systemd.services.podman-hermes-agent = { after = [ "hermes-agent-prepare-dirs.service" "systemd-tmpfiles-setup.service" ]; requires = [ "hermes-agent-prepare-dirs.service" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; }; }