add mars host, move Hermes Agent there from jupiter

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
This commit is contained in:
2026-08-22 03:00:24 +02:00
co-authored by Claude Sonnet 5
parent 9403122888
commit 7d63ba95df
18 changed files with 433 additions and 303 deletions
-1
View File
@@ -6,7 +6,6 @@
./hardware-configuration.nix
./disk-config.nix # disko: OS-disk partitions + filesystems
./secrets.nix # sops-nix: samba password, tailscale key, ...
./hermes-agent.nix # Hermes Agent, isolated instance (see file for why)
../../common.nix # shared base: user / ssh / nix / firewall
../../services/network/samba.nix
../../services/network/avahi.nix
-83
View File
@@ -1,83 +0,0 @@
{ config, ... }:
# Hermes Agent — jupiter's own instance (terra no longer runs one; see
# 713d91d). Locked down given jupiter's much bigger blast radius
# (irreplaceable immich photos on an unredundant RAID0, gitea/CI tokens, the
# whole media stack):
#
# - Own dedicated "hermes" system user (module default: user/group "hermes",
# createUser = true) — NOT darman. darman is in jupiter's "docker" group
# (services/containers.nix: rootful podman with dockerCompat), which is
# root-equivalent (`docker run -v /:/host --privileged ...`). Handing an
# LLM-driven agent that identity would mean a container escape = root on
# the whole NAS.
# - container.enable = true, backend = "podman": the ENTIRE gateway process
# runs inside a container (reusing jupiter's existing rootful podman
# instead of also standing up a second Docker daemon), not just the shell
# tool. Per upstream's own SECURITY.md this is "whole-process wrapping" —
# shell, file tools, MCP subprocesses, and the code-exec tool are all
# confined, unlike the lighter "terminal-backend"-only isolation.
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
# TELEGRAM_ALLOWED_USERS rather than relying solely on the adapter's
# fail-closed default. Sharing terra's bot token would 409-conflict two
# long-pollers on the same token.
# - No container.extraVolumes into /mnt/data or the homelab repo — nothing
# valuable is in reach if a command goes wrong or gets injected via
# Telegram/tool output. stateDir/workingDirectory live on the array
# (below) purely because coding-task state (repo clones, npm/pip caches
# inside the container's writable layer) belongs off the 29G eMMC, same
# reasoning as postgres/containers.storage in configuration.nix — NOT
# because anything else on /mnt/data is exposed to the agent.
{
services.hermes-agent = {
enable = true;
addToSystemPackages = true; # `hermes` on darman's PATH for interactive
# debugging over ssh — routes through to the
# container, does not grant darman any group.
# Off the eMMC: stateDir bind-mounts into the container as /data, so this
# is where any future scoped repo clone (container.extraVolumes) and the
# container's own writable layer (npm/pip installs during coding tasks)
# actually land. RequiresMountsFor below (mirrors podman/sabnzbd/gitea-runner
# in configuration.nix) keeps the service from starting — and bind-mounting
# the wrong, empty eMMC path — before the nofail array is up.
stateDir = "/mnt/data/AppData/hermes";
workingDirectory = "/mnt/data/AppData/hermes/workspaces";
# 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. Not a
# secret, so `environment` (plain .env) rather than sops.
environment.HERMES_TIMEZONE = "Europe/Berlin";
container = {
enable = true;
backend = "podman"; # jupiter already runs podman (services/containers.nix);
# default "docker" would stand up a second daemon.
};
# OpenCode Go provider account (same key originally used for terra's now-
# removed instance, copied into secrets/jupiter.yaml — just an API key,
# not a stateful identity like the Telegram bot token).
settings.model = {
provider = "opencode-go";
base_url = "https://opencode.ai/zen/go/v1";
default = "gpt-5.6-luna";
api_mode = "codex_responses";
};
settings.platforms.telegram = {
enabled = true;
home_channel = {
platform = "telegram";
chat_id = "15151223";
name = "Erik Simon";
user_id = "15151223";
};
};
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
};
systemd.services.hermes-agent.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
}
-17
View File
@@ -64,21 +64,4 @@
sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd";
sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd";
# Hermes Agent (hosts/jupiter/hermes-agent.nix) — a separate, isolated
# instance from terra's, with its OWN Telegram bot token (sharing terra's
# would 409-conflict two long-pollers on one token). opencode_go_api_key
# is the same provider account as terra (hosts/terra/secrets.nix) — a
# stateless API key, fine to duplicate across hosts. No owner override:
# sops.templates renders via a root-run activation script, which the
# hermes module's own activation script (also root) then reads — unlike
# sabnzbd's preStart, this doesn't run as the service's own user.
sops.secrets.opencode_go_api_key = { };
sops.secrets.telegram_bot_token = { };
sops.templates."hermes-agent.env".content = ''
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
TELEGRAM_HOME_CHANNEL=15151223
TELEGRAM_ALLOWED_USERS=15151223
'';
}
+49
View File
@@ -0,0 +1,49 @@
{ config, pkgs, ... }:
# mars — on-site x86_64 box, single-purpose: runs Hermes Agent only.
# See hermes-agent.nix for what that is and why it moved here from jupiter.
{
imports = [
./hardware-configuration.nix
./disk-config.nix # disko: OS-disk partitions + filesystems
./secrets.nix # sops-nix: samba/tailscale/hermes secrets
./hermes-agent.nix
../../common.nix # shared base: user / ssh / nix / firewall
../../services/containers.nix
../../services/vpn/tailscale.nix
];
networking.hostName = "mars";
networking.networkmanager.enable = true; # DHCP on-site, same as jupiter
users.users.darman.extraGroups = [ "docker" ]; # merges with common.nix; podman debug access
# ---- Boot (UEFI, confirmed) ----
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
# jupiter's samba share (services/network/samba.nix) — mounted on demand so
# mars doesn't stall boot/login when jupiter is off or unreachable. This is
# also where Hermes's shared dropbox lives now (hermes-agent.nix). Modes are
# tighter than terra's equivalent mount (0770 not 0755, gid=hermes not
# gid=users) since the hermes-agent container (uid 986, gid 983 — no podman
# userns remapping, see services/network/pihole.nix) needs group write into
# it, not just darman.
fileSystems."/mnt/jupiter" = {
device = "//jupiter/data";
fsType = "cifs";
options = [
"credentials=${config.sops.templates."jupiter-smb.credentials".path}"
"uid=1000"
"gid=983"
"file_mode=0770"
"dir_mode=0770"
"nofail"
"x-systemd.automount"
"x-systemd.idle-timeout=60"
"x-systemd.mount-timeout=10s"
"_netdev"
];
};
system.stateVersion = "26.05"; # set at install time; do NOT bump on upgrades
}
+37
View File
@@ -0,0 +1,37 @@
{ ... }:
# Declarative OS-disk layout (disko). UEFI: GPT with an ESP + ext4 root,
# same pattern as jupiter/terra (confirmed UEFI-capable, not the legacy-BIOS
# guess this scaffold started with).
#
# ⚠️ This disk is WIPED on install. Set `device` below to the real OS disk
# ONLY (by-id) — `ls -l /dev/disk/by-id` once you have console access.
{
disko.devices.disk.os = {
type = "disk";
device = "/dev/disk/by-id/ata-Samsung_SSD_840_EVO_120GB_S1D5NSAFB10834Z";
content = {
type = "gpt";
partitions = {
ESP = {
size = "512M";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}
+18
View File
@@ -0,0 +1,18 @@
# Do not modify this file! It was generated by nixos-generate-config
# and may be overwritten by future invocations. Please make changes
# to /etc/nixos/configuration.nix instead.
{ config, lib, pkgs, modulesPath, ... }:
{
imports =
[ (modulesPath + "/installer/scan/not-detected.nix")
];
boot.initrd.availableKernelModules = [ "xhci_pci" "ehci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" ];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
}
+146
View File
@@ -0,0 +1,146 @@
{ 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" ];
};
}
+38
View File
@@ -0,0 +1,38 @@
{ config, ... }:
# sops-nix wiring for mars. Encrypted values in ../../secrets/mars.yaml,
# decrypted with mars's own SSH host key (recipient in ../../.sops.yaml).
# The host key is pre-generated on the laptop and shipped at install
# (nixos-anywhere --extra-files -> /etc/ssh/ssh_host_ed25519_key).
{
sops.defaultSopsFile = ../../secrets/mars.yaml;
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
sops.secrets.darman_password.neededForUsers = true;
users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path;
sops.secrets.tailscale_authkey = { };
# Credentials file for the //jupiter/data cifs mount (see configuration.nix).
# Same value as jupiter's own samba_password (services/network/samba.nix) —
# mars authenticates as the same smb user, mirroring terra's setup.
sops.secrets.samba_password = { };
sops.templates."jupiter-smb.credentials".content = ''
username=darman
password=${config.sops.placeholder.samba_password}
'';
# Hermes Agent (hermes-agent.nix) — moved here from jupiter (see that
# host's git history); same Telegram bot token, opencode key, and
# Authentik OIDC client secret, so no new bot/app to provision.
sops.secrets.opencode_go_api_key = { };
sops.secrets.telegram_bot_token = { };
sops.secrets.hermes_dashboard_oidc_client_secret = { };
sops.templates."hermes-agent.env".content = ''
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
TELEGRAM_HOME_CHANNEL=15151223
TELEGRAM_ALLOWED_USERS=15151223
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
'';
}
+7
View File
@@ -110,6 +110,13 @@
reverse_proxy http://jupiter.orbit.sol:2283
'';
# ---- Hermes dashboard ----
# Authentik-gated (hosts/mars/hermes-agent.nix has the OIDC config and the
# "create the Authentik app" instructions — moved here from jupiter).
services.caddy.virtualHosts."hermes.mgaction.town".extraConfig = ''
reverse_proxy http://mars.orbit.sol:9119
'';
# ---- Gitea WebUI ----
# Gitea's web UI and HTTPS clones (services/dev/gitea.nix, HTTP_PORT 3000).
# Its SSH side is the separate :2222 forward further down.