Files
homelab/hosts/mars/hermes-agent.nix
T
darmanandClaude Sonnet 5 a1cd6ae6f1 mars: add hermes CLI shell alias
darman's own podman is rootless while the container runs under root's
(system) podman, so plain `podman exec` couldn't see it. Alias runs it
with sudo against the right socket.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du1WQRk1F8DenrPhf8TofF
2026-08-22 05:03:30 +02:00

156 lines
7.7 KiB
Nix

{ 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 (`hermes ...` alias below, needs sudo since
# the container itself runs under root's podman, not darman's rootless
# one).
#
# 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" ];
# `hermes <args>` on mars == `sudo podman exec -it hermes-agent hermes <args>`.
# sudo is required: virtualisation.oci-containers runs rootful (system)
# podman, a separate namespace from darman's own rootless `podman`/`docker`
# — darman's "hermes"/"docker" group membership only grants filesystem
# access to the bind-mounted state dir, not to root's container socket.
programs.zsh.shellAliases.hermes = "sudo podman exec -it hermes-agent 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" ];
};
}