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:
@@ -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
|
||||
}
|
||||
@@ -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 = "/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" ];
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user