New on-site host mars runs Hermes Agent as its sole service: joins the
tailnet, mounts jupiter's samba share at /mnt/jupiter (doubling as
Hermes's shared dropbox), and hosts state locally under /var/lib/hermes.
Same Authentik OIDC app/Telegram bot as before, just relocated — neptun's
hermes.mgaction.town vhost now points at mars.orbit.sol instead of jupiter.
hosts/jupiter/hermes-agent.nix and its three sops secrets are removed;
jupiter's Caddy vhost for it is gone too. Also refreshes tailscale_authkey
across all hosts and fixes two stale "erik@laptop" keys in flake.nix's
kexec/installer-iso images (leftover from a previous laptop, already
swapped out of common.nix back in 2fd5752) to darman@terra.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
147 lines
7.1 KiB
Nix
147 lines
7.1 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 (`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" ];
|
|
};
|
|
}
|