docs: condense comments across the repo

Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
This commit is contained in:
2026-09-18 21:36:30 +02:00
co-authored by Claude Sonnet 5
parent 3899290c5b
commit 6f24ab69ad
47 changed files with 1051 additions and 1965 deletions
+6 -8
View File
@@ -54,10 +54,9 @@
environment.systemPackages = with pkgs; [ git btop tmux curl wget zsh-powerlevel10k lsd jq ]; environment.systemPackages = with pkgs; [ git btop tmux curl wget zsh-powerlevel10k lsd jq ];
# ---- home-manager (user-level config for darman, all hosts) ---- # ---- home-manager (user-level config for darman, all hosts) ----
# Requires home-manager.nixosModules.home-manager in the host's own # Only sets values for options declared by home-manager.nixosModules.home-manager;
# `modules` list (flake.nix) — this only sets values for options that # it doesn't import that module, so every nixosSystem using common.nix must
# module declares, it doesn't import it, so every nixosSystem using # also list it in flake.nix's `modules`.
# common.nix needs that line too (mirrors terra's original setup).
home-manager.useGlobalPkgs = true; home-manager.useGlobalPkgs = true;
home-manager.useUserPackages = true; home-manager.useUserPackages = true;
# Protects activation if a plain (non-symlink) ~/.zshrc etc. already # Protects activation if a plain (non-symlink) ~/.zshrc etc. already
@@ -91,10 +90,9 @@
boot.loader.systemd-boot.configurationLimit = 5; boot.loader.systemd-boot.configurationLimit = 5;
boot.loader.generic-extlinux-compatible.configurationLimit = 5; boot.loader.generic-extlinux-compatible.configurationLimit = 5;
# Stock journald defaults to ~10% of the filesystem (up to 4G) before it # Stock journald grows unbounded up to ~10% of the filesystem (4G cap, no
# rotates — no scheduled vacuum, just a ceiling it grows into. On jupiter's # scheduled vacuum) — on jupiter's 29G eMMC that's ~2.9G it could silently
# 29G eMMC that's ~2.9G it could silently accumulate. Cap it well below that # fill. Cap it well below that everywhere.
# everywhere instead of only noticing when a disk fills up again.
services.journald.extraConfig = '' services.journald.extraConfig = ''
SystemMaxUse=200M SystemMaxUse=200M
''; '';
+88 -152
View File
@@ -31,39 +31,32 @@
url = "github:strangeglyph/mediamanager-nix"; url = "github:strangeglyph/mediamanager-nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# livesync-bridge — headless CouchDB <-> filesystem sync for Obsidian # Headless CouchDB<->filesystem sync for Obsidian LiveSync
# LiveSync, used on mars to give luna a real directory of markdown # (hosts/mars/livesync-bridge.nix); not a flake/not in nixpkgs, so plain
# (hosts/mars/livesync-bridge.nix). Not a flake and not in nixpkgs, so it # source pinned by flake.lock. Pin carefully — it's a small third-party
# comes in as plain source pinned by flake.lock; the service copies it out # project with open storage->couchdb bugs, so an unreviewed bump could
# and runs it under deno. Pinning matters more than usual here — this is a # silently change how notes get written back.
# small third-party project with open bugs on the storage->couchdb path,
# so an unreviewed bump could quietly change how the agent's notes are
# written back.
livesync-bridge = { livesync-bridge = {
url = "github:vrtmrz/livesync-bridge"; url = "github:vrtmrz/livesync-bridge";
flake = false; flake = false;
}; };
authentik-nix.url = "github:nix-community/authentik-nix"; authentik-nix.url = "github:nix-community/authentik-nix";
nix-flatpak.url = "github:gmodena/nix-flatpak"; nix-flatpak.url = "github:gmodena/nix-flatpak";
# Own Hyprland plugin (border + title bar), public repo, fetched over # Own Hyprland plugin (border + title bar), public repo over https.
# https (no credentials needed, unlike tome below). `nixpkgs.follows` is # `nixpkgs.follows` is required since Hyprland plugins are ABI-locked to
# what makes its packaged build ABI-correct — Hyprland plugins are # the exact Hyprland build — it must share this flake's nixpkgs, not
# ABI-locked to the exact Hyprland build they load into, so it has to be # whatever hypr-chrome's own lock pins standalone.
# built against THIS flake's own nixpkgs, not whatever hypr-chrome's own
# flake.lock happens to pin standalone.
hypr-chrome = { hypr-chrome = {
url = "git+https://git.mgaction.town/darman/hypr-chrome.git"; url = "git+https://git.mgaction.town/darman/hypr-chrome.git";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app. # Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app.
# Private repo on our own gitea; fetched over ssh with darman's ambient key, # Private repo on our own gitea, fetched over ssh with darman's ambient
# same as any other git flake input. `flake = false`: it's a plain source # key; plain source tree (`flake = false`), see pkgs/tome.nix.
# tree, not itself a flake. See pkgs/tome.nix.
# #
# NOTE: the credential-less installer-iso can't fetch this (git+ssh needs # NOTE: the credential-less installer-iso can't fetch this, so
# darman's key), so `./scripts/deploy install terra localhost` will fail # `./scripts/deploy install terra localhost` fails at nixos-install
# at nixos-install (post-disko) while this input is present. Known # (post-disko) while this input is present — a known tradeoff.
# tradeoff — re-removed this once before (4f79ec7) for the same reason.
tome = { tome = {
url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git"; url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git";
flake = false; flake = false;
@@ -136,10 +129,9 @@
]; ];
}; };
# mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP. Boots from an SD image: # mercury — Raspberry Pi 3B+ (aarch64), DNS/DHCP; SD image via:
# nix build .#nixosConfigurations.mercury.config.system.build.sdImage # nix build .#nixosConfigurations.mercury.config.system.build.sdImage
# (aarch64 build — needs binfmt/qemu on this x86 host, or a remote/aarch64 # Needs binfmt/qemu for the aarch64 build on this x86 host (or a remote aarch64 builder).
# builder; substitutes most paths from cache.nixos.org.)
mercury = nixpkgs.lib.nixosSystem { mercury = nixpkgs.lib.nixosSystem {
system = "aarch64-linux"; system = "aarch64-linux";
specialArgs = { inherit inputs; }; specialArgs = { inherit inputs; };
@@ -209,16 +201,12 @@
]; ];
}; };
# Bootable USB recovery installer with our SSH key + sshd + DHCP. Clones # Bootable USB recovery installer with our SSH key + sshd + DHCP; clones
# the (now public) homelab repo fresh at every boot to /root/homelab # the public homelab repo fresh at every boot to /root/homelab, so the
# always current master, so the same USB stick stays useful across # same stick stays current without a rebuild. Reusable for any host's
# install/rescue occasions without ever needing a rebuild. No # manual-USB install path.
# rsync/copy-the-repo-over step: boot it, ssh in, # Build: nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage,
# `cd /root/homelab && ./scripts/deploy install ...`. # dd to USB, boot the target, ssh in, ./scripts/deploy install ...
# Reusable for any host's manual-USB install path (jupiter, terra, ...).
# Build the ISO:
# nix build .#nixosConfigurations.installer-iso.config.system.build.isoImage
# dd it to a USB stick, boot the target from it, SSH in, ./deploy install.
installer-iso = nixpkgs.lib.nixosSystem { installer-iso = nixpkgs.lib.nixosSystem {
inherit system; inherit system;
modules = [ modules = [
@@ -233,34 +221,18 @@
console.keyMap = "de"; # matches common.nix's real hosts console.keyMap = "de"; # matches common.nix's real hosts
environment.systemPackages = [ pkgs.git ]; environment.systemPackages = [ pkgs.git ];
# findiso= is a SCRIPT-stage-1 feature (stage-1-init.sh) only. The # The systemd initrd (default since 26.05) has no findiso= path — only
# systemd initrd — the default since 26.05 — has no findiso path # the legacy script stage-1 does — so this install method needs it off.
# at all: it mounts /iso straight from
# /dev/disk/by-label/<volumeID> (iso-image.nix), which only exists
# when the ISO is the physical boot medium. Booted as a kernel +
# initrd off the ESP with the iso as a plain file elsewhere, that
# label never appears and stage 1 times out into an emergency
# shell (mounts /sysroot fine, then fails /sysroot/nix/.ro-store).
# Script stage 1 instead loop-mounts the file findiso= points at
# and symlinks it to /dev/root — which is the whole mechanism this
# install path relies on. So force it off here.
boot.initrd.systemd.enable = false; boot.initrd.systemd.enable = false;
# installation-cd-minimal leaves experimental-features unset, so # installation-cd-minimal ships with experimental-features unset;
# the ISO's nix.conf has no `nix-command`/`flakes` at all (unlike # without this, both `nix run .#disko` and `nixos-install --flake`
# the nixos-images kexec installer, which sets # die with "experimental Nix feature 'nix-command' is disabled".
# extra-experimental-features itself — which is why the same
# `install <config> localhost` branch works after kexec-local but
# not here). Without this, both `nix run .#disko` and
# `nixos-install --flake` die with "experimental Nix feature
# 'nix-command' is disabled".
nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Fresh clone of a PUBLIC repo no credentials baked into the # Fresh clone of the public repo (no credentials baked in) so
# ISO. require_tracked() in scripts/deploy still works fine here # scripts/deploy's require_tracked() sees a real checkout; retry
# (this IS a real git checkout, unlike the old baked-`self` # with `systemctl restart homelab-checkout` if DHCP wasn't up yet.
# approach), but retry manually with `systemctl restart
# homelab-checkout` if DHCP was still coming up at boot.
systemd.services.homelab-checkout = { systemd.services.homelab-checkout = {
description = "Clone the homelab repo to /root/homelab"; description = "Clone the homelab repo to /root/homelab";
after = [ "network-online.target" ]; after = [ "network-online.target" ];
@@ -277,34 +249,24 @@
''; '';
}; };
# Finishes a local_install_prepare_and_reboot() run (scripts/deploy) # Completes an unattended local_install_prepare_and_reboot() run:
# unattended: that function stages this ISO, points a systemd-boot # re-runs `./scripts/deploy install <config> localhost`, now genuinely
# one-shot entry at it with `homelab.install=<config>` on the kernel # inside the installer so it takes the disko+nixos-install branch.
# cmdline, and reboots. Once booted here, this re-runs the exact same # No-op if homelab.install= isn't on the kernel cmdline.
# `./scripts/deploy install <config> localhost` command — now genuinely
# inside the installer (hostname homelab-installer), so is_live_installer
# takes the disko+nixos-install branch instead of preparing again.
# A manual boot of this ISO with no such cmdline param is a no-op.
systemd.services.homelab-auto-install = { systemd.services.homelab-auto-install = {
description = "Auto-run the homelab install if homelab.install= was passed on the kernel cmdline"; description = "Auto-run the homelab install if homelab.install= was passed on the kernel cmdline";
after = [ "homelab-checkout.service" ]; after = [ "homelab-checkout.service" ];
requires = [ "homelab-checkout.service" ]; requires = [ "homelab-checkout.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
# Full system PATH, not the restricted default a `path = [...]` # Needs the full system PATH: scripts/deploy execs bash then shells
# produces: this unit execs `./scripts/deploy`, whose # out to nix/nixos-install/git/sudo/efibootmgr, none of which a
# `#!/usr/bin/env bash` needs bash, and which then reaches for # restricted `path = [...]` PATH provides. mkForce overrides NixOS's
# nix / nixos-install / git / sudo / efibootmgr. The default # default PATH derivation from `path`.
# service PATH gave "env: 'bash': No such file or directory"
# (status 127) before the script even started.
# /run/current-system/sw/bin carries all of it on the installer;
# /run/wrappers/bin for sudo. mkForce because NixOS otherwise
# derives environment.PATH from `path` and that line would win.
# #
# HOME too: systemd sets no $HOME for a service without User= # HOME too: systemd sets no $HOME without User= (SetLoginEnvironment=
# (systemd.exec(5): SetLoginEnvironment= defaults false), and # defaults false), and scripts/deploy runs under `set -u`, so a
# scripts/deploy runs under `set -u`, so a bare $HOME aborted the # missing $HOME aborted with a confusing "unbound variable".
# whole run with an "unbound variable" that read like a bug.
environment = { environment = {
HOME = "/root"; HOME = "/root";
PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin"; PATH = lib.mkForce "/run/current-system/sw/bin:/run/wrappers/bin";
@@ -316,17 +278,11 @@
exit 0 exit 0
fi fi
# Persist this whole run to a file that OUTLIVES the install. # Persist this run to a file that outlives the install: the journal
# The systemd journal is on the installer's tmpfs and dies with # dies with the reboot and disko wipes the OS disk before a failure
# the reboot, and by the time anything interesting fails disko # can be read back. homelab.logpart= points at the staging partition
# has already wiped the OS disk so a failed attempt used to # (survives the wipe); every step here is best-effort so logging
# leave nothing to debug. local_install_prepare_and_reboot() # itself can't break an install.
# (scripts/deploy) passes the STAGING partition's PARTUUID as
# homelab.logpart=; that partition holds the iso and is on a
# different disk from the one disko wipes, so it survives. The
# actual install runs inside do_install() below so one tee at
# the end captures all of it. Every step here is best-effort:
# logging must never be the thing that breaks an install.
logfile="" logfile=""
logpart=$(grep -o 'homelab\.logpart=[^ ]*' /proc/cmdline | cut -d= -f2 || true) logpart=$(grep -o 'homelab\.logpart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -n "$logpart" ]; then if [ -n "$logpart" ]; then
@@ -336,9 +292,8 @@
if mount -o rw "$dev" /run/homelab-log 2>/dev/null; then if mount -o rw "$dev" /run/homelab-log 2>/dev/null; then
logdir=/run/homelab-log logdir=/run/homelab-log
elif where=$(findmnt -fno TARGET "$dev" 2>/dev/null) && [ -n "$where" ]; then elif where=$(findmnt -fno TARGET "$dev" 2>/dev/null) && [ -n "$where" ]; then
# stage-1's findiso already holds this partition mounted # stage-1's findiso already has this partition mounted (how it
# (that is how it reached the iso) write into the existing # reached the iso) reuse that mount instead of a second one.
# mount rather than trying to stack a second one on it.
mount -o remount,rw "$where" 2>/dev/null || true mount -o remount,rw "$where" 2>/dev/null || true
logdir="$where" logdir="$where"
fi fi
@@ -358,12 +313,10 @@
fi fi
do_install() { do_install() {
# The host key scripts/deploy seeds /etc/ssh with (so sops can # The host key (so sops can decrypt on first boot) can't live in
# decrypt on boot #1) cannot live in this ISO: it is built from # this public-repo ISO; local_install_prepare_and_reboot() drops it
# a PUBLIC repo and the private keys are deliberately off-repo. # on the boot partition instead and passes that PARTUUID here the
# local_install_prepare_and_reboot() therefore drops it on the # copy dies with disko's wipe minutes later.
# boot partition and passes that partition's PARTUUID here.
# That copy dies with the disko wipe a few minutes later.
keypart=$(grep -o 'homelab\.keypart=[^ ]*' /proc/cmdline | cut -d= -f2 || true) keypart=$(grep -o 'homelab\.keypart=[^ ]*' /proc/cmdline | cut -d= -f2 || true)
if [ -n "$keypart" ]; then if [ -n "$keypart" ]; then
mkdir -p /run/homelab-key mkdir -p /run/homelab-key
@@ -384,12 +337,10 @@
fi fi
fi fi
# On a box whose old bootloader had no one-shot (Limine on # On bootloaders with no one-shot (Limine on terra), scripts/deploy
# terra), scripts/deploy got us here via a temporary UEFI # got here via a temporary UEFI entry + BootNext (arm_efi_bootnext);
# entry + BootNext (arm_efi_bootnext). BootNext is already # BootNext is spent but the entry would linger pointing at a
# spent, but the entry itself would linger in NVRAM pointing # partition disko is about to wipe, so remove it now.
# at a partition disko is about to reformat. Drop it now, so
# even an install that fails later leaves NVRAM clean.
for n in $(efibootmgr 2>/dev/null \ for n in $(efibootmgr 2>/dev/null \
| sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\?[[:space:]]Homelab Installer[[:space:]].*/\1/p'); do | sed -n 's/^Boot\([0-9A-Fa-f]\{4\}\)\*\?[[:space:]]Homelab Installer[[:space:]].*/\1/p'); do
echo "removing temporary UEFI entry Boot$n" echo "removing temporary UEFI entry Boot$n"
@@ -419,19 +370,11 @@
}; };
}; };
# VM test for `./scripts/deploy kexec-local`. Run: # VM test for `./scripts/deploy kexec-local` (nix build .#checks.x86_64-linux.kexec-local -L)
# nix build .#checks.x86_64-linux.kexec-local -L # — the one command that can't be rehearsed on real hardware since it jumps
# # the machine you're on. Regression-tests kexec-run.sh's backgrounded
# Worth having because kexec-local is the one command that cannot be # `sleep 6 && kexec -e`: cleaning up the staging dir on exit would delete
# rehearsed on real hardware: it jumps the machine you are typing at, and # the jump binary and the box would silently stay on the old kernel.
# a failure looks exactly like a slow boot. It regression-tests the
# subtle one — kexec-run.sh backgrounds `sleep 6 && kexec -e` and returns,
# so anything that cleans up the staging dir on exit deletes the binary
# that performs the jump and the box silently never leaves the old kernel.
#
# After the jump the test driver's backdoor is gone with the old kernel,
# so the installer is driven over a forwarded ssh port instead (the same
# approach nixos-images uses in its own kexec test).
checks.${system} = { checks.${system} = {
kexec-local = kexec-local =
let let
@@ -489,9 +432,9 @@
machine.succeed("install -Dm755 /etc/deploy /root/deploy") machine.succeed("install -Dm755 /etc/deploy /root/deploy")
# systemd-run starts units with a bare PATH that lacks # systemd-run starts units with a bare PATH lacking
# /run/current-system/sw/bin, so `#!/usr/bin/env bash` cannot even # /run/current-system/sw/bin, so bash (and tar/findmnt/nohup)
# resolve bash, let alone tar/findmnt/nohup. Set it explicitly. # can't resolve set it explicitly.
env = ( env = (
" --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin" " --setenv=PATH=/run/wrappers/bin:/run/current-system/sw/bin"
" --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz" " --setenv=HOMELAB_KEXEC_TARBALL=${tarball}/nixos-kexec-installer-${system}.tar.gz"
@@ -513,9 +456,9 @@
while ssh(["true"], check=False).returncode != 0: while ssh(["true"], check=False).returncode != 0:
time.sleep(1) time.sleep(1)
# Refuses without --yes when stdin is not a tty (read gets EOF). # Refuses without --yes when stdin isn't a tty; needs the same env to
# Must reach the confirmation prompt, so it needs the same env # reach the confirmation prompt, else it dies early on the nix build
# otherwise it just dies early on the nix build and proves nothing. # and proves nothing.
out = machine.fail(f"{envsh} /root/deploy kexec-local </dev/null 2>&1") out = machine.fail(f"{envsh} /root/deploy kexec-local </dev/null 2>&1")
assert "using prebuilt kexec installer" in out, \ assert "using prebuilt kexec installer" in out, \
f"never reached the prompt, so the refusal proves nothing:\n{out}" f"never reached the prompt, so the refusal proves nothing:\n{out}"
@@ -575,27 +518,23 @@
# `nix develop` — hot-reload loop for dotfiles/quickshell. # `nix develop` — hot-reload loop for dotfiles/quickshell.
# #
# hosts/terra/home.nix ships the shell via `xdg.configFile."quickshell"`, # hosts/terra/home.nix ships the shell as a store copy (`xdg.configFile`),
# which COPIES the tree into the store, so ~/.config/quickshell is a # which only hot-reloads its own frozen files; pointing at the working
# read-only symlink into /nix/store and every QML tweak costs a # tree with `qs -p` restores edit-save-see without a rebuild.
# nixos-rebuild. quickshell DOES hot-reload on file save — but only for
# the files it is watching, which are those frozen store copies. Pointing
# it at the working tree with `qs -p` restores edit-save-see, no rebuild.
# #
# quickshell keys instance identity on the CONFIG PATH, so a working-tree # quickshell keys instance identity on the config path, so the
# instance and the store-backed one are two different instances that would # working-tree and store-backed shells are different instances that
# both map layer-shell bars onto every output. Hence a swap, not a second # would both claim every output — hence a swap, not a second instance.
# instance — and the swap starts dev FIRST, killing the packaged shell # The swap starts dev first and only kills the packaged shell once dev
# only once dev is confirmed up, so a QML error in the working tree leaves # is confirmed up, so a QML error leaves you on your normal bar.
# you on your normal bar instead of no bar at all.
# #
# Every kill is scoped to one config (`qs kill` = default only, `qs kill # Every kill is scoped to one config (`qs kill` = default, `qs kill -p
# -p` = that path only). A blanket kill would also take out unrelated # <path>` = that path) since a blanket kill would also take out
# quickshell instances pkgs/rishot.nix is one. # unrelated instances like pkgs/rishot.nix.
# #
# Deliberately NOT wired to direnv (no .envrc in this repo): programs.direnv # Deliberately not wired to direnv: programs.direnv is enabled for this
# is enabled for this user, so a `use flake` would swap the running desktop # user, so a `use flake` would swap the desktop shell on every `cd`
# shell on every `cd` into the checkout, including over ssh. # into the checkout, including over ssh.
devShells.${system}.default = devShells.${system}.default =
let let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
@@ -652,10 +591,9 @@
echo "qs-dev: live on $cfg edits there now hot-reload" echo "qs-dev: live on $cfg edits there now hot-reload"
''; '';
# qs log -f prints everything the instance logs; WARN and ERROR are the # qs log -f prints everything the instance logs; WARN/ERROR are what
# two that mean something is wrong with the QML in front of you. A # mean something is actually wrong with the QML (a binding loop or
# binding loop or a failed binding is a WARN and easy to miss when it # failed binding is a WARN, easy to miss in the reload chatter).
# scrolls past inside a reload's worth of chatter.
qs-log = pkgs.writeShellScriptBin "qs-log" '' qs-log = pkgs.writeShellScriptBin "qs-log" ''
set -uo pipefail set -uo pipefail
${preamble} ${preamble}
@@ -665,12 +603,10 @@
-a|--all) filter='.' ;; -a|--all) filter='.' ;;
esac esac
# -t 1: `qs log -f` replays the whole backlog first, which would dump # -t 1: `qs log -f` otherwise replays the whole backlog on shell entry.
# every historical warning into the terminal on shell entry. # It also ends when the attached instance exits, and the dev shell
# # outlives individual instances (a QML error kills one, qs-dev starts
# `qs log -f` ends when the instance it attached to exits, and the dev # another) so re-attach in a loop instead of going quiet for the session.
# shell outlives individual instances a QML error kills one, `qs-dev`
# starts another. Re-attach instead of going quiet for the session.
while :; do while :; do
if running "$cfg"; then if running "$cfg"; then
${qs} log -p "$cfg" -t 1 -f 2>/dev/null | ${grep} --line-buffered -E "$filter" >&2 ${qs} log -p "$cfg" -t 1 -f 2>/dev/null | ${grep} --line-buffered -E "$filter" >&2
+4 -5
View File
@@ -8,10 +8,9 @@
programs.home-manager.enable = true; programs.home-manager.enable = true;
# Matches terra's baseline (compinit, deduped/shared history, HISTFILE # Matches terra's baseline (compinit, deduped/shared history, HISTFILE
# under $HOME). home-manager owns ~/.zshrc + ~/.zshenv as real files, which # under $HOME). home-manager owning ~/.zshrc + ~/.zshenv as real files also
# also means zsh's built-in zsh-newuser-install wizard never fires on # means zsh's newuser-install wizard never fires (it only triggers when
# first interactive login (it only triggers when none of # none of those dotfiles exist) — previously an issue on every host except
# .zshenv/.zprofile/.zshrc/.zlogin exist) — that used to happen on every # terra.
# host except terra.
programs.zsh.enable = true; programs.zsh.enable = true;
} }
+37 -85
View File
@@ -40,16 +40,11 @@
# systemd-boot for UEFI. If ZimaBlade boots legacy/BIOS, switch to grub. # systemd-boot for UEFI. If ZimaBlade boots legacy/BIOS, switch to grub.
boot.loader.systemd-boot.enable = true; boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true; boot.loader.efi.canTouchEfiVariables = true;
# common.nix's cap of 5 comes from this box's own 34-generation incident, # common.nix's default of 5 is still too many boot entries for a 29G eMMC — override down to 2.
# but at ~5G free on a 29G eMMC even 5 is too many — override down to 2.
boot.loader.systemd-boot.configurationLimit = lib.mkForce 2; boot.loader.systemd-boot.configurationLimit = lib.mkForce 2;
# A `switch` pins the old generation as a GC root until the box reboots onto # A `switch` pins the old generation as a GC root until reboot; common.nix's weekly
# the new one (booted-system vs current-system) — common.nix's nix.gc is # nix.gc is too slow for a 29G eMMC, so collect garbage on every boot instead.
# weekly, far too slow to catch that on a 29G eMMC. 2026-08-19: one switch
# alone took 14G -> 19G used; only reboot (releases the old root) + this GC
# brought it back to 14G. Run a full collect right after every boot instead
# of waiting on the weekly timer.
systemd.services.gc-on-boot = { systemd.services.gc-on-boot = {
description = "Full nix-collect-garbage on every boot"; description = "Full nix-collect-garbage on every boot";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -70,46 +65,24 @@
boot.kernelParams = [ "reboot=pci" ]; boot.kernelParams = [ "reboot=pci" ];
# ---- GPU (jellyfin hardware transcoding) ---- # ---- GPU (jellyfin hardware transcoding) ----
# Apollo Lake N3450 / HD Graphics 500 (Gen9, pci 8086:5A85). The i915 KERNEL # Apollo Lake N3450 / HD Graphics 500 (Gen9). i915 binds on its own, but VAAPI needs
# driver binds on its own — /dev/dri/{card1,renderD128} exist without this — # the iHD userspace driver (Gen9; i965 is Gen8-only) or jellyfin-ffmpeg exits 251 on
# but the libva USERSPACE driver only ships when hardware.graphics is on, and # every transcode with no clearer error than "FFmpeg exited with code 251" in the log.
# nothing else here pulled it in. Without it VAAPI init fails with "unknown
# libva error" and jellyfin-ffmpeg exits 251 on EVERY transcode, which the
# client shows as generic playback failure: the server log only says "FFmpeg
# exited with code 251", never that a driver is missing. Verified on the box:
# the same h264_vaapi encode goes 251 -> 0 once iHD is on LIBVA_DRIVERS_PATH.
#
# iHD (intel-media-driver) is the right one for Gen9; i965 is for Gen8 and
# older. Note the render node is 0666 but card1 is 0660 root:video, so the
# group membership in services/media/jellyfin.nix matters for the card node.
hardware.graphics = { hardware.graphics = {
enable = true; enable = true;
extraPackages = [ pkgs.intel-media-driver ]; extraPackages = [ pkgs.intel-media-driver ];
}; };
# ⚠️ This buys VAAPI only — jellyfin must be set to VAAPI, NOT QSV, in its # ⚠️ Use VAAPI, not QSV, in jellyfin's UI — QSV needs an MFX runtime not safely
# web UI (Dashboard -> Playback -> Transcoding). QSV needs an MFX runtime on # available for this Gen9 chip (only insecure/EOL options) and fails with exit 171.
# top of the libva driver: ffmpeg's `-init_hw_device qsv=qs@va` dies with # 4K HDR remuxes also can't be tone-mapped here (needs OpenCL or Gen11+); keep those
# "Error creating a MFX session: -9" -> exit 171, the SECOND failure hiding # as 1080p SDR or let them direct-play.
# behind the first (fixing the missing driver only moved 251 -> 171).
# There is no good way to provide it here: vpl-gpu-rt is Gen12+, and the
# Gen9 runtime `intel-media-sdk` is marked INSECURE in nixpkgs (EOL, 5 CVEs
# incl. local privilege escalation) — not worth it when VAAPI does the same
# job on this chip at ~3.5x realtime for 1080p->720p.
#
# Also: 4K HDR (the 2160p HEVC/DV remuxes) can NOT be tone-mapped here.
# tonemap_opencl needs OpenCL, which has no platform on this box, and
# tonemap_vaapi is Gen11+ — both fail. Only a plain scale_vaapi=format=nv12
# succeeds, which drops HDR without tone-mapping (washed-out picture).
# Those files need to direct-play, or be kept as 1080p SDR versions.
# ---- NAS data array ---- # ---- NAS data array ----
# Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T). # Existing ext4 on mdadm RAID0 (sda+sdb, md0, 29.1T) — mounted, not formatted, kept
# Mounted, NOT formatted; kept out of disko so it is never wiped. # out of disko. ⚠️ RAID0 has no redundancy: either disk failing loses ALL data.
# ⚠️ RAID0 = no redundancy: either 16TB disk failing loses ALL data. boot.swraid.enable = true;
boot.swraid.enable = true; # assemble the mdadm array at boot # Silences the "mdmon service will crash" eval warning — mdmon never actually runs
# Silences "mdmon service will crash" eval warning. RAID0 here uses native # here (native superblocks, not external-metadata) but the module warns regardless.
# superblocks so mdmon (external-metadata arrays only) never actually runs,
# but the module warns unconditionally without SOME MAILADDR/PROGRAM set.
boot.swraid.mdadmConf = "MAILADDR root"; boot.swraid.mdadmConf = "MAILADDR root";
fileSystems."/mnt/data" = { fileSystems."/mnt/data" = {
# fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0. # fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0.
@@ -118,69 +91,48 @@
options = [ "nofail" ]; # don't block boot if the array is degraded/absent options = [ "nofail" ]; # don't block boot if the array is degraded/absent
}; };
# `nofail` above is necessary but NOT sufficient — any mount layered on the # `nofail` alone isn't enough — mounts layered on the array (prowlarr/seerr binds)
# array (prowlarr/seerr binds) is RequiredBy local-fs.target and will fail it # are RequiredBy local-fs.target and can still trip Emergency Mode, which is a dead
# regardless, and emergency mode on this box is a dead end: root is locked, so # end here (root locked, no ssh). Boot as far as possible instead; the array-backed
# sulogin drops you at a prompt you cannot answer, with no ssh. 2026-08-06: a # services carry RequiresMountsFor=/mnt/data so they still won't write to the eMMC.
# drive that failed to enumerate after the rack move did exactly this —
# "Timed out waiting for device /dev/disk/by-uuid/dadbff6f-…" -> Dependency
# failed for Local File Systems -> Reached target Emergency Mode, twice.
# Boot as far as possible instead and leave the failed units to be read over
# ssh. The array-backed services carry RequiresMountsFor=/mnt/data so they
# still refuse to start rather than writing to the eMMC.
systemd.enableEmergencyMode = false; systemd.enableEmergencyMode = false;
# ---- Heavy state moved off the eMMC ---- # ---- Heavy state moved off the eMMC ----
# A deploy holds TWO full closures (~9G each) on a 29G disk at once, so the # A deploy holds two full closures (~9G each) on this 29G disk at once, so state
# OS disk has no room for state that grows on its own. 2026-08-09: it hit 0 # that grows on its own can't live there — moved under /mnt/data/AppData like every
# bytes free with both gen 39 and gen 40 resident, and postgres died on # other service's state. Settings below are jupiter-only; services/containers.nix
# "No space left on device" — note ext4 reserves 5% for root, so non-root # stays engine/host-agnostic (mercury runs podman with no array).
# services see zero while df still shows ~300M free.
#
# Paths live under /mnt/data/AppData like every other service's state. Both
# settings below are jupiter-only on purpose: services/containers.nix stays
# engine- and host-agnostic (mercury runs pihole on podman with no array).
# podman: CI images dominate and keep growing — the gitea runner's # runroot stays on /run (per-boot tmpfs, doesn't grow); graphroot moves to the array
# act-latest is 1.7G, and the act-22.04 label in services/dev/gitea.nix # since the gitea runner's CI images alone run several GB.
# pulls another ~1.7G the first time a job requests it.
# runroot stays on /run: it is per-boot tmpfs state, not a growing store.
virtualisation.containers.storage.settings.storage = { virtualisation.containers.storage.settings.storage = {
driver = "overlay"; driver = "overlay";
graphroot = "/mnt/data/AppData/containers/storage"; graphroot = "/mnt/data/AppData/containers/storage";
runroot = "/run/containers/storage"; runroot = "/run/containers/storage";
}; };
# immich's postgres cluster. Version component mirrors the upstream default # immich's postgres cluster. Version-qualified path (matches upstream default) so a
# (`/var/lib/postgresql/${psqlSchema}`) so a major bump gets its own dir # major bump gets a fresh dir instead of reusing the old cluster's files.
# instead of silently reusing the old cluster's files. # ⚠️ Puts the DB in the same RAID0 failure domain as the photos it indexes —
# ⚠️ This puts the DB in the SAME failure domain as the photos it indexes: # deliberate (the two are useless apart) but neither is backed up.
# /mnt/data is RAID0, so either 16TB disk now loses both, where before an
# eMMC failure and an array failure each took only one. Chosen deliberately
# — the two are useless apart — but neither is backed up.
services.postgresql.dataDir = services.postgresql.dataDir =
"/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}"; "/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}";
# /mnt/data/AppData is drwx--x--- darman:users, so postgres needs group # /mnt/data/AppData is drwx--x--- darman:users, so postgres needs the "users" group
# "users" just to TRAVERSE into its own dataDir — exactly the reason immich # just to traverse into its dataDir (same reason immich needs it) — postgres itself
# has the same line. The cluster dir itself keeps the mode it was initdb'd # refuses to start unless the cluster dir is 0700 or 0750.
# with (0750 postgres:postgres) — postgres only accepts 0700, or 0750 when
# the cluster was created with group access, and refuses to start otherwise.
users.users.postgres.extraGroups = [ "users" ]; users.users.postgres.extraGroups = [ "users" ];
# Neither path is under /var/lib, so no module creates it: the postgresql # Neither path is under /var/lib, so no module creates it automatically — same
# module's own tmpfiles entry only adjusts a dataDir that already exists, # reason immich needs its own mediaLocation tmpfiles rule.
# the same way immich's mediaLocation rule does.
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt/data/AppData/postgresql 0750 postgres postgres -" "d /mnt/data/AppData/postgresql 0750 postgres postgres -"
"d /mnt/data/AppData/containers 0700 root root -" "d /mnt/data/AppData/containers 0700 root root -"
]; ];
# graphroot is not a systemd path dependency the way dataDir is, so nothing # Without this, podman would recreate an empty store on the eMMC if the array mounts
# derives a mount ordering from it. Without these, podman would recreate an # late or is absent, and the runner would re-pull every image.
# empty store on the eMMC under the mountpoint when the array is late or # (podman-clonarr already sets this in services/media/clonarr.nix.)
# absent, and the runner would re-pull every image into it.
# (podman-clonarr already carries this from services/media/clonarr.nix.)
systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
+20 -37
View File
@@ -1,14 +1,8 @@
{ config, ... }: { config, ... }:
# sops-nix secret wiring (real host only; not imported by vm.nix). # sops-nix secret wiring (real host only; not imported by vm.nix). Decrypts with the
# Encrypted values live in ../../secrets/jupiter.yaml, decrypted at activation to # host's own SSH host key (ssh-to-age), shipped once at install via nixos-anywhere
# /run/secrets/<name>. # --extra-files, so there's no separate sops-only key to manage.
#
# The host decrypts with its OWN SSH host key (age identity derived via
# ssh-to-age, recipient listed in ../../.sops.yaml). The key is pre-generated on
# the laptop and shipped once at install as /etc/ssh/ssh_host_ed25519_key
# (nixos-anywhere --extra-files) — so decryption works on boot #1 and there is
# no separate sops-only key to manage.
{ {
sops.defaultSopsFile = ../../secrets/jupiter.yaml; sops.defaultSopsFile = ../../secrets/jupiter.yaml;
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
@@ -26,18 +20,14 @@
# Headscale pre-auth key for tailscale auto-registration (see configuration.nix). # Headscale pre-auth key for tailscale auto-registration (see configuration.nix).
sops.secrets.tailscale_authkey = { }; sops.secrets.tailscale_authkey = { };
# Immich's OIDC client secret, from its Authentik application (a SEPARATE # Immich's OIDC client secret (separate Authentik app from headscale/headplane, see
# app from headscale's and headplane's — see hosts/neptun/secrets.nix). # hosts/neptun/secrets.nix). Resolved via systemd LoadCredential as root before
# Referenced as settings.oauth.clientSecret._secret in # privilege drop, so sops's default root:root 0400 is correct — do NOT set `owner`.
# services/media/immich.nix; the module resolves it through systemd
# LoadCredential, which reads as root before dropping privileges, so the
# sops default of root:root 0400 is correct — do NOT set `owner`.
sops.secrets.immich_oauth_client_secret = { }; sops.secrets.immich_oauth_client_secret = { };
# Gitea Actions runner registration token (services/dev/gitea.nix). Gitea # Gitea Actions runner registration token — gitea generates this itself once Actions
# generates this itself once Actions is enabled — it is not a password # is enabled. Rendered into an env file since gitea-actions-runner takes an
# chosen up front. Rendered into a `TOKEN=...` env file because # EnvironmentFile, not a raw secret path.
# gitea-actions-runner takes an EnvironmentFile, not a raw secret path.
sops.secrets.gitea_runner_token = { }; sops.secrets.gitea_runner_token = { };
sops.templates."gitea-runner.env".content = sops.templates."gitea-runner.env".content =
"TOKEN=${config.sops.placeholder.gitea_runner_token}"; "TOKEN=${config.sops.placeholder.gitea_runner_token}";
@@ -53,15 +43,11 @@
owner = "gitea"; owner = "gitea";
}; };
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) # SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) for
# migrated off the reused ini in services/media/sabnzbd.nix into # services/media/sabnzbd.nix; sabnzbd_api_key is shared with
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this # services/experimental/mediamanager.nix rather than duplicated.
# migration (provisioned for mediamanager's future use, services/experimental/ # owner = sabnzbd because the module's preStart runs as that user, and sops secrets
# mediamanager.nix — not currently imported by any host); reused here as the # default to root:root 0400.
# same single source of truth rather than duplicating it.
# owner = sabnzbd: the module's preStart (replace-secret) runs as the
# service's own User=/Group=, and sops secrets default to root:root 0400 —
# without this, replace-secret gets Permission denied reading /run/secrets.
sops.secrets.sabnzbd_web_username.owner = "sabnzbd"; sops.secrets.sabnzbd_web_username.owner = "sabnzbd";
sops.secrets.sabnzbd_web_password.owner = "sabnzbd"; sops.secrets.sabnzbd_web_password.owner = "sabnzbd";
sops.secrets.sabnzbd_api_key.owner = "sabnzbd"; sops.secrets.sabnzbd_api_key.owner = "sabnzbd";
@@ -69,15 +55,12 @@
sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd"; sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd";
sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd"; sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd";
# CouchDB admin account for Obsidian LiveSync # CouchDB admin account for Obsidian LiveSync — rendered into an [admins] ini
# (services/dev/obsidian-livesync.nix). Rendered into an [admins] ini # fragment instead of services.couchdb.adminPass, which would put the plaintext in
# fragment rather than passed as services.couchdb.adminPass, which would put # the world-readable store.
# the plaintext in the world-readable store. # owner = couchdb on both: couchdb re-reads the ini as its own user after privilege
# # drop, and without this sops's default root:root 0400 leaves it with no admin
# owner = couchdb on BOTH: couchdb re-reads its ini chain as its own # configured (every request 401s).
# User=/Group= after systemd drops privileges, and sops defaults to
# root:root 0400 — without this it comes up with no admin configured, which
# under require_valid_user means every request 401s.
sops.secrets.couchdb_admin_password.owner = "couchdb"; sops.secrets.couchdb_admin_password.owner = "couchdb";
sops.templates."couchdb-admins.ini" = { sops.templates."couchdb-admins.ini" = {
owner = "couchdb"; owner = "couchdb";
+9 -12
View File
@@ -25,13 +25,12 @@
boot.loader.systemd-boot.enable = true; boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true; boot.loader.efi.canTouchEfiVariables = true;
# jupiter's samba share (services/network/samba.nix) mounted on demand so # 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 # mars doesn't stall when jupiter is off — also where Hermes's shared
# also where Hermes's shared dropbox lives now (hermes-agent.nix). Modes are # dropbox lives (hermes-agent.nix). Tighter modes than terra's equivalent
# tighter than terra's equivalent mount (0770 not 0755, gid=hermes not # mount (0770/gid=hermes, not 0755/gid=users) since the hermes-agent
# gid=users) since the hermes-agent container (uid 986, gid 983 no podman # container (uid 986/gid 983, no podman userns remapping) needs group
# userns remapping, see services/network/pihole.nix) needs group write into # write here, not just darman.
# it, not just darman.
fileSystems."/mnt/jupiter" = { fileSystems."/mnt/jupiter" = {
device = "//jupiter/data"; device = "//jupiter/data";
fsType = "cifs"; fsType = "cifs";
@@ -43,11 +42,9 @@
"dir_mode=0770" "dir_mode=0770"
"nofail" "nofail"
"x-systemd.automount" # lazy-mount so boot doesn't stall if jupiter's down "x-systemd.automount" # lazy-mount so boot doesn't stall if jupiter's down
# NO idle-timeout here (unlike terra's equivalent mount): hermes-agent's # NO idle-timeout here (unlike terra's): podman-hermes-agent.service
# podman-hermes-agent.service RequiresMountsFor this path, so an idle # RequiresMountsFor this path, so an idle auto-unmount silently kills
# auto-unmount tears the container down with it — confirmed the hard # the container with it — confirmed the hard way (~60-70s per start).
# way, it killed the service ~60-70s after every start with no crash
# or error, just "Unmounting /mnt/jupiter" right before the stop.
"x-systemd.mount-timeout=10s" "x-systemd.mount-timeout=10s"
"_netdev" "_netdev"
]; ];
+159 -317
View File
@@ -1,83 +1,45 @@
{ config, pkgs, ... }: { config, pkgs, ... }:
# Hermes Agent — moved here from jupiter (hosts/jupiter/hermes-agent.nix, # Hermes Agent runs on mars, which has no big data array — state lives on the
# see its git history / b5fa599 / 713d91d for the terra->jupiter->mars # local OS disk, and the shared dropbox reaches jupiter's array as a CIFS
# lineage). mars is dedicated to this one service, on-site, with no big # client instead of being served locally.
# 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 # Runs the official docker.io/nousresearch/hermes-agent image (verified on
# real and actively maintained, contrary to what the checked-out repo's own # Docker Hub) as a plain podman container. It never sets HERMES_MANAGED, so
# README/docker-compose.yml suggested; verified directly on Docker Hub) as a # Hermes fully self-manages config.yaml, profiles, memories and skills.
# 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: # Security posture: reachable paths are only Hermes's own state dir, the
# - Reachable paths: its own local state dir, the small shared "dropbox" # shared dropbox, and git/tea as the PR-tier `luna` gitea account (see
# (via the jupiter samba mount) for darman to hand files to Hermes, and # services/dev/gitea.nix) — no working copy of this repo is provisioned, and
# `git`/`tea`, logged in as the `luna` gitea account (PR-tier only — # nothing else on jupiter's array or host is reachable if a command goes
# see services/dev/gitea.nix). No working copy of this repo is # wrong or gets injected via Telegram/tool output. It runs its own Telegram
# provisioned for her: an earlier version cloned one into # bot with an explicit TELEGRAM_ALLOWED_USERS, and as a rootful podman
# ${hermesHome}/workspace/homelab, dropped again because nothing ever # container under its own uid/gid (not darman's). git/tea access is direct
# told her at runtime where it was (she self-manages config/profiles/ # CLI rather than a wrapper; the real backstop is server-side gitea branch
# memories, so a host-side path in this file never reached her) — she # protection on `master` (only darman can push/merge/approve), not anything
# searched /opt/data/homelab and /workspace, found neither, and # client-side here.
# concluded she had no repo at all. She can clone one herself if she
# wants; the credentials below are what actually grants the access.
# Nothing else on jupiter's array or the host 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).
# - git/tea access is direct CLI, not a narrow wrapper: darman explicitly
# chose this over a purpose-built MCP server (tried first, scrapped —
# see git history) in favor of simplicity. The backstop is entirely
# server-side: gitea's branch protection on `master` (only darman can
# push/merge/approve there) is what actually keeps a bad or injected
# command from reaching the base branch, not anything client-side here.
# #
# Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik, same setup as on # Dashboard (HERMES_DASHBOARD=1) is gated behind Authentik like jupiter's; it
# jupiter. Its default bind (0.0.0.0:9119) fails closed without an auth # fails closed without a registered auth provider. Binds 0.0.0.0:9119 (not
# provider registered, and 0.0.0.0 (not loopback) is required so neptun's # loopback) so neptun's Caddy can reach it over tailscale0, but stays
# Caddy can reach it over tailscale0 — reachability itself stays LAN-closed # LAN-closed since there's no firewall rule opening it — reach it directly at
# (no networking.firewall.allowedTCPPorts entry; tailscale0 is already a # mars.orbit.sol:9119 or via the public hermes.mgaction.town vhost on neptun.
# trustedInterface, services/vpn/tailscale.nix). Public route: neptun's # Uses upstream's generic self-hosted OIDC plugin against the same Authentik
# hermes.mgaction.town vhost (hosts/neptun/configuration.nix) proxies to this # application (slug `hermes`) as before.
# over the tailnet. mars's own Caddy (luna-sites.nix) only serves luna's apps
# and has no vhost for this — 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 # Starts with a fresh state dir — jupiter's instance was already reset to
# application as before (slug `hermes`) — the client ID/secret didn't need # fresh on 2026-08-21, so nothing needed carrying forward. Its old data is
# to change since the public redirect URI (hermes.mgaction.town) didn't. # backed up at /mnt/data/AppData/hermes.bak-2026-08-21 if that's ever wrong.
#
# 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 let
stateDir = "/var/lib/hermes"; stateDir = "/var/lib/hermes";
hermesHome = "${stateDir}/.hermes"; hermesHome = "${stateDir}/.hermes";
# Shared drop-in folder: darman can put files here from any host. Lives on # Shared drop-in folder for darman to hand files to Hermes, on jupiter's
# jupiter's array (reachable at /mnt/jupiter, the samba mount below) rather # array (CIFS mount below) rather than locally. Mounted under /opt/data so
# than locally, so it's the same physical location it always was — only # it's inside Hermes's own write-safe root (HERMES_WRITE_SAFE_ROOT).
# 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"; dropboxDir = "/mnt/jupiter/AppData/hermes-dropbox";
# Pinned by digest (captured 2026-08-21 via `podman image inspect # Pinned by digest (captured 2026-08-21 from jupiter) rather than floating
# docker.io/nousresearch/hermes-agent:latest --format '{{.Digest}}'` on # :latest, so bumping Hermes is an explicit edit here, not silent drift.
# 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"; hermesImage = "docker.io/nousresearch/hermes-agent@sha256:5342e518734a08f6c66b89b4262434813c28a77abbc59c230c8f1637df71a259";
# Kept identical to jupiter's instance purely so nothing else needs to # Kept identical to jupiter's instance purely so nothing else needs to
@@ -90,15 +52,10 @@ let
# is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data. # is hers to make, anywhere inside HERMES_WRITE_SAFE_ROOT=/opt/data.
giteaHost = "git.mgaction.town"; giteaHost = "git.mgaction.town";
# luna's webhook filters, mounted READ-ONLY below. They live in the nix store # luna's webhook filters, mounted READ-ONLY from the nix store rather than
# rather than being written into hermesHome because hermesHome IS # written into hermesHome: that IS her write-safe root, so a writable copy
# HERMES_WRITE_SAFE_ROOT: a filter dropped there is a loop guard sitting # would let her edit her own loop guard back out. A missing script fails
# inside the writable root of the agent it constrains, and she could edit # closed (Hermes ignores it); read-only from the store rules out a rewrite.
# it back out. Deleting it would fail closed (Hermes treats a missing
# script as "ignore"), but rewriting it to always-allow would silently
# restore the reply loop. Read-only from the store makes that impossible
# and keeps the guard versioned in git — same reasoning as the git/tea
# binaries mounted below.
prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" ( prCommentFilter = pkgs.writeText "gitea-pr-comment-filter.py" (
builtins.readFile ./gitea-pr-comment-filter.py builtins.readFile ./gitea-pr-comment-filter.py
); );
@@ -106,13 +63,10 @@ let
builtins.readFile ./gitea-pr-review-filter.py builtins.readFile ./gitea-pr-review-filter.py
); );
# The route prompts. These are NOT mounted into the container: the route # Route prompts: not mounted into the container, but embedded as strings by
# config below embeds them as strings, and jq reads them from these store # the route config below via jq --rawfile, which lets ~60 lines of markdown
# paths host-side with --rawfile. Keeping them in files rather than inline # full of apostrophes/{placeholders} skip nix string escaping and stay
# nix strings is still what makes that work — they are ~60 lines of markdown # diffable in git.
# full of apostrophes and {placeholders} that would otherwise have to
# survive nix string escaping on the way into a shell command. --rawfile
# crosses all of that untouched, and they stay diffable in git.
prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" ( prCommentPrompt = pkgs.writeText "gitea-pr-comment-prompt.md" (
builtins.readFile ./gitea-pr-comment-prompt.md builtins.readFile ./gitea-pr-comment-prompt.md
); );
@@ -126,88 +80,55 @@ let
prCommentEvents = [ "issue_comment" ]; prCommentEvents = [ "issue_comment" ];
prReviewEvents = [ "pull_request_comment" "pull_request_rejected" ]; prReviewEvents = [ "pull_request_comment" "pull_request_rejected" ];
# Toolsets granted to both routes' agent runs. # Toolsets granted to both routes' agent runs. Hermes's webhook default
# # (web_search, web_extract, vision_analyze, clarify) has no shell/file/edit
# Hermes defaults webhook runs to a deliberately narrow set (web_search, # access, so neither prompt could act without this — and it REPLACES the
# web_extract, vision_analyze, clarify) because a webhook payload is # default rather than merging, hence "web" being re-listed. luna could in
# third-party content. That default cannot clone, edit or push, so neither # principle self-grant via webhook_subscriptions.json (it's under her own
# prompt was executable under it: the run would be woken, read the comment, # HERMES_WRITE_SAFE_ROOT, and she has edited it before), so this only makes
# and have no way to act on it. # the grant reviewable and reasserted on restart, not unforgeable — the
# # real backstop stays gitea's branch protection on master.
# This list REPLACES the platform default for these routes rather than
# merging with it, so anything the default provided has to be re-listed —
# "web" is here for that reason, not because the prompts ask for research.
#
# Upstream's stated boundary is that `hermes webhook subscribe` has no
# --toolsets flag, so "an agent creating its own subscription at runtime
# cannot self-grant terminal". That boundary does NOT hold here and must not
# be relied on: webhook_subscriptions.json lives under /opt/data, which is
# HERMES_WRITE_SAFE_ROOT, so luna can edit her own grant — she already did
# once, which is why this moved into nix. What this buys is that the grant
# is deliberate, reviewable and re-asserted on every restart, not that it is
# unforgeable. The real backstop stays server-side: gitea's branch
# protection on master.
routeToolsets = [ "terminal" "file" "web" ]; routeToolsets = [ "terminal" "file" "web" ];
# hermesHome as the CONTAINER sees it (the bind mount below). Anything # hermesHome as the CONTAINER sees it. Anything written host-side that gets
# written host-side that gets READ back inside the container must use this # READ back inside the container must use this prefix, not hermesHome.
# prefix, not hermesHome — see the credential.helper below, which was
# broken exactly that way from 3c1f3e5 until 2026-08-23.
containerHome = "/opt/data"; containerHome = "/opt/data";
in in
{ {
# Browsing convenience (ssh access to the bind-mounted local state) — does # ssh browsing convenience only — the container still uses HERMES_UID/GID
# NOT touch the container, which keeps using HERMES_UID/GID above # above regardless of this.
# regardless of what's declared here.
users.groups.hermes.gid = 983; users.groups.hermes.gid = 983;
users.users.darman.extraGroups = [ "hermes" ]; users.users.darman.extraGroups = [ "hermes" ];
# `hermes <args>` on mars == `sudo podman exec -it hermes-agent hermes <args>`. # `hermes <args>` == `sudo podman exec -it hermes-agent hermes <args>`. sudo
# sudo is required: virtualisation.oci-containers runs rootful (system) # is needed because oci-containers runs rootful podman, a separate
# podman, a separate namespace from darman's own rootless `podman`/`docker` # namespace from darman's own rootless one.
# — 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"; programs.zsh.shellAliases.hermes = "sudo podman exec -it hermes-agent hermes";
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${stateDir} 0750 root hermes -" "d ${stateDir} 0750 root hermes -"
]; ];
# podman requires the bind-mount source to already exist (no auto-create), # podman needs the bind-mount sources to exist first; the dropbox lives on
# and the dropbox lives on the CIFS mount below — mkdir there works fine # the CIFS mount below, which is fine to mkdir into directly.
# over cifs, no server-side (jupiter) config needed.
# #
# Also provisions luna's git/tea access: writes a git credential-store file # Also provisions luna's git/tea access as root, before the container
# and runs `tea logins add` INTO hermesHome (i.e. paths that appear at # starts, and chowns what it writes itself — the image's cont-init only
# /opt/data/... once the container is up). Both run on the HOST as root, # fixes ownership of hermesHome's top level, not what this oneshot drops
# before the container starts, and both therefore have to chown what they # into it. No longer clones the repo for her (see the header); the version
# write themselves — see the chown at the end of the script. Do NOT assume # that did left a stale ${hermesHome}/workspace/homelab that this does not
# the image's cont-init fixes ownership under hermesHome: it does not # clean up.
# recurse into what this oneshot drops there, even though it runs after it.
# #
# It deliberately does NOT clone the repo for her any more (see the # Delete-then-add for the tea login, not an existence check: tea can leave
# header). The stale ${hermesHome}/workspace/homelab left behind by the # a login entry behind even when `add` itself reports failure, so
# version that did is not cleaned up here either — it just stops being # delete-then-add is the only idempotent option and picks up a rotated
# managed, and stops being updated. Remove it by hand if you want it gone.
#
# Delete-then-add for the tea login (not a "does it exist" check): tea can
# leave a login entry behind even when `add` reports failure (e.g. a token
# missing a scope errors out AFTER the entry is written — observed
# directly against the real instance during the first version of this
# setup). Delete-then-add is idempotent either way and picks up a rotated
# token for free. # token for free.
# #
# `tea logins add` is the ONLY step in here that touches the network, and # `tea logins add` is the only network call here, and ordering matters:
# ordering is what makes it survivable. switch-to-configuration restarts # switch-to-configuration restarts NetworkManager in the same pass as this
# NetworkManager and starts this unit in the SAME pass: on 2026-09-11 the # unit, and on 2026-09-11 that raced badly enough to hang the unit for
# two landed in the same second, tea's connect went out over an interface # minutes and take the whole container down. Hence network-online.target,
# that was still coming back, and the kernel spent 2m48s on SYN retries # the bounded probe below, and TimeoutStartSec as a backstop.
# before reporting "connection timed out". That failed this unit, which
# podman-hermes-agent Requires=, so a five-second network blip took the
# whole container down and returned 4 from the deploy. Hence
# network-online.target below, the bounded reachability probe in the script,
# and TimeoutStartSec as the backstop — no single blocking call in here may
# outlive the deploy that started it.
systemd.services.hermes-agent-prepare-dirs = { systemd.services.hermes-agent-prepare-dirs = {
description = "Create Hermes state dirs + luna's git/tea access before the container starts"; description = "Create Hermes state dirs + luna's git/tea access before the container starts";
before = [ "podman-hermes-agent.service" ]; before = [ "podman-hermes-agent.service" ];
@@ -218,16 +139,13 @@ in
path = [ pkgs.git pkgs.tea pkgs.curl pkgs.coreutils ]; path = [ pkgs.git pkgs.tea pkgs.curl pkgs.coreutils ];
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
# Everything here is either local or bounded to ~30s by the probe loop, so # Everything here is either local or bounded to ~30s by the probe loop, so
# anything past two minutes is a hang, not slowness. Failing at that point # anything past two minutes is a hang, not slowness.
# is strictly better than holding the deploy open.
serviceConfig.TimeoutStartSec = "120"; serviceConfig.TimeoutStartSec = "120";
script = '' script = ''
mkdir -p ${hermesHome} mkdir -p ${hermesHome}
mkdir -p ${dropboxDir} mkdir -p ${dropboxDir}
# Parent for the read-only filters bind-mounted at # Parent dir for the read-only filters bind-mounted below; must exist
# /opt/data/scripts/gitea-pr-*-filter.py. /opt/data is itself a bind # host-side first since /opt/data is itself a bind mount of hermesHome.
# mount of hermesHome, so this directory has to exist HOST-side before
# podman can mount a file inside it.
mkdir -p ${hermesHome}/scripts mkdir -p ${hermesHome}/scripts
export HOME=${hermesHome} export HOME=${hermesHome}
@@ -241,25 +159,18 @@ in
install -m 0600 /dev/null ${hermesHome}/.git-credentials install -m 0600 /dev/null ${hermesHome}/.git-credentials
printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \ printf 'https://luna:%s@${giteaHost}\n' "$(cat "$token_file")" \
> ${hermesHome}/.git-credentials > ${hermesHome}/.git-credentials
# containerHome, NOT hermesHome: git reads this .gitconfig from INSIDE # containerHome, not hermesHome: git reads this .gitconfig from inside
# the container, where the host path does not exist. Nothing host-side # the container, and nothing host-side needs it any more.
# consumes these credentials any more (the clone that used to is gone),
# so the container's view is the only one that has to be right.
git config --global credential.helper "store --file=${containerHome}/.git-credentials" git config --global credential.helper "store --file=${containerHome}/.git-credentials"
git config --global user.name "luna" git config --global user.name "luna"
git config --global user.email "luna@${giteaHost}" git config --global user.email "luna@${giteaHost}"
# Probe before touching the login, with a hard per-attempt timeout: a # A bare TCP connect to an interface still coming up can hang ~3min on
# bare TCP connect to an interface that is still coming up hangs for # kernel SYN retries, and tea has no timeout flag, so probe first with a
# ~3 minutes on kernel SYN retries, and tea has no timeout flag of its # hard per-attempt timeout. /api/v1/version is unauthenticated (tests
# own. /api/v1/version is unauthenticated, so this says "is gitea # reachability only). Probing before touching the login (rather than
# reachable", never "is the token good" the token is the add's job. # retrying the add) protects it: delete-then-add isn't atomic, so an add
# # that fails on a down network would leave luna with no login at all.
# Probing FIRST (rather than retrying the add) is what protects the
# login that is already there. delete-then-add is not atomic: an add
# that fails because the network is down leaves luna with no login at
# all, strictly worse than the stale-but-working one we started with.
# Unreachable therefore means skip the refresh entirely and warn.
gitea_up=0 gitea_up=0
for attempt in 1 2 3; do for attempt in 1 2 3; do
if curl -fsS --max-time 5 -o /dev/null "https://${giteaHost}/api/v1/version"; then if curl -fsS --max-time 5 -o /dev/null "https://${giteaHost}/api/v1/version"; then
@@ -271,42 +182,29 @@ in
done done
if [ "$gitea_up" = 1 ]; then if [ "$gitea_up" = 1 ]; then
# Reachable but the add still fails == a real problem (revoked or # Reachable but still failing means a real problem (revoked/under-
# under-scoped token, gitea rejecting the login), and that stays # scoped token) stays fatal since it won't fix itself on reboot.
# fatal: it is a config error, it will not fix itself on the next
# boot, and it should be loud.
tea logins delete luna 2>/dev/null || true tea logins delete luna 2>/dev/null || true
GITEA_SERVER_TOKEN="$(cat "$token_file")" timeout 60 tea logins add \ GITEA_SERVER_TOKEN="$(cat "$token_file")" timeout 60 tea logins add \
--name luna --url "https://${giteaHost}" --no-version-check --name luna --url "https://${giteaHost}" --no-version-check
else else
# Deliberately not fatal. Every other thing this unit does is local, # Not fatal: everything else here is local, and podman-hermes-agent
# and podman-hermes-agent Requires= it failing here would take # Requires= this unit failing here would take Telegram/dashboard
# Telegram and the dashboard down over a transient blip. luna keeps # down over a transient blip instead of just the tea CLI.
# git (the credential helper above needs no network to be written)
# and loses only the tea CLI until the next start re-runs this.
echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2 echo "WARNING: ${giteaHost} unreachable; left luna's tea login untouched." >&2
fi fi
# Hand everything written above to the container's uid/gid. This does # Hand written files to the container's uid/gid: the image's cont-init
# NOT happen by itself: the image's cont-init only chowns hermesHome's # only chowns hermesHome's top level, so root-owned files dropped here
# top level and its own state, so root-owned 0600 files dropped here by # (confirmed on 2026-08-23) are otherwise unreadable to Hermes.
# this oneshot (.git-credentials, and tea's config.yml tea writes it
# 0600 too) are simply unreadable to uid ${hermesUid}. Symptom is not an
# error but an absence: git reports no credential helper and tea reports
# no login, i.e. "they're missing". Confirmed on the real instance
# 2026-08-23 cont-init ran AFTER these files were written and left
# them root-owned regardless.
# #
# `if`, not `[ -d x ] && chown`: this script runs under `set -e`, where # `if`, not `[ -d x ] && chown`: this script runs under `set -e`, and a
# a false test as the left side of an && list takes the whole list's # false test on the left of && would abort the whole unit.
# non-zero status and aborts the unit.
chown ${hermesUid}:${hermesGid} \ chown ${hermesUid}:${hermesGid} \
${hermesHome}/.gitconfig \ ${hermesHome}/.gitconfig \
${hermesHome}/.git-credentials ${hermesHome}/.git-credentials
# Same cont-init caveat as the files above: the directory is created # Same cont-init caveat: this dir is created as root, and Hermes reads
# here as root, and Hermes reads its scripts as uid ${hermesUid}. The # scripts as uid ${hermesUid}.
# mounted filters themselves are world-readable 0444 from the store, so
# only the directory needs handing over.
chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts chown ${hermesUid}:${hermesGid} ${hermesHome}/scripts
if [ -d ${hermesHome}/.config ]; then if [ -d ${hermesHome}/.config ]; then
@@ -330,25 +228,18 @@ in
"${hermesHome}:/opt/data" "${hermesHome}:/opt/data"
"${dropboxDir}:/opt/data/dropbox" "${dropboxDir}:/opt/data/dropbox"
# luna's Obsidian vault, kept in sync with CouchDB on jupiter by # luna's Obsidian vault, synced with CouchDB on jupiter by
# livesync-bridge.nix. Under /opt/data so it lands inside # livesync-bridge.nix. Under /opt/data so she can write notes, not just
# HERMES_WRITE_SAFE_ROOT and she can write notes, not just read them — # read them; the bridge runs as this same uid/gid so no chown is needed.
# same reasoning as the dropbox above. The bridge runs as this very
# uid/gid, so no ownership fixup is needed on either side.
"/var/lib/livesync-bridge/vault:/opt/data/vault" "/var/lib/livesync-bridge/vault:/opt/data/vault"
# git/tea for luna: the image doesn't ship `tea` (and shouldn't be # git/tea for luna: the image ships neither (and its own git shouldn't
# trusted to have a known-good `git` either), so both come from this # be trusted), so both come from this host's Nix store, read-only.
# host's Nix store instead — mounted read-only at fixed PATH-visible # /nix/store must come along too since both binaries are dynamically
# locations. /nix/store itself has to come along too since both # linked against it.
# binaries are dynamically linked against paths inside it; the store # Filters mounted read-only (see prCommentFilter above), where Hermes
# is read-only content-addressed build output, not a source of # resolves route scripts (~/.hermes/scripts). Prompts are NOT mounted —
# secrets, so mounting the whole thing read-only costs nothing beyond # they're embedded directly in the route config the unit below writes.
# the two specific binaries actually being reachable.
# Read-only: see prCommentFilter above. Hermes resolves route scripts
# under ~/.hermes/scripts, which is /opt/data/scripts in here. The route
# prompts are NOT mounted — they are embedded in the route config the
# unit below writes, so nothing inside the container reads them.
"${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro" "${prCommentFilter}:/opt/data/scripts/gitea-pr-comment-filter.py:ro"
"${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro" "${prReviewFilter}:/opt/data/scripts/gitea-pr-review-filter.py:ro"
@@ -361,16 +252,12 @@ in
HERMES_GID = hermesGid; HERMES_GID = hermesGid;
TZ = "Europe/Berlin"; TZ = "Europe/Berlin";
# Point git/tea at the config the prepare-dirs oneshot wrote into # Points git/tea at the config prepare-dirs wrote into hermesHome
# hermesHome (visible here as /opt/data/...) — the credential-store # (visible here as /opt/data/...).
# helper, the luna gitea login, and (implicitly, via HOME not being
# overridden) darman's Hermes state stays wherever it already was.
GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig"; GIT_CONFIG_GLOBAL = "/opt/data/.gitconfig";
XDG_CONFIG_HOME = "/opt/data/.config"; XDG_CONFIG_HOME = "/opt/data/.config";
# HERMES_TIMEZONE is the highest-priority source hermes_time.py checks # Highest-priority source hermes_time.py checks; without it the
# (ahead of config.yaml's `timezone` key) — the container has no host # container defaults to UTC (no /etc/localtime bind-mount).
# /etc/localtime bind-mount, so it defaults to UTC otherwise (fixed in
# 9403122 on jupiter; carried forward here).
HERMES_TIMEZONE = "Europe/Berlin"; HERMES_TIMEZONE = "Europe/Berlin";
# Dashboard + Authentik OIDC gate — see the file-level comment above. # Dashboard + Authentik OIDC gate — see the file-level comment above.
@@ -378,14 +265,11 @@ in
HERMES_DASHBOARD_HOST = "0.0.0.0"; # must be tailscale0-reachable, not just loopback 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_ISSUER = "https://auth.mgaction.town/application/o/hermes/";
HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6"; HERMES_DASHBOARD_OIDC_CLIENT_ID = "4BqdJu3htnMtSZnyEu5zHnsSOvlEbw3Ie3mYVlh6";
# uvicorn's proxy_headers=True (web_server.py) only trusts # uvicorn only trusts X-Forwarded-Proto from forwarded_allow_ips
# X-Forwarded-Proto from forwarded_allow_ips, which defaults to # (default 127.0.0.1); neptun's Caddy reaches this over a real routed
# 127.0.0.1 — neptun's Caddy reaches this over the tailnet (a real # tailnet IP, so without this it builds an http:// redirect_uri that
# routed IP), so without this the dashboard sees the raw scheme (http) # Authentik rejects. Safe to trust any peer: 9119 is already scoped to
# and builds an http:// redirect_uri that Authentik rejects against its # loopback + tailscale0 only.
# 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 = "*"; FORWARDED_ALLOW_IPS = "*";
}; };
environmentFiles = [ config.sops.templates."hermes-agent.env".path ]; environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
@@ -401,35 +285,19 @@ in
unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ];
}; };
# The two Gitea webhook routes, written as config rather than created with # The two Gitea webhook routes, written as config (not via `hermes webhook
# `hermes webhook subscribe`. # subscribe`, which has no --toolsets flag — see routeToolsets above).
# Gitea posts directly to Hermes with X-Hub-Signature-256 and
# X-GitHub-Event, which is what Hermes validates against and reads the
# event name from.
# #
# Gitea posts straight at Hermes (jupiter's gitea-hermes-webhook-provision # Written host-side into hermesHome (bind-mounted at /opt/data), so the
# registers one hook per route at http://mars.orbit.sol:8644/webhooks/<name>) # webhook adapter hot-reloads it on the next delivery — no container
# — there is no relay in between. Gitea's addDefaultHeaders sends # restart needed.
# X-Hub-Signature-256 in GitHub's exact format AND X-GitHub-Event,
# unconditionally, for every webhook type, which is precisely what Hermes
# validates and reads the event name from.
# #
# WHY NOT `hermes webhook subscribe`: it has no --toolsets flag, and without # Events below are WIRE names (X-GitHub-Event), not the api names
# a toolset override a webhook run gets Hermes's constrained default # gitea.nix's hooks use — gitea spells the same events three ways and two
# (web_search, web_extract, vision_analyze, clarify) — no shell, no file # spellings collide:
# access, so neither prompt below can actually be carried out. Upstream's
# documented answer is to write the `toolsets` key into
# webhook_subscriptions.json by hand. Doing that by hand does not survive
# this unit, which re-provisions on every start, so the whole route
# definition moves here instead and the CLI is not used at all. See
# routeToolsets above for what that costs.
#
# This writes the file HOST-side. hermesHome is bind-mounted at /opt/data,
# so the container sees the same inode, and the webhook adapter hot-reloads
# the file (mtime-gated) on the next delivery — no container restart, and no
# `podman exec` quoting chain between nix and the prompt text.
#
# Events are WIRE names (X-GitHub-Event). Gitea spells the same events three
# different ways and two of the spellings collide — from
# HookEventType.Event() in modules/webhook/type.go, and updateHookEvents in
# routers/api/v1/utils/hook.go for the api column:
# #
# HookEventType wire name (here) api name (gitea.nix) # HookEventType wire name (here) api name (gitea.nix)
# --------------------------- ---------------------- -------------------- # --------------------------- ---------------------- --------------------
@@ -439,46 +307,31 @@ in
# pull_request_review_rejected pull_request_rejected pull_request_review # pull_request_review_rejected pull_request_rejected pull_request_review
# pull_request_review_approved pull_request_approved pull_request_review # pull_request_review_approved pull_request_approved pull_request_review
# #
# Hermes matches these against X-GitHub-Event, i.e. the WIRE name. So # So "pull_request_comment" HERE means a review and "issue_comment" HERE
# "pull_request_comment" HERE means a review and "issue_comment" HERE means # means a comment — neither this file nor gitea.nix has a typo.
# a comment — the exact inversion of how they read. X-GitHub-Event-Type
# carries the HookEventType, but Hermes does not look at it. This file and
# services/dev/gitea.nix therefore name the same event differently on
# purpose; neither is a typo.
# #
# The api column is not a third alias but a coarser set: HasEvent # api names collapse all three review types onto pull_request_review, so
# (models/webhook/webhook.go) collapses all three review types onto # approvals can't be subscribed separately — they arrive here and are
# pull_request_review, so the gitea hook cannot subscribe them separately. # dropped by omission from prReviewEvents. Widen by adding
# Approvals arrive here as a result and are dropped by NOT being in # "pull_request_approved" here and to the filter's ALLOWED_REVIEW_TYPES.
# prReviewEvents — Hermes answers {"status": "ignored"} on the event match,
# before the filter script and before any LLM call. Widening to approvals is
# a mars-side change only: add "pull_request_approved" to prReviewEvents and
# "pull_request_review_approved" to the filter's ALLOWED_REVIEW_TYPES.
# #
# issue_comment on the wire covers comments on plain issues too; the hook # issue_comment on the wire also covers plain-issue comments; the comment
# does not subscribe those, and the comment filter's is_pull check drops # filter's is_pull check drops those if the hook is ever widened.
# them anyway if the hook is ever widened.
# #
# deliver is "log", not a chat target: both prompts tell her to answer in # deliver is "log", not a chat target both prompts answer directly in the
# the pull request, so the PR comment IS the delivery. # pull request.
# #
# `script` is the selection that MUST NOT be retunable at runtime. # `script` must not be retunable at runtime: the filter drops luna's own
# gitea-pr-comment-filter.py drops luna's own comments before any LLM call, # comments before any LLM call (what stops the reply loop, since her PR
# which is what stops the reply loop: the prompt tells her to answer on the # answer is itself a pull_request_comment), and is mounted read-only so she
# PR, and her answer is itself a pull_request_comment. Both filters are # can't edit her own guard out.
# bind-mounted read-only from the store above so the agent cannot edit her
# own guard out. Hermes resolves the name relative to ~/.hermes/scripts,
# hence the bare filename.
# #
# What read-only does NOT buy: it protects the sources, and this unit # Read-only protects the source only — this unit re-asserts prompt, filter,
# re-asserts prompt, filter, events and toolsets from them on every start, # events and toolsets on every start, so a live self-modification only
# so a restart restores the intended config. The live file is inside the # sticks until the next restart.
# agent's own write-safe root, so a self-modification sticks until this unit
# next runs.
# #
# Routes this unit does not name are left alone (the merge below is # Routes not named here are left alone (the merge below is per-key);
# per-key), so retiring an old one stays a deliberate one-off: # retire one with `sudo podman exec hermes-agent hermes webhook remove <name>`.
# sudo podman exec hermes-agent hermes webhook remove <name>
systemd.services.hermes-agent-webhook-routes = { systemd.services.hermes-agent-webhook-routes = {
description = "Write Hermes's Gitea webhook route config"; description = "Write Hermes's Gitea webhook route config";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@@ -504,26 +357,19 @@ in
tmp="$conf.new" tmp="$conf.new"
trap 'rm -f "$tmp"' EXIT trap 'rm -f "$tmp"' EXIT
# --slurpfile below cannot read a file that does not exist. Creating it # --slurpfile needs the file to exist; empty is safe pre-first-run.
# empty is safe: this only ever happens before the first run, when there # Invalid JSON fails the unit loudly and leaves it untouched better a
# are no routes to lose. If it exists but is not valid JSON, slurpfile # failed unit than silently discarded routes.
# fails the unit loudly and leaves it untouched, which is the right
# direction better a failed unit than silently discarded routes.
[ -e "$conf" ] || printf '%s\n' '{}' > "$conf" [ -e "$conf" ] || printf '%s\n' '{}' > "$conf"
# The secret reaches jq via --rawfile, never argv: /proc/<pid>/cmdline # Secret goes to jq via --rawfile, never argv (cmdline is world
# is world-readable, so `--arg secret "$(cat ...)"` would publish it to # readable) same reason the prompts come in by path, not value.
# every user on the box for the lifetime of the process. Same reason the # sops stores this without a trailing newline, but rtrimstr guards
# prompts come in by path rather than by value. # against one anyway: it would silently change the HMAC key.
# # The emptiness guards are load-bearing: without them a truncated
# sops stores this one without a trailing newline (see secrets.nix), but # secret or unreadable prompt yields "", and the route is written with
# rtrimstr is kept anyway: a stray newline would silently change the key # an empty secret that fails every signature check while reporting
# the HMAC is computed with and fail every delivery afterwards. # success.
#
# The emptiness guards are load-bearing. Without them a truncated secret
# file or an unreadable prompt yields "", and the route is written with
# an empty secret which fails EVERY signature check while the unit
# still reports success.
jq -n \ jq -n \
--slurpfile existing "$conf" \ --slurpfile existing "$conf" \
--rawfile rawSecret "$SECRET_FILE" \ --rawfile rawSecret "$SECRET_FILE" \
@@ -547,12 +393,9 @@ in
deliver: "log", deliver: "log",
toolsets: $toolsets }; toolsets: $toolsets };
# created_at is cosmetic (hermes webhook list prints it) and is the # created_at is cosmetic and the only key carried over from any
# one key carried over from whatever is already there, so it keeps # existing route; everything else is replaced outright so a
# reading as when the route first appeared rather than as the last # leftover key from an earlier definition can't survive here.
# deploy. Everything else is replaced outright: a leftover key from
# an earlier definition or from a hand edit would otherwise
# survive here forever.
def upsert($name; $r): def upsert($name; $r):
.[$name] = ($r + { created_at: (.[$name].created_at // (now | todate)) }); .[$name] = ($r + { created_at: (.[$name].created_at // (now | todate)) });
@@ -566,10 +409,9 @@ in
$reviewEvents; $reviewPrompt; "gitea-pr-review-filter.py")) $reviewEvents; $reviewPrompt; "gitea-pr-review-filter.py"))
' > "$tmp" ' > "$tmp"
# 0600 because the file holds the HMAC secret in cleartext, and owned by # 0600: holds the HMAC secret in cleartext. Owned by the container's
# the container's uid because Hermes rewrites it itself whenever anything # uid since Hermes rewrites this file itself on `webhook subscribe`.
# calls `hermes webhook subscribe`. mv is an atomic rename within the # mv is an atomic rename, so a delivery mid-write never sees a half
# same directory, so a delivery landing mid-write never reads a half
# written config. # written config.
chmod 0600 "$tmp" chmod 0600 "$tmp"
chown ${hermesUid}:${hermesGid} "$tmp" chown ${hermesUid}:${hermesGid} "$tmp"
+49 -71
View File
@@ -1,59 +1,47 @@
{ config, pkgs, inputs, ... }: { config, pkgs, inputs, ... }:
# livesync-bridge (vrtmrz) — mirrors an Obsidian LiveSync vault out of CouchDB # livesync-bridge (vrtmrz) — mirrors an Obsidian LiveSync vault out of CouchDB
# on jupiter (services/dev/obsidian-livesync.nix) into a real directory of # on jupiter (services/dev/obsidian-livesync.nix) into real markdown files
# markdown here, so luna can read and write the vault as files. Obsidian itself # here, since Obsidian itself is a GUI-only Electron app and luna needs files.
# is an Electron GUI with no headless mode, and an agent wants files anyway.
#
# ⚠️ THE WRITE-BACK PATH IS THE RISKY ONE. Upstream has three open, unanswered
# issues on storage->couchdb — #50 (Jun 2026, writes detected and logged as
# uploaded, database never updated), #23 (only lowercase filenames transmitted
# from storage), #46 (silent stall on files over ~30KB). All fail QUIETLY: the
# log says success and the note never arrives. So do not treat this directory
# as durable storage for anything luna cannot regenerate, and check that her
# edits actually reach your devices before trusting it. (E2EE itself is fine —
# PeerCouchDB.ts hard-errors if a passphrase is missing for an encrypted
# remote, so it is a deliberate code path. The one issue claiming E2EE breaks
# bridging, #12, is a single unreproduced report with no maintainer reply.)
# #
# ⚠️ THE WRITE-BACK PATH IS THE RISKY ONE: upstream has open bugs where a
# write is logged as uploaded but the database is never updated (#50), only
# lowercase filenames sync from storage (#23), and files over ~30KB silently
# stall (#46) — all fail quietly with no error in the log. Don't treat this
# directory as durable for anything luna can't regenerate, and verify her
# edits actually reach your devices. (E2EE itself is fine — it hard-errors on
# a missing passphrase rather than failing silently.)
# #
# EXPECTED NOISE ON FIRST SYNC: a stack trace per historically-deleted file — # EXPECTED NOISE ON FIRST SYNC: a stack trace per historically-deleted file —
# NotFound: ... remove '<vault>/Welcome.md' at PeerStorage.delete # CouchDB replays deletion tombstones against a directory where the file
# CouchDB keeps deletion tombstones, and the bridge replays them against a # never existed. Harmless, caught and logged, and stops once the initial
# directory where the file never existed. PeerStorage.ts:33-40 catches it, # catch-up ends.
# logs, and returns false, so nothing is wrong; it only LOOKS fatal because #
# main.ts pins the logger to LOG_LEVEL_DEBUG, which prints exception dumps # Talks to CouchDB over the tailnet (jupiter.orbit.sol:5984) directly — mars
# that are otherwise verbose-level. It stops once the initial catch-up ends. # is a tailnet node, so neptun's public vhost/TLS/allowlist don't apply here.
# Talks to CouchDB over the TAILNET (jupiter.orbit.sol:5984), not through
# neptun: mars is a tailnet node, so the public vhost, its TLS and its path
# allowlist are all irrelevant here.
let let
stateDir = "/var/lib/livesync-bridge"; stateDir = "/var/lib/livesync-bridge";
appDir = "${stateDir}/app"; appDir = "${stateDir}/app";
vaultDir = "${stateDir}/vault"; vaultDir = "${stateDir}/vault";
# The same uid/gid the hermes-agent container runs as (hermes-agent.nix). # The same uid/gid hermes-agent runs as (hermes-agent.nix), so both peers
# Deliberate: the bridge and luna both read and write these files, and # share files without depending on umask — two uids in a shared group only
# sharing one uid removes any dependence on the container's umask. Two # works while every file stays group-writable, and one 0644 file from the
# different uids in a shared group only works while every file stays # agent would silently stall sync.
# group-writable, and a single 0644 file dropped by the agent would stall
# sync on that path with nothing but a permission error in the log.
hermesUid = 986; hermesUid = 986;
# Which vault. `group` is what pairs the two peers — both must match or the # `group` pairs the two peers — mismatched and the bridge starts but never
# bridge starts cleanly and simply never syncs anything. # syncs.
# #
# ⚠️ `database` must be the name entered in the Obsidian plugin for luna's # ⚠️ `database` must match the name entered in the Obsidian plugin exactly:
# vault. Get it wrong and nothing errors: the credential below is CouchDB's # get it wrong and nothing errors, since the admin credential below lets
# admin, so PouchDB CREATES the misnamed database and replicates an empty # PouchDB just create the misnamed database and replicate an empty vault.
# vault into it quite happily.
peerGroup = "luna"; peerGroup = "luna";
database = "luna_wiki"; database = "luna_wiki";
in in
{ {
# hermes-agent.nix declares the GROUP (gid 983) but no user: the container # hermes-agent.nix declares the group (gid 983) but no user the container
# brings its own uid and needs no host account. The bridge does need one to # needs no host account, but this service does, so it's declared here.
# run as, so the matching user is declared here.
users.users.hermes = { users.users.hermes = {
uid = hermesUid; uid = hermesUid;
group = "hermes"; group = "hermes";
@@ -62,27 +50,23 @@ in
description = "Hermes agent uid, shared with the livesync-bridge service"; description = "Hermes agent uid, shared with the livesync-bridge service";
}; };
# Created here rather than by the service so they exist before anything # Created here, not by the service, so they exist before anything needs
# tries to use them: # them: vaultDir before podman-hermes-agent starts (else podman creates it
# - vaultDir before podman-hermes-agent starts, because a bind-mount # as root:root), and appDir before ExecStartPre runs (WorkingDirectory
# source that does not exist is created by podman as root:root and the # applies to it too).
# bridge then cannot write into its own vault;
# - appDir because WorkingDirectory applies to ExecStartPre as well, so a
# missing one fails the unit before preStart ever gets to create it.
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${vaultDir} 0770 hermes hermes -" "d ${vaultDir} 0770 hermes hermes -"
"d ${appDir} 0750 hermes hermes -" "d ${appDir} 0750 hermes hermes -"
"d ${stateDir}/deno 0750 hermes hermes -" "d ${stateDir}/deno 0750 hermes hermes -"
]; ];
# The bridge's peer config, rendered by sops because it carries three # Rendered by sops (three inline secrets: CouchDB password + both
# secrets inline (CouchDB password + both passphrases) and the file format # passphrases; the json format has no include mechanism).
# has no include mechanism.
# #
# ⚠️ sops substitutes placeholders into the ALREADY-RENDERED json, so a # ⚠️ sops substitutes into the ALREADY-RENDERED json, so a secret with a
# secret containing a double quote or a backslash produces an invalid config # quote or backslash yields invalid config — the bridge then just sits with
# and the bridge logs "Could not parse configuration!" and then sits there # zero peers logging "Could not parse configuration!" instead of exiting.
# with zero peers — it does not exit. Keep all three values alphanumeric. # Keep all three values alphanumeric.
sops.templates."livesync-bridge.json" = { sops.templates."livesync-bridge.json" = {
owner = "hermes"; owner = "hermes";
content = builtins.toJSON { content = builtins.toJSON {
@@ -96,11 +80,10 @@ in
username = "obsidian"; username = "obsidian";
password = config.sops.placeholder.couchdb_luna_password; password = config.sops.placeholder.couchdb_luna_password;
passphrase = config.sops.placeholder.obsidian_luna_passphrase; passphrase = config.sops.placeholder.obsidian_luna_passphrase;
# The plugin derives path obfuscation from the same passphrase it # Same secret as the content passphrase — the plugin derives path
# uses for content, so this is the same secret. Split into its own # obfuscation from it too, but the bridge takes them as separate
# field because the bridge takes them separately — if paths come # fields. If paths come back as garbage while contents decode fine,
# back as garbage while contents decode fine, this is the field that # this is the field to check.
# is wrong.
obfuscatePassphrase = config.sops.placeholder.obsidian_luna_passphrase; obfuscatePassphrase = config.sops.placeholder.obsidian_luna_passphrase;
# Reads the chunking tweaks the plugin stored in the remote, instead # Reads the chunking tweaks the plugin stored in the remote, instead
# of guessing sizes that then disagree with every other client. # of guessing sizes that then disagree with every other client.
@@ -137,22 +120,17 @@ in
HOME = stateDir; HOME = stateDir;
}; };
# Copy the pinned source out of the store and install its locked deps. # Copies the pinned source out of the store and installs locked deps,
# It cannot run from /nix/store directly: deno.jsonc sets # since deno.jsonc's `nodeModulesDir: manual` (byonm) needs to write
# `nodeModulesDir: manual` with byonm, so `deno install` must write a # node_modules/ next to the sources — it can't run from /nix/store directly.
# node_modules/ next to the sources.
# #
# The copy target is a FIXED path on purpose. Deno keys localStorage # The copy target is a FIXED path on purpose: Deno keys its localStorage
# which is where the bridge records per-file sync state (Peer.ts:119) — by # (where the bridge tracks per-file sync state) by the main module's
# the main module's origin, and stores it under # origin, so running straight from /nix/store would change that origin —
# DENO_DIR/location_data/<sha of that origin>. VERIFIED by running the same # and reset the bridge to a full rescan of both peers — on every input bump.
# source from two paths against one DENO_DIR: two separate origin dirs
# appear. Running straight from /nix/store would therefore change the
# origin on every input bump and silently reset the bridge to a full
# rescan of both peers.
# #
# Guarded by a stamp file so this is a no-op on ordinary restarts; only a # Guarded by a stamp file: a no-op on ordinary restarts, only a flake
# flake input bump pays for the re-install (which needs network). # input bump pays for the (networked) re-install.
preStart = '' preStart = ''
set -eu set -eu
stamp=${stateDir}/.src stamp=${stateDir}/.src
+3 -3
View File
@@ -29,9 +29,9 @@ let
cmd = [ "/bin/sleep" "infinity" ]; cmd = [ "/bin/sleep" "infinity" ];
}; };
# The "app" luna builds on top of. No network in the VM, so it is loaded # The "app" luna builds on top of, loaded from the store since the VM has
# from the store instead of pulled. Runs under luna-apps, which has no # no network. Runs under luna-apps, which has no /nix/store mount — hence
# /nix/store mount — hence the closure inside the image. # the closure baked into the image.
app = busyboxImage { app = busyboxImage {
name = "testapp"; name = "testapp";
extraCommands = "mkdir -p www && echo hello > www/index.html"; extraCommands = "mkdir -p www && echo hello > www/index.html";
+38 -54
View File
@@ -13,31 +13,22 @@
# /var/lib/luna-sites/live/<name>.caddy root-owned, imported by caddy # /var/lib/luna-sites/live/<name>.caddy root-owned, imported by caddy
# /opt/data/sites-status.txt what was accepted, and why not # /opt/data/sites-status.txt what was accepted, and why not
# #
# Why a registry of {name, port} instead of letting her drop Caddyfile # A registry of {name, port}, not raw Caddyfile snippets from her: a snippet
# snippets: a snippet can proxy to anything on this box (the dashboard on # could proxy to anything on the box or break caddy on the next boot, while
# 9119, the webhook listener on 8644, node-exporter) or file_server anything # the generator only ever emits one validated shape.
# caddy can read, and one syntax error keeps caddy from coming up on the next
# boot. The generator only ever emits one fixed shape from a validated name
# and a port inside portMin..portMax, so none of that is expressible.
# #
# Why paths, not <name>.mars.sol: mars has no fixed DHCP lease, and a wildcard # Paths, not <name>.mars.sol: mars has no fixed DHCP lease, and pihole-FTL's
# needs one. `address=/…/` takes an IP, and pihole-FTL's dnsmasq skips # dnsmasq can't wildcard-CNAME without one.
# wildcard --cname entries outside authoritative zones (cache_reload():
# `if (a->alias[1] != '*' …)`). Moving to subdomains later only changes the
# fragment the generator writes; the registry format stays.
# #
# Why a podman socket instead of ssh: what she needs is long-running processes # A podman socket, not ssh: gives her long-running processes outside her own
# OUTSIDE her own container (anything started inside it dies with the # container (which dies on restart and holds her tokens) with no host shell.
# container, and sits next to her Telegram/gitea tokens). The socket gives # It's not a strong boundary by itself — socket access is code execution as
# exactly that and no host shell. It is not a strong boundary on its own — # luna-apps — but luna-apps can't enter /var/lib/hermes (0750 root:hermes), so
# rootless podman socket access is code execution as luna-apps, which can read # her apps can't reach her tokens.
# whatever that user can — but luna-apps owns nothing and cannot enter
# /var/lib/hermes (0750 root:hermes), so the apps cannot reach her tokens.
# #
# She learns all this from a read-only README mounted at # She learns all this from a read-only README mounted at
# /opt/data/sites-README.md (luna-sites-README.md). She self-manages her # /opt/data/sites-README.md (luna-sites-README.md) — she self-manages her own
# memories, so nothing in this file reaches her otherwise — see the dropped # memory, so nothing else in this file reaches her.
# repo clone in hermes-agent.nix's header for what happens when it doesn't.
# #
# VM test: nix build .#checks.x86_64-linux.luna-sites -L (luna-sites-test.nix) # VM test: nix build .#checks.x86_64-linux.luna-sites -L (luna-sites-test.nix)
let let
@@ -77,29 +68,26 @@ in
isNormalUser = true; isNormalUser = true;
inherit uid; inherit uid;
description = "luna's hosted web apps (rootless podman)"; description = "luna's hosted web apps (rootless podman)";
# Nothing ever logs in as this user. Only its systemd user manager runs, # No interactive login; linger keeps its systemd user manager (and thus
# kept up without a session by linger, which is what brings the podman # the podman socket) running across reboots without a session.
# socket and podman-restart back after a reboot.
linger = true; linger = true;
autoSubUidGidRange = true; # rootless podman's user namespace autoSubUidGidRange = true; # rootless podman's user namespace
hashedPassword = "!"; hashedPassword = "!";
shell = "${pkgs.shadow}/bin/nologin"; shell = "${pkgs.shadow}/bin/nologin";
}; };
# `--restart=always` containers only come back after a reboot through this # Rootless podman has no daemon to bring `--restart=always` containers back
# unit — rootless podman has no daemon to remember them. The podman module # after a reboot; the podman module enables this for every user, scoped
# already enables podman.socket for every user's manager; this one is # here to luna-apps.
# scoped to luna-apps.
systemd.user.services.podman-restart = { systemd.user.services.podman-restart = {
wantedBy = [ "default.target" ]; wantedBy = [ "default.target" ];
unitConfig.ConditionUser = user; unitConfig.ConditionUser = user;
}; };
# ---- the socket luna's container talks to ---- # ---- the socket luna's container talks to ----
# luna-apps's own socket lives under /run/user/1001 (0700), which the # luna-apps's own socket lives under /run/user/1001 (0700), unreachable to
# container's uid cannot enter. This re-exposes it to group hermes, and the # the container's uid; this re-exposes it to group hermes via a proxy that
# proxy behind it runs as luna-apps, so it holds no access beyond the socket # itself runs as luna-apps, so it holds no more access than the socket.
# it forwards to.
systemd.sockets.luna-apps-podman = { systemd.sockets.luna-apps-podman = {
wantedBy = [ "sockets.target" ]; wantedBy = [ "sockets.target" ];
listenStreams = [ "${socketDir}/podman.sock" ]; listenStreams = [ "${socketDir}/podman.sock" ];
@@ -124,9 +112,9 @@ in
# Merges into hermes-agent.nix's container definition. # Merges into hermes-agent.nix's container definition.
virtualisation.oci-containers.containers.hermes-agent = { virtualisation.oci-containers.containers.hermes-agent = {
volumes = [ volumes = [
# The directory, not the socket file: the socket is created by systemd # Mounts the directory, not the socket file — a file bind mount would
# at boot, and a file bind mount would pin whatever inode was there when # pin the inode present at container start, before systemd creates the
# the container started. Read-only still permits connect(). # socket. Read-only still permits connect().
"${socketDir}:${socketDir}:ro" "${socketDir}:${socketDir}:ro"
"${config.virtualisation.podman.package}/bin/podman:/usr/local/bin/podman:ro" "${config.virtualisation.podman.package}/bin/podman:/usr/local/bin/podman:ro"
"${readme}:/opt/data/sites-README.md:ro" "${readme}:/opt/data/sites-README.md:ro"
@@ -163,16 +151,13 @@ in
description = "Turn luna's site registry into caddy routes"; description = "Turn luna's site registry into caddy routes";
# Also runs once at boot, for edits made while nothing was watching. # Also runs once at boot, for edits made while nothing was watching.
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
# After caddy, so the reload below never races caddy's own start. Nothing # After caddy, so the reload below can't race caddy's own start; nothing
# orders caddy after THIS unit, which is what keeps the blocking # orders caddy after this unit, so that reload never waits on its own.
# `systemctl reload caddy` from waiting on its own start job.
after = [ "caddy.service" ]; after = [ "caddy.service" ];
# No start rate limit. The default (5 starts in 10s) is hit by nothing # No start rate limit: the default (5/10s) trips from just a handful of
# more than a handful of quick writes — the VM test does exactly that — # quick writes and permanently disables luna-sites.path (unit-start-
# and when it is, systemd also fails luna-sites.path for good # limit-hit) until someone runs reset-failed. Bursts are absorbed by the
# (unit-start-limit-hit): every later registration is silently ignored # script's own debounce instead.
# until someone runs reset-failed. Bursts are absorbed by the debounce at
# the top of the script instead.
startLimitIntervalSec = 0; startLimitIntervalSec = 0;
path = [ pkgs.jq pkgs.util-linux pkgs.diffutils config.services.caddy.package ]; path = [ pkgs.jq pkgs.util-linux pkgs.diffutils config.services.caddy.package ];
# caddy validate wants somewhere to write its data/config dirs. # caddy validate wants somewhere to write its data/config dirs.
@@ -195,9 +180,9 @@ in
script = '' script = ''
set -euo pipefail set -euo pipefail
# Everything that touches luna's tree runs as the container's uid, never # Runs as the container's uid, never root — she controls every path
# as root: she controls every path under it, including swapping one for # under it, including swapping one for a symlink between a check here
# a symlink into /etc between a check here and its use. # and its use.
as_luna() { setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups -- "$@"; } as_luna() { setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups -- "$@"; }
if [ ! -d ${hermesHome} ]; then if [ ! -d ${hermesHome} ]; then
@@ -313,15 +298,14 @@ in
publish_report publish_report
} }
# Debounce: writes usually come in bursts (several files, or an editor's # Debounce: any trigger landing while this oneshot is still activating
# write-then-rename), and every trigger that lands while this oneshot # merges into the same start job, so one second collapses a burst of
# is still activating merges into this same start job instead of # writes (several files, an editor's write-then-rename) into one run.
# queuing another. One second collapses a burst into one run.
sleep 1 sleep 1
# That merging also means an entry written mid-run would otherwise wait # That same merging means an entry written mid-run would otherwise wait
# for the next unrelated change. Compare the registry before and after, # for the next unrelated trigger, so compare the registry before/after
# and go again. Bounded, so a writer in a loop cannot pin the unit. # and rerun if it changed — bounded, so a writer in a loop can't pin it.
for attempt in 1 2 3 4 5; do for attempt in 1 2 3 4 5; do
before=$(entries) before=$(entries)
generate generate
+26 -51
View File
@@ -22,25 +22,16 @@
password=${config.sops.placeholder.samba_password} password=${config.sops.placeholder.samba_password}
''; '';
# Hermes Agent (hermes-agent.nix) — moved here from jupiter (see that # Hermes Agent (hermes-agent.nix) — same Telegram bot token, opencode key,
# host's git history); same Telegram bot token, opencode key, and # and Authentik OIDC client secret as it used before moving here from
# Authentik OIDC client secret, so no new bot/app to provision. # jupiter, so no new bot/app to provision.
sops.secrets.opencode_go_api_key = { }; sops.secrets.opencode_go_api_key = { };
sops.secrets.telegram_bot_token = { }; sops.secrets.telegram_bot_token = { };
sops.secrets.hermes_dashboard_oidc_client_secret = { }; sops.secrets.hermes_dashboard_oidc_client_secret = { };
# Same value as in secrets/jupiter.yaml (the sending side), stored WITHOUT a # Same value as secrets/jupiter.yaml (the sending side), stored WITHOUT a
# trailing newline — a stray newline would change the key the HMAC is # trailing newline — a stray newline would change the HMAC key and fail
# computed with and fail every delivery. `scripts/edit_secrets` writes a # every delivery. Written host-side by hermes-agent-webhook-routes, so it
# bare value. hermes-agent.nix trims one anyway, belt and braces. # no longer needs to sit in the container's env where luna could read it.
#
# This is NOT in the container's env any more. It used to be, because
# hermes-agent-webhook-route ran `hermes webhook subscribe` inside the
# container and read the secret back out of its environment — which meant
# podman-hermes-agent had to be restarted first on rotation, or the
# subscription silently pinned the stale value. The route config is now
# written host-side (hermes-agent-webhook-routes reads this file directly),
# so that ordering constraint is gone and the secret no longer sits in an
# env var luna can read with `env`.
sops.secrets.gitea_hermes_webhook_secret = { sops.secrets.gitea_hermes_webhook_secret = {
restartUnits = [ "hermes-agent-webhook-routes.service" ]; restartUnits = [ "hermes-agent-webhook-routes.service" ];
}; };
@@ -54,47 +45,31 @@
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret} HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
''; '';
# luna's own gitea push token (services/dev/gitea.nix provisions the # luna's gitea push token (services/dev/gitea.nix provisions the account +
# account + PR-tier repo access on jupiter; this is the per-user token # PR-tier access), generated once via `gitea admin user generate-access-token
# generated once via `gitea admin user generate-access-token --username # --username luna --scopes write:repository,read:user` on jupiter — read:user
# luna --scopes write:repository,read:user` on jupiter — read:user is # is required or `tea logins add` fails. restartUnits re-provisions the git
# required, `tea logins add` fails without it). Read directly by # credential-store file and `tea` login on rotation, without a full deploy.
# hermes-agent.nix's prepare-dirs oneshot (default root:root owner is
# fine — that oneshot already runs as root) to set up a git
# credential-store file and a `tea` login, both written into hermesHome
# so they're visible inside the container at /opt/data/....
# restartUnits re-provisions both on rotation, without a full mars deploy.
sops.secrets.gitea_luna_token.restartUnits = [ "hermes-agent-prepare-dirs.service" ]; sops.secrets.gitea_luna_token.restartUnits = [ "hermes-agent-prepare-dirs.service" ];
# livesync-bridge (livesync-bridge.nix) — luna's Obsidian vault, mirrored # livesync-bridge (livesync-bridge.nix) — luna's Obsidian vault, mirrored
# out of CouchDB on jupiter. Both values are consumed by the rendered # from CouchDB on jupiter. Consumed only via the rendered config.json, so
# config.json rather than read directly, so the sops default of root:root # the sops default of root:root 0400 is fine here.
# 0400 is correct here; only the TEMPLATE needs an owner (set where it is
# defined, next to the vault path it references).
# #
# couchdb_luna_password holds jupiter's `obsidian` ADMIN password — the same # ⚠️ couchdb_luna_password is jupiter's `obsidian` ADMIN password (same as
# value as secrets/jupiter.yaml's couchdb_admin_password and # secrets/jupiter.yaml's couchdb_admin_password) and obsidian_luna_passphrase
# obsidian_luna_passphrase is the same passphrase as the personal vault. # reuses the personal vault's passphrase — reusing what already existed, but
# That is a deliberate choice to reuse what already existed, but it is worth # it means mars (running an autonomous agent) can decrypt and read EVERY
# being clear about what it costs: mars can decrypt and read EVERY vault # vault database, not just luna's. To shrink that blast radius: give luna's
# database, not just luna's, and mars is the box running an autonomous # vault its own passphrase, and/or scope a CouchDB account to her database
# agent. The two are independent to fix, cheapest first: # via _security (README -> "Obsidian vaults"). Neither is required for the
# # bridge to work.
# 1. A vault-specific passphrase (re-encrypts luna's remote database, but
# leaves the personal vault's contents unreadable from here).
# 2. A CouchDB account scoped to luna's database via _security (three curl
# calls, in README -> "Obsidian vaults"), which also stops mars from
# reaching the other databases at all.
#
# Neither is required for the bridge to work; both shrink the blast radius
# if mars is ever compromised.
sops.secrets.couchdb_luna_password = { }; sops.secrets.couchdb_luna_password = { };
# The E2EE passphrase for luna's vault, as entered in the Obsidian plugin. # The E2EE passphrase for luna's vault, as entered in the Obsidian plugin.
# Vault passphrases otherwise never leave the clients (see the note in # Vault passphrases otherwise never leave the clients (obsidian-livesync.nix)
# services/dev/obsidian-livesync.nix) — this one has to be here because mars # — this has to be here because mars IS a client, decrypting to write real
# IS a client: it decrypts in order to write real markdown to disk. Path # markdown to disk. Also feeds the bridge's separate obfuscatePassphrase
# obfuscation uses the same passphrase in the plugin, so the bridge's # field, since the plugin derives path obfuscation from the same value.
# separate obfuscatePassphrase field is fed from this one value.
sops.secrets.obsidian_luna_passphrase = { }; sops.secrets.obsidian_luna_passphrase = { };
} }
+8 -9
View File
@@ -16,26 +16,25 @@
networking.hostName = "mercury"; networking.hostName = "mercury";
# ---- Static networking ---- # ---- Static networking ----
# A DNS/DHCP server must have a fixed address. Fill in the Pi's real values # A DNS/DHCP server needs a fixed address (values from `ip -brief a` / `ip
# (from `ip -brief a` / `ip route` on the running Pi). eth0 = the Pi's NIC. # route` on the running Pi; eth0 is its NIC).
networking.useDHCP = false; networking.useDHCP = false;
networking.usePredictableInterfaceNames = false; # keep it named eth0 networking.usePredictableInterfaceNames = false; # keep it named eth0
networking.interfaces.eth0.ipv4.addresses = [ networking.interfaces.eth0.ipv4.addresses = [
{ address = "10.0.0.10"; prefixLength = 24; } # the Pi's current IP { address = "10.0.0.10"; prefixLength = 24; } # the Pi's current IP
]; ];
# Stable IPv6 (FRITZ!Box ULA prefix) so mercury is a fixed IPv6 DNS target. # Stable IPv6 (FRITZ!Box ULA prefix) so mercury is a fixed IPv6 DNS target
# SLAAC still provides the GUA + default route. Announce THIS address as the # SLAAC still handles the GUA + default route. Announce this address as the
# DNSv6 server in the FRITZ!Box so IPv6 clients resolve .sol via pihole. # FRITZ!Box's DNSv6 server so IPv6 clients resolve .sol via pihole.
networking.interfaces.eth0.ipv6.addresses = [ networking.interfaces.eth0.ipv6.addresses = [
{ address = "fd18:df17:9078:0::10"; prefixLength = 64; } { address = "fd18:df17:9078:0::10"; prefixLength = 64; }
]; ];
networking.defaultGateway = { address = "10.0.0.1"; interface = "eth0"; }; networking.defaultGateway = { address = "10.0.0.1"; interface = "eth0"; };
networking.nameservers = [ "1.1.1.1" "9.9.9.9" ]; networking.nameservers = [ "1.1.1.1" "9.9.9.9" ];
# Never take the tailnet's DNS on THIS host: headscale points every node at # Never take the tailnet's DNS here: headscale points every node at pihole,
# pihole, which runs here — mercury would be resolving through itself. Keep # which runs on this host, so mercury would resolve through itself — keep
# the public resolvers above for the Pi's own lookups, exactly as the # the public resolvers above for its own lookups.
# unbound resolveLocalQueries note in CLAUDE.md requires.
services.tailscale.extraUpFlags = [ "--accept-dns=false" ]; services.tailscale.extraUpFlags = [ "--accept-dns=false" ];
# ---- pihole web admin password (from sops) ---- # ---- pihole web admin password (from sops) ----
+4 -5
View File
@@ -2,11 +2,10 @@
# sops-nix wiring for mercury. Encrypted values in ../../secrets/mercury.yaml. # sops-nix wiring for mercury. Encrypted values in ../../secrets/mercury.yaml.
# #
# SD images have no `--extra-files` step, so mercury uses a DEDICATED age key # SD images get no `--extra-files` step, so mercury uses a dedicated age key
# placed on the ROOT filesystem (the Pi's vfat partition isn't mounted at # on the root filesystem instead of the admin key — the Pi's vfat boot
# runtime u-boot reads it pre-boot). `./deploy flash mercury <dev>` drops # partition isn't mounted at runtime (u-boot reads it pre-boot), so the key
# ~/.config/homelab/mercury/age.txt there automatically. # can't live there.
# The key never enters the repo, the nix store, or the image itself.
{ {
sops.defaultSopsFile = ../../secrets/mercury.yaml; sops.defaultSopsFile = ../../secrets/mercury.yaml;
sops.age.keyFile = "/var/lib/sops-nix/age.txt"; sops.age.keyFile = "/var/lib/sops-nix/age.txt";
+22 -62
View File
@@ -43,30 +43,19 @@
# default via fe80::1 dev eth0 metric 1024 onlink # default via fe80::1 dev eth0 metric 1024 onlink
networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; }; networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; };
networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ]; networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ];
# Addressing is fully static above, but netcup's router still sends periodic # netcup's router still sends periodic RAs on this segment despite fully static
# RAs on this segment; the kernel then tries (and fails, since the static # addressing, spamming "ndisc_router_discovery failed to add default route" on the
# route already exists) to install its own default route from them, spamming # console. Stop processing RAs on eth0 entirely instead of living with the noise.
# "ndisc_router_discovery failed to add default route" on the console. Stop
# it from processing RAs on eth0 at all rather than just live with the noise.
boot.kernel.sysctl."net.ipv6.conf.eth0.accept_ra" = 0; boot.kernel.sysctl."net.ipv6.conf.eth0.accept_ra" = 0;
# ---- Local split-DNS stub ---- # ---- Local split-DNS stub ----
# neptun must NOT take the tailnet's DNS: headscale points every node at # neptun must NOT take the tailnet's DNS: headscale points every node at pihole on
# pihole on mercury, and making a public reverse proxy's name resolution # mercury, and a public reverse proxy depending on a Pi on a domestic line for name
# depend on a Pi behind a domestic line would take ACME renewals — and so # resolution (and thus for its own ACME renewals) would be fragile and circular.
# the certs for the control server every node needs — down with it. It is # It opts out (--accept-dns=false) and runs its own split DNS instead: dnsmasq
# also circular, since tailscaled has to resolve vpn.mgaction.town to # forwards the tailnet suffix to MagicDNS (100.100.100.100, still answered by
# connect in the first place. # tailscaled) and everything else to the public resolvers above — jupiter's address
# # is resolved live, never pinned.
# So neptun opts out with --accept-dns=false and does its own split DNS.
# tailscaled still answers MagicDNS on 100.100.100.100 whenever it is
# running (--accept-dns only governs whether it rewrites resolv.conf), so
# dnsmasq forwards just the tailnet suffix there and everything else to the
# public resolvers above. jupiter's address is therefore resolved live and
# never pinned — nothing to update when the tailnet is rebuilt.
#
# resolveLocalQueries (default) points resolv.conf at 127.0.0.1 and feeds
# networking.nameservers to dnsmasq as upstreams via resolvconf.
services.tailscale.extraUpFlags = [ "--accept-dns=false" ]; services.tailscale.extraUpFlags = [ "--accept-dns=false" ];
services.dnsmasq = { services.dnsmasq = {
enable = true; enable = true;
@@ -112,49 +101,20 @@
''; '';
# ---- Obsidian LiveSync (CouchDB on jupiter) ---- # ---- Obsidian LiveSync (CouchDB on jupiter) ----
# Obsidian's mobile apps refuse cleartext HTTP and *.jupiter.sol cannot hold # Published publicly (mobile apps refuse cleartext HTTP; *.jupiter.sol has no public
# a publicly trusted cert, so the vault database is published here instead of # cert), kept safe by the plugin's end-to-end encryption (jupiter stores only
# staying on the LAN. That means a credentialed database on the open # ciphertext) plus this allowlist — CouchDB otherwise exposes Fauxton, /_all_dbs and
# internet; two things keep it sane: # /_node/_local/_config, the last of which can rewrite the server's config with admin
# creds. Use the tailnet directly for those: `curl http://jupiter.orbit.sol:5984/_utils/`.
# #
# 1. The plugin's end-to-end encryption, switched on BEFORE the first sync. # The regex keys off CouchDB's own naming rule (system paths start with `_`, user
# jupiter then stores only ciphertext, so a breach here is not a leak of # databases can't) rather than listing vaults, plus `_session` for cookie auth — so a
# the notes themselves. # mistyped-but-legal name reaches CouchDB (real 404) while an illegal one gets
# 2. This allowlist. CouchDB serves far more than the replication API — # caddy's 404 with no CORS, which Obsidian shows as a silent connection failure.
# Fauxton (/_utils), /_all_dbs, and /_node/_local/_config, the last of # Never point two vaults at the same database (LiveSync merges them, not reversibly).
# which REWRITES the server's config given admin credentials. Only the
# paths the plugin actually speaks are proxied; everything else is
# answered here and never reaches jupiter. Use the tailnet for the rest:
# `curl http://jupiter.orbit.sol:5984/_utils/`.
# #
# ONE DATABASE PER VAULT, and the matcher keys off CouchDB's own naming rule # `flush_interval -1` is required, not tuning — replication rides a continuous
# rather than listing them: every system endpoint begins with `_`, and a # _changes feed that caddy would otherwise buffer, stalling sync.
# user-creatable database never can (CouchDB requires a lowercase letter
# first). So adding a vault needs no edit here. `_session` is the single
# underscore path let through, for cookie auth.
#
# The flip side of not listing them: a mistyped but otherwise LEGAL database
# name is proxied through and reaches CouchDB, which answers a real 404 the
# plugin can report. An ILLEGAL one — anything starting with a capital or an
# underscore — fails the matcher instead and gets caddy's 404, which carries
# no CORS headers and surfaces in Obsidian as a connection failure with no
# error message at all. If a new vault refuses to connect and the plugin
# says nothing, check the database name is lowercase first.
#
# Never point two vaults at one database: LiveSync merges them into a single
# file tree, which is not cleanly reversible.
#
# Known consequence: LiveSync's "Check database configuration" panel reads
# /_node/_local/_config and so reports the server as unconfigured from
# outside. Expected — that config is declarative in
# services/dev/obsidian-livesync.nix and is not the plugin's to patch.
#
# `flush_interval -1` is required, not tuning: replication rides a
# continuous _changes feed, which caddy would otherwise buffer — sync then
# stalls until the buffer fills (same reason vpn.mgaction.town sets it).
#
# No netcup edge-firewall change: this rides the 443 the other vhosts
# already use, unlike gitea's :2222.
services.caddy.virtualHosts."notes.mgaction.town".extraConfig = '' services.caddy.virtualHosts."notes.mgaction.town".extraConfig = ''
@livesync path_regexp ^/(_session|[a-z][a-z0-9_$()+-]*)?(/.*)?$ @livesync path_regexp ^/(_session|[a-z][a-z0-9_$()+-]*)?(/.*)?$
handle @livesync { handle @livesync {
+11 -18
View File
@@ -14,13 +14,11 @@
sops.secrets.darman_password.neededForUsers = true; sops.secrets.darman_password.neededForUsers = true;
users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path; users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path;
# Authentik takes a single systemd EnvironmentFile (services/identity/authentik.nix). # Authentik takes a single systemd EnvironmentFile (services/identity/authentik.nix);
# No `owner` here on purpose: systemd reads EnvironmentFile as root before # no `owner` here on purpose, since systemd reads it as root before dropping to
# dropping to the service's DynamicUser, so root:root 0400 is what we want. # DynamicUser. AUTHENTIK_SECRET_KEY signs sessions (rotating it logs everyone out);
# # the BOOTSTRAP_* vars only matter on the very first start (create `akadmin`) and are
# AUTHENTIK_SECRET_KEY signs sessions/tokens — rotating it logs everyone out. # inert after.
# The BOOTSTRAP_* vars only take effect on the very first start, where they
# create the `akadmin` superuser; they're inert on every boot after that.
sops.secrets.authentik_secret_key = { }; sops.secrets.authentik_secret_key = { };
sops.secrets.authentik_bootstrap_password = { }; sops.secrets.authentik_bootstrap_password = { };
sops.secrets.authentik_bootstrap_email = { }; sops.secrets.authentik_bootstrap_email = { };
@@ -38,17 +36,12 @@
ACME_EMAIL=${config.sops.placeholder.caddy_acme_email} ACME_EMAIL=${config.sops.placeholder.caddy_acme_email}
''; '';
# Headplane: cookie_secret_path takes a path natively (no store leak). # Headplane's cookie_secret_path takes a path natively (no store leak); oidc.client_secret
# oidc.client_secret + the headscale API key are still REPLACE_ME # and the headscale API key are still REPLACE_ME placeholders (services/vpn/headplane.nix)
# placeholders (see services/vpn/headplane.nix) until Authentik/headscale are # until Authentik/headscale are deployed for real. Unlike authentik's EnvironmentFile,
# actually deployed and those get created for real. # headscale/headplane open these paths themselves as the headscale user, so each needs
# # an explicit owner — and headscale's OIDC client is a separate Authentik app from
# owner: unlike authentik's EnvironmentFile above, headscale and headplane # headplane's, hence the second client secret.
# open these paths themselves, already running as the headscale user — so
# the root:root 0400 default would fail and each needs an explicit owner.
#
# headscale's OIDC client is a SEPARATE Authentik application from
# headplane's (services/vpn/headscale.nix), hence the second client secret.
sops.secrets.headscale_oidc_client_secret.owner = "headscale"; sops.secrets.headscale_oidc_client_secret.owner = "headscale";
sops.secrets.headplane_cookie_secret.owner = "headscale"; sops.secrets.headplane_cookie_secret.owner = "headscale";
+34 -75
View File
@@ -44,17 +44,9 @@ in
# https://nix.dev/permalink/stub-ld ---- # https://nix.dev/permalink/stub-ld ----
programs.nix-ld.enable = true; programs.nix-ld.enable = true;
# The default set above is deliberately minimal and carries no X11, # JetBrains IDEs installed via Toolbox bundle a JBR that aborts with
# freetype, wayland or xkbcommon, so a prebuilt *graphical* binary dies # `libX11.so.6: cannot open shared object file` under the default (X11-less)
# before it draws anything. JetBrains IDEs installed through Toolbox are the # nix-ld set. Additive — merges with the module's own base list (zlib etc).
# case that surfaced this: their bundled JBR aborts with `libX11.so.6:
# cannot open shared object file` unless the Toolbox GUI — itself an FHS
# wrapper — is what launches them, which makes them unusable from a terminal
# or from a per-repo devShell. These are the libraries `ldd` reports missing
# across a JBR's own .so files, plus the three it resolves by dlopen rather
# than DT_NEEDED: fontconfig for font discovery, libGL, and libsecret for
# the credential store. Definitions merge, so this adds to the module's base
# list rather than replacing it (zlib is already there).
programs.nix-ld.libraries = with pkgs; [ programs.nix-ld.libraries = with pkgs; [
freetype freetype
fontconfig fontconfig
@@ -72,28 +64,19 @@ in
libxinerama libxinerama
libxcb libxcb
# CLion Nova's C++ backend (the clion-radler plugin) is a .NET 10 # CLion Nova's C++ backend is a .NET 10 app that needs ICU or reports
# application bundling its own runtime, and .NET refuses to start # "Couldn't find a valid ICU package installed on the system".
# without ICU: libSystem.Globalization.Native.so dlopens libicuuc.so
# and libicui18n.so, and failing that the IDE reports "Couldn't find a
# valid ICU package installed on the system" and comes up degraded.
icu icu
]; ];
# ---- envfs: serves /bin and /usr/bin from the calling process's PATH ---- # NixOS only ships /bin/sh; envfs serves /bin and /usr/bin from PATH so
# NixOS ships only /bin/sh, but plenty of third-party tooling writes scripts # third-party scripts hardcoding `#!/bin/bash` (e.g. JetBrains Toolbox's
# with a hardcoded interpreter. JetBrains Toolbox is the standing example: # generated launchers) still resolve.
# it generates ~/.local/share/JetBrains/Toolbox/scripts/{clion,rider,...}
# with `#!/bin/bash`, so every one of those shims fails with `bad
# interpreter` in any shell. envfs resolves such shebangs against PATH,
# which fixes them all at once instead of per-IDE wrappers.
services.envfs.enable = true; services.envfs.enable = true;
# ---- home-manager (user-level config for darman) ---- # ---- home-manager (user-level config for darman) ----
# Base settings (useGlobalPkgs/useUserPackages/backupFileExtension) and the # Base settings + shared zsh baseline live in common.nix + home/common.nix
# shared zsh baseline now live in common.nix + home/common.nix, applied to # (every host); this layers terra's desktop profile on top (imports merge).
# every host. This just layers terra's desktop/dev-specific profile on top
# — home-manager.users.darman.imports merges additively across modules.
home-manager.extraSpecialArgs = { inherit unstable inputs; }; home-manager.extraSpecialArgs = { inherit unstable inputs; };
home-manager.users.darman.imports = [ ./home.nix ]; home-manager.users.darman.imports = [ ./home.nix ];
@@ -102,71 +85,47 @@ in
boot.loader.efi.canTouchEfiVariables = true; boot.loader.efi.canTouchEfiVariables = true;
hardware.cpu.amd.updateMicrocode = true; hardware.cpu.amd.updateMicrocode = true;
# mercury (aarch64) is built/flashed from here. Without this, `nix build` # Lets `nix build` target mercury (aarch64) from here — see CLAUDE.md's
# for it dies with "platform mismatch" — no qemu binfmt handler registered # aarch64 gotcha.
# and aarch64-linux missing from nix.settings.extra-platforms. This module
# sets up both (see CLAUDE.md's aarch64 gotcha).
boot.binfmt.emulatedSystems = [ "aarch64-linux" ]; boot.binfmt.emulatedSystems = [ "aarch64-linux" ];
# ---- GPU (Radeon RX 6800 XT / Navi 21) ---- # ---- GPU (Radeon RX 6800 XT / Navi 21) ----
hardware.enableRedistributableFirmware = true; hardware.enableRedistributableFirmware = true;
boot.initrd.kernelModules = [ "amdgpu" ]; boot.initrd.kernelModules = [ "amdgpu" ];
# /dev/dri/renderD128 is root:render 0660, so rootless podman containers can # /dev/dri/renderD128 is root:render 0660 — host user needs render group for
# only reach the GPU if the *host* user is in render. Needed by the Vulkan # rootless podman GPU containers (Vulkan whisper.cpp/llama.cpp).
# whisper.cpp/llama.cpp containers in ~/Data/Dev/repos/content-trigger-scanner.
users.users.darman.extraGroups = [ "render" "video" ]; users.users.darman.extraGroups = [ "render" "video" ];
# ---- ollama (local LLM server, ROCm on the 6800 XT) ---- # ---- ollama (local LLM server, ROCm on the 6800 XT) ----
# Navi 21 is gfx1030 officially supported by ROCm, so no # Navi 21 (gfx1030) is officially ROCm-supported, so no
# rocmOverrideGfx/HSA_OVERRIDE_GFX_VERSION needed (that's for gpus ROCm # HSA_OVERRIDE_GFX_VERSION needed. Upstream module already runs under
# doesn't recognize, e.g. RDNA1/gfx101x). The upstream module runs the # DynamicUser with render/kfd/drm access wired, unlike jellyfin's static user.
# service under DynamicUser with SupplementaryGroups=["render"] and
# DeviceAllow for char-kfd/char-drm/char-fb already, so unlike jellyfin's
# static user it needs no extraGroups wiring here.
services.ollama = { services.ollama = {
enable = true; enable = true;
package = pkgs.ollama-rocm; package = pkgs.ollama-rocm;
# keep in sync with services/desktop/librechat.nix's endpoints.custom # keep default model in sync with services/desktop/librechat.nix's
# default model — LibreChat's config schema needs a non-empty default # endpoints.custom default (its schema needs a non-empty value even
# even though fetch=true replaces it with whatever's actually pulled. # though fetch=true overrides it).
# gemma4:12b: general chat/coding daily driver, fits fully in 16G VRAM # gemma4:12b: daily-driver chat/coding model, fits fully in 16G VRAM; also
# also doubles as the memory-extraction agent (see librechat.nix): a # doubles as LibreChat's memory-extraction agent (librechat.nix) since a
# 3b model (llama3.2:3b, dropped) couldn't reliably tell the user's # smaller model confused the user's stated facts with its own boilerplate.
# stated facts apart from its own boilerplate, e.g. saving "I am an AI # qwen3.6:35b-a3b: MoE (3B active/36B total, ~24GB Q4_K_M) — doesn't fit
# assistant with tool calling capabilities" as the user's personal_info # in VRAM alone, so ollama offloads inactive experts to CPU RAM; sparsity
# after "Hi I'm Erik Simon". Reusing gemma4:12b for both roles also means # makes that less painful than for a dense model this size, but still slower.
# no second model needs to swap into VRAM while it's already the active # VladimirGav/qwen3.8-27B-14GB-IQ4: dense 27B at IQ4 (~14GB) — nominally
# chat model. # fits the 16G card but leaves little headroom, so expect partial CPU
# qwen3.6:35b-a3b: MoE (3B active/36B total), ~24GB Q4_K_M — doesn't fit # offload as context grows.
# in VRAM alone, so ollama offloads the inactive experts to CPU RAM.
# Sparse activation makes that far less painful than it'd be for a dense
# model this size, but still expect it to run slower than the two above.
# VladimirGav/qwen3.8-27B-14GB-IQ4: dense 27B at IQ4, ~14GB of weights —
# nominally fits the 6800 XT's 16G, but that leaves only ~2G for the KV
# cache and the compositor, so expect partial CPU offload as context grows
# (OLLAMA_CONTEXT_LENGTH below applies to every model on this server).
loadModels = [ loadModels = [
"gemma4:12b" "gemma4:12b"
"qwen3.6:35b-a3b" "qwen3.6:35b-a3b"
"VladimirGav/qwen3.8-27B-14GB-IQ4" "VladimirGav/qwen3.8-27B-14GB-IQ4"
]; ];
# Ollama truncates context far below the model's real window unless # Ollama truncates context far below a model's real window unless told
# told otherwise (the OpenAI-compat /v1 route it's reached through has # otherwise. 131072 is the practical ceiling from load-testing: VRAM stays
# no way to set this per-request). 131072 chosen as the practical # 100% GPU with no CPU spillover up to here, but headroom and prefill
# ceiling after load-testing with real prompts, not just idle # throughput both degrade near the top — going higher risks CPU spillover
# `ollama ps` checks: # under concurrent GPU load (compositor, jellyfin transcode) for little gain.
# 32768 (31.6k-token prompt) and 65536 (40.8k-token prompt) both stayed
# 100% GPU with VRAM barely moving (~10.1G / ~10.67G of 16G) — KV cache
# cost barely grows with context, likely sliding-window/local attention
# on most of gemma4:12b's layers. At 131072 that stopped being true: a
# ~108k-token prompt pushed VRAM to ~11.4G/16G (still 100% GPU, no CPU
# spillover, negligible GTT) but with visibly shrinking headroom, and
# prefill throughput measurably dropped (~490 -> ~460 tok/s) over just
# the last 13k tokens — filling the full window would take minutes of
# pure prompt processing. Stopped here rather than push further: next
# doubling would risk CPU spillover under any concurrent GPU load
# (desktop compositor, jellyfin transcode) for diminishing benefit.
environmentVariables.OLLAMA_CONTEXT_LENGTH = "131072"; environmentVariables.OLLAMA_CONTEXT_LENGTH = "131072";
}; };
+9 -23
View File
@@ -5,27 +5,14 @@
# `fileSystems.*` entries, so hardware-configuration.nix must NOT define # `fileSystems.*` entries, so hardware-configuration.nix must NOT define
# fileSystems for "/" or "/boot". # fileSystems for "/" or "/boot".
# #
# ⚠️ disko's `mkfs` create step SKIPS formatting when `blkid` still detects a # ⚠️ disko's `mkfs` step skips formatting if `blkid` still detects a
# filesystem signature on the freshly-cut partition: # filesystem signature on the partition. Repartitioning doesn't erase
# # signatures at the new offsets, so this disk's old CachyOS btrfs
# if ! (blkid "$device" -o export | grep -q '^TYPE='); then # superblock survived, causing mkfs (and the ESP's mkfs.vfat) to be
# mkfs.btrfs "$device" -f # ← -f only runs WHEN this line runs # skipped and the later mount to fail on the stale superblock.
# fi # Fix: `preCreateHook = wipefs --all --force "$device"` on each
# # partition — it runs after sgdisk re-cuts the partition but before the
# The disk previously held a CachyOS btrfs root. The whole-disk `wipefs` # `blkid` guard, so the guard sees no signature and `mkfs` actually runs.
# disko runs before partitioning clears the signature at the OLD layout's
# offsets, but `sgdisk --clear --align-end` then re-cuts the partitions, so
# a stale btrfs superblock survives at the NEW root partition's own 64 KiB
# offset. `blkid` sees TYPE=btrfs, `mkfs` is skipped entirely, and the
# later `mount` fails on the leftover bytes ("wrong fs type / bad
# superblock"). Switching ext4→btrfs did NOT fix this: `mkfs.btrfs -f` is
# never reached, because the guard is on whether `mkfs` runs at all, not on
# its flags. The ESP hits the same trap (its `mkfs.vfat` gets skipped too).
#
# Fix: `preCreateHook = wipefs --all --force "$device"` on each partition's
# content. The hook runs AFTER sgdisk re-cuts the partition but BEFORE the
# `blkid` guard, so it erases the stale signature at the FINAL offset;
# `blkid` then comes back empty and `mkfs` actually runs.
# #
# ⚠️ This disk is WIPED on install. This is the Kingston SA400 SSD that # ⚠️ This disk is WIPED on install. This is the Kingston SA400 SSD that
# currently holds CachyOS (btrfs root+subvols on sdb2, ESP on sdb1). # currently holds CachyOS (btrfs root+subvols on sdb2, ESP on sdb1).
@@ -58,8 +45,7 @@
type = "btrfs"; type = "btrfs";
extraArgs = [ "-f" ]; extraArgs = [ "-f" ];
mountpoint = "/"; mountpoint = "/";
# erase the stale CachyOS btrfs superblock before disko's blkid # same wipefs fix as the ESP above (see header comment)
# format-guard, otherwise mkfs.btrfs is skipped (see header comment)
preCreateHook = ''wipefs --all --force "$device"''; preCreateHook = ''wipefs --all --force "$device"'';
}; };
}; };
+12 -20
View File
@@ -2,23 +2,17 @@
let let
tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; }; tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; };
# SUDO_ASKPASS helper: renders sudo's password prompt in the quickshell # SUDO_ASKPASS helper: shows sudo's password prompt in quickshell
# shell (HyprChrome/Widgets/Askpass) instead of on the terminal. # (HyprChrome/Widgets/Askpass) instead of the terminal. sudo doesn't speak
# polkit (setuid + PAM reading the tty), so this reuses the polkit dialog's
# look via the askpass mechanism instead — `run0` is the actual polkit-native
# alternative.
# #
# sudo does NOT speak polkit — it is setuid + PAM reading the tty, and no # Must be a package, not a dotfiles file: SUDO_ASKPASS needs an executable,
# sudoers option bridges the two — so this is the askpass mechanism, a # and xdg.configFile copies keep store-copy permissions.
# separate path that happens to reuse the polkit dialog's look. `run0` is the
# polkit-native alternative if you want the agent itself.
# #
# A package rather than a file in dotfiles/quickshell because SUDO_ASKPASS # The secret returns over a 0600 fifo (never argv/env, so not visible in
# must point at something EXECUTABLE, and xdg.configFile copies keep their # /proc); cancelling closes the fifo unwritten so sudo aborts cleanly.
# store mode — which is why open_launcher.sh has to be invoked as
# `bash <path>` rather than run directly.
#
# The secret comes back over a 0600 fifo, never in argv or the environment,
# so it is not visible in /proc to anything. Cancelling closes the fifo
# without writing: `cat` reads nothing, this exits non-zero, and sudo aborts
# instead of burning a retry on an empty password.
qs-askpass = pkgs.writeShellApplication { qs-askpass = pkgs.writeShellApplication {
name = "qs-askpass"; name = "qs-askpass";
runtimeInputs = [ pkgs.quickshell pkgs.coreutils ]; runtimeInputs = [ pkgs.quickshell pkgs.coreutils ];
@@ -74,11 +68,9 @@ in
nix-direnv.enable = true; nix-direnv.enable = true;
}; };
# Rootless podman: containers run as darman, not root. services/containers.nix # Rootless podman runs containers as darman; compose v2 talks to a socket
# gives us the `docker` CLI shim (dockerCompat), but compose v2 is a separate # rather than the docker CLI shim, so point it at the user podman socket
# binary and talks to a socket rather than the CLI — the NixOS podman module # instead of the root one.
# enables the *user* socket (systemd.user.sockets.podman), so point compose at
# it instead of the root /var/run/docker.sock.
home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock"; home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock";
# Only sets WHICH helper sudo uses; it still only calls it when asked with # Only sets WHICH helper sudo uses; it still only calls it when asked with
+15 -36
View File
@@ -1,32 +1,16 @@
{ lib, pkgs, config, inputs, ... }: { lib, pkgs, config, inputs, ... }:
# Hyprland config migrated from github.com/darman96/hyprland-dotfiles (the # Hyprland config migrated from github.com/darman96/hyprland-dotfiles into
# hyprlang `hypr/*.conf` files) into the home-manager lua-style `settings` # home-manager's lua-style `settings` (each attr becomes an `hl.<name>(...)`
# (configType defaults to "lua" on stateVersion 26.05). Each top-level # call in hyprland.lua). Imported by home.nix; system-level enable lives in
# `settings` attr becomes an `hl.<name>(...)` call in ~/.config/hypr/hyprland.lua; # ../../services/desktop/desktop-hyprland.nix.
# `_args` lists become multi-arg calls, `_var` locals become `local x = ...`, and
# `lib.generators.mkLuaInline` values render as raw Lua expressions.
# #
# Imported by home.nix. System-level Hyprland enable (session entry, portals) # Not migrated: hyprbars (unpackaged plugin; its successor hypr-chrome is
# lives in ../../services/desktop/desktop-hyprland.nix; this manages the user's # wired in below instead), hyprqt6engine (conflicts with home.nix's qtct/
# own hyprland.lua. # Dracula Qt theming), hyprlock (use programs.hyprlock), the old wob volume
# # overlay (kept only wpctl/playerctl), and Arch-specific env vars. Several
# Deliberately NOT migrated: # binds reference apps not yet packaged here (vivaldi-stable, dolphin,
# - hyprbars.conf: config for the third-party `hyprbevelbars` plugin, which # vicinae, grimblast, waypaper, discord, gitkraken, qbz).
# isn't packaged in nixpkgs. Load it via
# `wayland.windowManager.hyprland.plugins` and re-add its config once
# available. (hyprredsquare.conf's plugin was renamed hypr-chrome and
# rewritten since - it's wired in below via the `hypr-chrome` flake
# input instead, with its own `plugin.hyprchrome` config.)
# - hyprqt6engine.conf + `QT_QPA_PLATFORMTHEME=hyprqt6engine`: terra themes Qt
# through qtct/Dracula in home.nix, so that env var is left off to avoid a conflict.
# - hyprlock.conf: a separate program (use `programs.hyprlock` if wanted).
# - the duplicate pamixer/amixer + `.wob` volume binds: kept only the clean
# pipewire `wpctl`/`playerctl` set (no wob overlay is configured here).
# - `XDG_MENU_PREFIX=arch-` and `VCPKG_ROOT`: Arch-/user-specific.
# Many binds reference apps/scripts not packaged on terra yet (vivaldi-stable,
# dolphin, vicinae, grimblast, waypaper, discord, gitkraken, qbz,
# ~/.config/scripts/start-communications.sh); add them separately.
let let
lua = lib.generators.mkLuaInline; lua = lib.generators.mkLuaInline;
@@ -37,11 +21,9 @@ let
cursorName = config.home.pointerCursor.name; cursorName = config.home.pointerCursor.name;
cursorSize = toString config.home.pointerCursor.size; cursorSize = toString config.home.pointerCursor.size;
# Wallpaper images aren't checked into this repo (binary blobs) — pulled # Wallpapers aren't checked into this repo (binaries) — pulled from the
# from the existing Wallhaven library on /mnt/hdd_01 instead. Picked once # Wallhaven library on /mnt/hdd_01. Picked once here since hyprpaper has
# here rather than at runtime, since hyprpaper has no built-in "random" # no built-in "random" mode.
# mode; re-pick and rebuild (or swap in real per-monitor selection) when
# this stops being a placeholder.
# wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png"; # wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-ym81rl.png";
wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-mlwz78.png"; wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-mlwz78.png";
@@ -317,11 +299,8 @@ in
"hyprland.start" "hyprland.start"
(lua '' (lua ''
function() function()
-- No polkit agent is started here: quickshell registers one -- No polkit agent started here: quickshell registers its own
-- itself (HyprChrome/Widgets/Polkit), and a session admits only -- (HyprChrome/Widgets/Polkit), and a session admits only one.
-- one. The hyprpolkitagent line this replaces had been dead for
-- a while anyway the unit was never installed, so the start
-- failed silently and the session ran with no agent at all.
hl.exec_cmd("cosmic-settings-daemon") hl.exec_cmd("cosmic-settings-daemon")
hl.exec_cmd("quickshell") hl.exec_cmd("quickshell")
hl.exec_cmd("alacritty", { workspace = "special:terminal silent" }) hl.exec_cmd("alacritty", { workspace = "special:terminal silent" })
+8 -13
View File
@@ -23,14 +23,11 @@ in
}; };
}; };
# The cursor theme. XCURSOR_THEME alone is not enough for Steam: the client # XCURSOR_THEME alone isn't enough for Steam: steamwebhelper runs inside a
# UI (steamwebhelper) runs inside a pressure-vessel container that rebuilds # pressure-vessel container with its own /etc, so XCURSOR_PATH doesn't
# /etc, so the /etc/profiles/per-user/darman/share/icons entry of # resolve there and it falls back to the core X11 cursor. $HOME and /nix are
# XCURSOR_PATH does not exist in there and libXcursor finds no theme by # bind-mounted in though, so the ~/.icons symlink `dotIcons` drops still
# that name — it falls back to the built-in core X11 cursor. $HOME and # resolves — same fix as the flatpak workaround below.
# /nix are bind-mounted into the container, so the ~/.icons symlink that
# `dotIcons` (on by default) drops does resolve. Same class of problem as
# the ~/.themes/~/.icons flatpak workaround above.
home.pointerCursor = { home.pointerCursor = {
name = "Bibata-Modern-Classic"; name = "Bibata-Modern-Classic";
package = pkgs.bibata-cursors; package = pkgs.bibata-cursors;
@@ -39,11 +36,9 @@ in
hyprcursor.enable = true; hyprcursor.enable = true;
}; };
# Flatpak apps are sandboxed and can't see XDG_DATA_DIRS/nix-store theme # Flatpak apps can't see XDG_DATA_DIRS/nix-store theme paths, so the
# paths, so the portal-reported GTK theme / icon theme names resolve to # portal-reported theme names resolve to nothing and fall back to Adwaita;
# nothing inside the sandbox and they fall back to Adwaita. Flatpak # Flatpak auto-exposes ~/.themes and ~/.icons read-only as the workaround.
# auto-exposes ~/.themes and ~/.icons read-only to every sandboxed app
# specifically for this case.
home.file.".themes/Dracula".source = home.file.".themes/Dracula".source =
"${pkgs.dracula-theme}/share/themes/Dracula"; "${pkgs.dracula-theme}/share/themes/Dracula";
home.file.".icons/${iconTheme}".source = iconThemeFolder; home.file.".icons/${iconTheme}".source = iconThemeFolder;
+5 -7
View File
@@ -17,13 +17,11 @@ stdenvNoCC.mkDerivation {
dontBuild = true; dontBuild = true;
# Upstream ships a handful of dangling symlinks under mimetypes/16 (e.g. # Upstream ships a handful of dangling symlinks under mimetypes/16 (e.g. a
# libreoffice-spreadsheet.svg -> libreoffice-oasis-spreadsheet.svg, which # target that doesn't exist in that size dir) — harmless, GTK's own
# doesn't exist in that size dir) — a minor packaging bug in the theme # Inherits= chain (breeze-dark, breeze, Adwaita, hicolor) covers the
# itself. Harmless: GTK's icon lookup just falls through to the theme's # fallback. Nixpkgs' default noBrokenSymlinks check would otherwise fail
# own Inherits= chain (breeze-dark, breeze, Adwaita, hicolor) for those few # the build over it.
# mimetypes. Nixpkgs' default noBrokenSymlinks fixup check would otherwise
# fail the whole build over it.
dontCheckForBrokenSymlinks = true; dontCheckForBrokenSymlinks = true;
# gtk3's setup hook strips icon-theme.cache from $out by default # gtk3's setup hook strips icon-theme.cache from $out by default
+5 -9
View File
@@ -21,15 +21,11 @@ stdenvNoCC.mkDerivation {
# the theme's own Inherits= chain (breeze-dark, Adwaita, hicolor) for those. # the theme's own Inherits= chain (breeze-dark, Adwaita, hicolor) for those.
dontCheckForBrokenSymlinks = true; dontCheckForBrokenSymlinks = true;
# index.theme's Directories= lists panel/16@2, panel/22@2, panel/24@2 (with # index.theme's Directories= names panel/16@2 etc. (Scale=2) but the
# Scale=2), but the actual on-disk dirs are named 16@2x/22@2x/24@2x (the # on-disk dirs are 16@2x etc. (the correct suffix) — an upstream typo that
# correct freedesktop-spec suffix) — an upstream index.theme typo. That # makes `gtk-update-icon-cache` exit 1, so this theme ships uncached and
# mismatch makes `gtk-update-icon-cache` refuse to emit ANY cache at all # relies on GTK's live directory scan instead (functionally fine, just not
# (exits 1, "The generated cache was invalid"), so unlike the other vendored # cache-accelerated).
# themes here, this one ships with no icon-theme.cache and relies on GTK's
# live directory-scan lookup instead — functionally fine, just not
# cache-accelerated. gtk3's default postFixup hook (dropIconThemeCache)
# would strip a cache anyway, so there's nothing to opt out of.
installPhase = '' installPhase = ''
runHook preInstall runHook preInstall
mkdir -p "$out/share/icons" mkdir -p "$out/share/icons"
+5 -7
View File
@@ -56,13 +56,11 @@ buildDotnetModule (finalAttrs: {
executables = [ "Tome.App" ]; executables = [ "Tome.App" ];
# wrapGAppsHook3: buildDotnetModule sets dontWrapGApps = true by default (to # buildDotnetModule sets dontWrapGApps = true by default, but wrapGAppsHook3's
# avoid double-wrapping) but its own wrap step still splices gappsWrapperArgs # own wrap step still splices gappsWrapperArgs in when present (same pattern
# in when the hook is present (see nixpkgs' libation package, same pattern). # as nixpkgs' libation). Without it XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR never
# Without it the binary never gets XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR set, so # get set, so GTK/WebKitGTK can't find the icon theme or settings — missing
# GTK/WebKitGTK can't find the icon theme or GTK settings from the desktop # icons, denser default UI font.
# session — symptoms: missing icons and a denser default UI font/size than
# when launched from an already-fully-initialized session (e.g. via Rider).
nativeBuildInputs = [ copyDesktopItems wrapGAppsHook3 ]; nativeBuildInputs = [ copyDesktopItems wrapGAppsHook3 ];
runtimeDeps = [ runtimeDeps = [
+126 -281
View File
@@ -1,106 +1,48 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Deploy a NixOS host from this flake. ALL arguments are mandatory (no defaults). # Deploy a NixOS host from this flake. ALL arguments are mandatory (no defaults).
# #
# ./deploy kexec <config> <host> headless kexec into a RAM installer, for a # ./deploy kexec <config> <host> ZimaOS/RO-root box: kexec into a RAM installer, ships the ssh key, then run `install`.
# read-only-root box (ZimaOS) where # ./deploy kexec-local [--yes] kexec THIS machine (no ssh) into the RAM installer; disks untouched. Then `install <config> localhost`.
# nixos-anywhere can't ssh-copy-id. Ships our
# SSH login key. Then run `install`. <config>
# is only used to look up the vault item.
# ./deploy kexec-local [--yes] kexec THIS machine into the RAM installer,
# no ssh/second machine involved. Run as root,
# locally, on the box you're installing onto.
# Disks are untouched; console drops for
# ~1-2 min then comes back as the installer.
# Prompts for confirmation (--yes skips it),
# because run on the wrong terminal this
# kexecs your laptop. TMPDIR (default
# /var/tmp) must be exec-capable and hold
# ~3x the tarball.
# Then run `install <config> localhost`.
# ./deploy install <config> <host> [--yes] # ./deploy install <config> <host> [--yes]
# first install. Wipes the OS disk. Ships the # first install (wipes the OS disk, ships the host's sops key). localhost only
# host's sops key. <host>=localhost/127.0.0.1 # runs disko/nixos-install directly once already inside a live installer;
# skips nixos-anywhere/ssh and runs disko + # from a real running OS it stages installer-iso and reboots into that instead.
# nixos-install directly against /mnt — but
# ONLY once actually inside a live installer
# (hostname nixos-installer, from kexec, or
# homelab-installer, from installer-iso).
# Run from the REAL running OS instead (e.g.
# a box where kexec-local doesn't work),
# it builds installer-iso, stages its
# kernel/initrd + the host key on the boot
# partition and the iso file on a non-OS-disk
# partition, sets a systemd-boot one-shot
# entry with homelab.install=<config> +
# homelab.keypart=<PARTUUID> on its kernel
# cmdline, and reboots — a real ACPI reboot,
# not a kexec jump. The booted installer's
# homelab-auto-install.service reads those
# cmdline params, picks the host key back up
# and re-runs this exact command itself once
# its repo checkout (homelab-checkout.service)
# succeeds, finishing the install unattended.
# It confirms before rebooting; --yes skips
# that (it is what the ISO passes itself).
# See CLAUDE.md. # See CLAUDE.md.
# ./deploy switch <config> <host> rebuild + activate on a running host. # ./deploy switch <config> <host> rebuild + activate on a running host.
# ./deploy boot <config> <host> stage for next boot, don't activate now. # ./deploy boot <config> <host> stage for next boot, don't activate now.
# ./deploy test <config> <host> activate without adding a boot entry. # ./deploy test <config> <host> activate without adding a boot entry.
# ./deploy image <config> build an SD-card image (e.g. rpi mercury). # ./deploy image <config> build an SD-card image (e.g. rpi mercury).
# ./deploy flash <config> <dev> build SD image, write to <dev>, and (if # ./deploy flash <config> <dev> build SD image, write to <dev>, and drop the sops age key onto it if one exists.
# ~/.config/homelab/<config>/age.txt exists)
# drop the sops key on its boot partition.
# #
# <config> = a nixosConfigurations name (e.g. jupiter, vps). Its pre-generated # <config> = a nixosConfigurations name. Its pre-generated SSH host key must be
# SSH host key must be at ~/.config/homelab/<config>/ssh_host_ed25519_key. # at ~/.config/homelab/<config>/ssh_host_ed25519_key. Runs from a non-NixOS host too.
# #
# Runs from a non-NixOS host too (nixos-rebuild / nixos-anywhere via `nix run`). # Password prompts auto-fill from the "HomeLab" Proton Pass vault, keyed by
# # <config> not <host> (darman@<config> for sudo, root@<config> for ssh).
# Password prompts are auto-filled from the "HomeLab" Proton Pass vault when
# `pass-cli` is installed and logged in; otherwise every command prompts exactly
# as before. Both items are keyed by <config>, never by <host>: the address is
# incidental (DHCP, a new box, localhost) while the config name is the stable
# identity of the machine being built.
# darman@<config> darman's sudo password (switch/boot/test)
# root@<config> root's ssh password (kexec/install)
# Override with HOMELAB_PASS_ITEM / HOMELAB_PASS_ROOT_ITEM / HOMELAB_PASS_VAULT. # Override with HOMELAB_PASS_ITEM / HOMELAB_PASS_ROOT_ITEM / HOMELAB_PASS_VAULT.
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
# Captured before anything shifts/parses $@, so require_root() below can # Captured before $@ is parsed, so require_root() can re-exec the ORIGINAL
# re-exec the ORIGINAL invocation under sudo inside a function, "$@"/"$1" # invocation under sudo (inside a function, "$@" is the function's own args).
# refer to the function's own args (empty here), not the script's, so this
# has to be a global array instead of relying on positional-parameter scoping.
SCRIPT_ARGS=("$@") SCRIPT_ARGS=("$@")
# Locate the repo root (flake dir) regardless of where this script lives on disk. # Locate the repo root (flake dir) regardless of where this script lives on disk.
SCRIPT_PATH="$(realpath "$0")" # absolute — "$0" itself may be relative, SCRIPT_PATH="$(realpath "$0")"
# and require_root() re-execs after cd "$REPO"
SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" SCRIPT_DIR="$(dirname "$SCRIPT_PATH")"
REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || dirname "$SCRIPT_DIR")" REPO="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || dirname "$SCRIPT_DIR")"
cd "$REPO" cd "$REPO"
export PATH="/nix/var/nix/profiles/default/bin:$PATH" export PATH="/nix/var/nix/profiles/default/bin:$PATH"
# Every `nix` call below assumes `nix-command` + `flakes`. Those are ambient on # A stock NixOS box (unlike a Determinate-Nix laptop) leaves nix-command/flakes
# a Determinate-Nix laptop, but a STOCK NixOS box leaves both experimental # disabled, and that's exactly the prepare host for `install <config> localhost`.
# features OFF — so bare `nix eval`/`build`/`run` die with "experimental Nix # Enable them additively so root gets them too after the require_root() re-exec.
# feature 'nix-command' is disabled". That box is exactly the prepare host for
# `install <config> localhost` (a fresh NixOS the reinstall runs from), and it
# is why the installer-iso already sets these itself (flake.nix). Enable them
# additively via NIX_CONFIG (extra-, so anything already configured is kept).
# This runs again at the top of the sudo re-exec in require_root(), so root
# gets it too regardless of whether `sudo -E` carries the env across.
export NIX_CONFIG="$(printf 'extra-experimental-features = nix-command flakes\n%s' "${NIX_CONFIG:-}")" export NIX_CONFIG="$(printf 'extra-experimental-features = nix-command flakes\n%s' "${NIX_CONFIG:-}")"
# Off-repo material keyed by <config>: pre-generated SSH host keys (install) # Off-repo material keyed by <config>: pre-generated SSH host keys (install)
# and per-config sops age keys (flash). # and per-config sops age keys (flash). Resolved defensively, not as a bare
# # $HOME, since systemd doesn't set $HOME for a service without User= — this
# Resolved defensively rather than as a bare $HOME, because this script also # also runs unattended from installer-iso's homelab-auto-install.service.
# runs from installer-iso's homelab-auto-install.service, and systemd does not
# set $HOME for a system service without User= (systemd.exec(5):
# SetLoginEnvironment= "defaults to true if User=, DynamicUser= or PAMName= are
# set, false otherwise"). Under `set -u` that aborted the whole unattended run
# with an "unbound variable" that read like a bug in this script.
KEYDIR="${HOMELAB_KEY_DIR:-${HOME:-/root}/.config/homelab}" KEYDIR="${HOMELAB_KEY_DIR:-${HOME:-/root}/.config/homelab}"
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
@@ -108,48 +50,40 @@ die() { echo "error: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; } need() { command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"; }
# Self-elevate instead of dying: re-exec this exact invocation under sudo. # Self-elevate instead of dying: re-exec this exact invocation under sudo.
# -E preserves the environment (HOMELAB_* overrides, Proton Pass vault vars) # -E preserves HOMELAB_*/vault env vars; pin HOMELAB_KEY_DIR too since whether
# across the re-exec. A no-op once already root. # sudo carries $HOME across depends on the local sudoers policy. No-op if already root.
require_root() { require_root() {
[ "$(id -u)" = 0 ] && return 0 [ "$(id -u)" = 0 ] && return 0
echo ">> $1 needs root — re-executing under sudo" >&2 echo ">> $1 needs root — re-executing under sudo" >&2
# $KEYDIR is derived from $HOME, and whether sudo carries $HOME across
# depends on the local sudoers policy (env_reset/always_set_home). Pin the
# resolved value so the re-exec looks for host keys where the invoking user
# has them, not under /root.
export HOMELAB_KEY_DIR="$KEYDIR" export HOMELAB_KEY_DIR="$KEYDIR"
exec sudo -E -- "$SCRIPT_PATH" "${SCRIPT_ARGS[@]}" exec sudo -E -- "$SCRIPT_PATH" "${SCRIPT_ARGS[@]}"
} }
# Exactly one path matching a glob, or die. `ls glob | head -1` silently yields # Exactly one path matching a glob, or die. `ls glob | head -1` silently yields
# an empty string when nothing matches (head exits 0, so set -e never fires) and # an empty string when nothing matches (head exits 0, so set -e never fires).
# the failure only surfaces later as a confusing tar/dd error.
one_match() { one_match() {
local what="$1"; shift local what="$1"; shift
local f=("$@") # caller expands the glob (nullglob is on) local f=("$@") # caller expands the glob (nullglob is on)
[ "${#f[@]}" -gt 0 ] || die "no $what found — did the build actually produce one?" [ "${#f[@]}" -gt 0 ] || die "no $what found — did the build actually produce one?"
# Say so instead of silently taking [0]: a stale result-sd/ symlink from an # A stale result-sd/ symlink from an earlier config is how you'd otherwise
# earlier config is exactly how you flash the wrong image without a word. # flash the wrong image without a word — warn instead of silently taking [0].
[ "${#f[@]}" -eq 1 ] \ [ "${#f[@]}" -eq 1 ] \
|| echo ">> warning: ${#f[@]} candidates for $what, using ${f[0]} (rm the stale ones)" >&2 || echo ">> warning: ${#f[@]} candidates for $what, using ${f[0]} (rm the stale ones)" >&2
printf '%s\n' "${f[0]}" printf '%s\n' "${f[0]}"
} }
# Every whole-disk device backing a block device or a mounted path, one per # Every whole-disk device backing a block device or mounted path, one per line.
# line. LVM/RAID/LUKS can sit on several at once (verified on terra: # LVM/RAID/LUKS can span several disks at once (e.g. terra's /mnt/ssd_01),
# /mnt/ssd_01 -> sdd AND sde), so a single lookup is not enough. Empty output # so callers must treat empty output as "unknown", not "safe".
# means "could not determine" — which callers must treat as unsafe, not as OK.
disks_backing() { disks_backing() {
lsblk -rnso NAME,TYPE "$1" 2>/dev/null | awk '$2 == "disk" { print "/dev/" $1 }' lsblk -rnso NAME,TYPE "$1" 2>/dev/null | awk '$2 == "disk" { print "/dev/" $1 }'
} }
# Label of the temporary UEFI boot entry arm_efi_bootnext() creates. Also the # Label of the temporary UEFI boot entry arm_efi_bootnext() creates; also what
# key the ISO uses to delete it again once it has booted (see flake.nix). # the booted ISO matches to delete it again (flake.nix) — must match EXACTLY.
EFI_LABEL="Homelab Installer" EFI_LABEL="Homelab Installer"
# Boot numbers of every UEFI entry with exactly this label, one per line. # Boot numbers of every UEFI entry with exactly this label, one per line.
# efibootmgr prints `Boot0002* Limine<TAB>HD(1,GPT,...)/\EFI\...`, so the
# label runs from past the "Boot####* " prefix up to the first TAB.
# (Character classes spelled out rather than {4}: mawk predates ERE intervals.) # (Character classes spelled out rather than {4}: mawk predates ERE intervals.)
efi_entries_named() { efi_entries_named() {
efibootmgr 2>/dev/null | awk -v want="$1" ' efibootmgr 2>/dev/null | awk -v want="$1" '
@@ -162,17 +96,11 @@ efi_entries_named() {
}' }'
} }
# Arm a genuine one-shot boot of the staged installer WITHOUT any help from the # Arm a genuine one-shot boot of the staged installer without bootloader help:
# bootloader: create a UEFI boot entry that EFI-stub-boots the kernel straight # create a UEFI entry that EFI-stub-boots the kernel off the ESP and point
# off the ESP, and point BootNext at it. # BootNext at it. Needed because Limine (terra's CachyOS) has no one-shot
# # entry support; BootNext is a firmware feature so it works underneath any
# Needed because "boot this once, then go back to normal" is not something # bootloader, and the firmware clears it after one boot either way.
# every bootloader can do. systemd-boot has it; terra's CachyOS runs Limine,
# which reports `One-shot entry control: ✗` and has no equivalent, and whose
# limine.conf is regenerated by pacman hooks anyway. BootNext is a firmware
# feature, so it works underneath all of them — and the firmware clears it
# after that one boot, which is what keeps the "a failed attempt still comes
# back on the normal bootloader" property that makes this safe to try.
arm_efi_bootnext() { arm_efi_bootnext() {
local esp="$1" cmdline="$2" local esp="$1" cmdline="$2"
local esp_src esp_disk esp_part num n local esp_src esp_disk esp_part num n
@@ -184,20 +112,17 @@ arm_efi_bootnext() {
{ [ -n "$esp_disk" ] && [ -n "$esp_part" ]; } \ { [ -n "$esp_disk" ] && [ -n "$esp_part" ]; } \
|| die "couldn't work out the disk + partition number of the ESP ($esp -> $esp_src)" || die "couldn't work out the disk + partition number of the ESP ($esp -> $esp_src)"
# Clear anything left by an earlier attempt first, so repeated runs don't # Clear anything left by an earlier attempt so NVRAM doesn't slowly fill
# slowly fill NVRAM with dead entries pointing at a wiped partition. # with dead entries pointing at a wiped partition.
for n in $(efi_entries_named "$EFI_LABEL"); do for n in $(efi_entries_named "$EFI_LABEL"); do
echo ">> removing stale UEFI entry Boot$n ($EFI_LABEL)" echo ">> removing stale UEFI entry Boot$n ($EFI_LABEL)"
efibootmgr -q -B -b "$n" efibootmgr -q -B -b "$n"
done done
# --create-only, NOT --create: the latter also pushes the entry to the front # --create-only, NOT --create: --create also pushes the entry to the front of
# of BootOrder, which would make a wiped installer the permanent default if # BootOrder, which would make a wiped installer the permanent default on any
# anything went wrong. This way the entry is reachable through BootNext and # failure. This way it's reachable only via BootNext, exactly once. The EFI
# nothing else, i.e. exactly once. # stub loads `initrd=` relative to the ESP root, hence the backslash path.
#
# The EFI stub loads `initrd=` off the volume it was itself loaded from, so
# the path is relative to the ESP root and uses backslashes.
efibootmgr -q --create-only --disk "$esp_disk" --part "$esp_part" \ efibootmgr -q --create-only --disk "$esp_disk" --part "$esp_part" \
--label "$EFI_LABEL" \ --label "$EFI_LABEL" \
--loader '\homelab-installer\bzImage' \ --loader '\homelab-installer\bzImage' \
@@ -210,11 +135,8 @@ arm_efi_bootnext() {
} }
# Sets tb / cpio / bbox — the kexec tarball plus the static cpio+gzip that # Sets tb / cpio / bbox — the kexec tarball plus the static cpio+gzip that
# kexec-run.sh needs on PATH to rebuild its initrd. # kexec-run.sh needs on PATH to rebuild its initrd. HOMELAB_KEXEC_TARBALL (+
# # _CPIO/_GZIP) short-circuits the build to reuse a prebuilt installer instead.
# HOMELAB_KEXEC_TARBALL (with _CPIO / _GZIP) short-circuits the build and uses a
# prebuilt installer instead. That lets the VM test in flake.nix drive this
# script offline, and lets you re-kexec a box without rebuilding ~500MB.
kexec_artifacts() { kexec_artifacts() {
if [ -n "${HOMELAB_KEXEC_TARBALL:-}" ]; then if [ -n "${HOMELAB_KEXEC_TARBALL:-}" ]; then
tb="$HOMELAB_KEXEC_TARBALL" tb="$HOMELAB_KEXEC_TARBALL"
@@ -235,11 +157,10 @@ kexec_artifacts() {
fi fi
} }
# True inside one of the throwaway live-installer environments this repo # True inside one of this repo's throwaway live-installer environments
# produces (kexec's nixos-installer, or installer-iso's homelab-installer) — # (nixos-installer from kexec, or homelab-installer from installer-iso) —
# i.e. `install <config> localhost` should wipe/install right here. False on # i.e. `install <config> localhost` should wipe/install right here, not
# any real running OS, where the same command instead means "prepare and # prepare-and-reboot (see local_install_prepare_and_reboot).
# reboot into an installer for THIS box" (see local_install_prepare_and_reboot).
is_live_installer() { is_live_installer() {
case "$(uname -n)" in case "$(uname -n)" in
nixos-installer | homelab-installer) return 0 ;; nixos-installer | homelab-installer) return 0 ;;
@@ -247,17 +168,12 @@ is_live_installer() {
esac esac
} }
# `install <config> localhost` run on a REAL running OS (not already inside a # `install <config> localhost` on a REAL running OS (not yet inside a live
# live installer): builds installer-iso, stages its kernel/initrd + the host's # installer): stages installer-iso's kernel/initrd + host key on the boot
# pre-generated ssh key on the boot partition and the iso file on a non-OS # partition, arms a one-shot boot with homelab.install=<config> on its
# disk, points a systemd-boot one-shot entry at them with # cmdline, and does a real ACPI reboot — deliberately not a kexec jump, per
# homelab.install=<config> + homelab.keypart=<PARTUUID> on the kernel cmdline, # terra's kexec-local gotcha in CLAUDE.md. The booted installer re-runs this
# and reboots — a real ACPI reboot through firmware POST, deliberately NOT a # same command itself once its repo checkout succeeds, finishing unattended.
# kexec jump (see terra's kexec-local gotcha in CLAUDE.md). The booted
# installer's homelab-auto-install.service reads those params, picks the host
# key back up and re-runs this exact `install <config> localhost` command
# itself (now genuinely inside the installer) once homelab-checkout.service has
# fetched the repo, finishing the job unattended.
local_install_prepare_and_reboot() { local_install_prepare_and_reboot() {
local config="$1" hostkey="$2" assume_yes="$3" local config="$1" hostkey="$2" assume_yes="$3"
require_root "preparing a local reinstall" require_root "preparing a local reinstall"
@@ -271,17 +187,12 @@ local_install_prepare_and_reboot() {
need stat need stat
need df need df
# Where to stage the installer, and how to make the box boot it exactly once. # Where to stage the installer: use bootctl's reported $BOOT (XBOOTLDR or the
# # ESP), not a hardcoded /boot, since that's not always where the ESP mounts.
# systemd-boot keeps its entries on $BOOT — the XBOOTLDR partition when there # No systemd-boot (terra's CachyOS runs Limine) means no `bootctl
# is one, the ESP otherwise — which is not always /boot. Hardcoding /boot on # set-oneshot`, so fall back to firmware BootNext (arm_efi_bootnext()) —
# a box that mounts its ESP elsewhere just creates a directory on the root # which EFI-stub-boots the kernel directly and needs it on the ESP itself,
# filesystem and then reboots into an entry the firmware never sees. # not a separate XBOOTLDR.
#
# No systemd-boot (terra's CachyOS runs Limine) means no `bootctl set-oneshot`,
# so fall back to the firmware's own BootNext — see arm_efi_bootnext(). That
# path EFI-stub-boots the kernel directly, which requires it to sit on the ESP
# itself rather than on a separate XBOOTLDR.
local boot boot_mode esp local boot boot_mode esp
esp="$(bootctl --print-esp-path 2>/dev/null)" \ esp="$(bootctl --print-esp-path 2>/dev/null)" \
|| die "bootctl couldn't locate the ESP — is this box actually UEFI-booted?" || die "bootctl couldn't locate the ESP — is this box actually UEFI-booted?"
@@ -296,10 +207,9 @@ local_install_prepare_and_reboot() {
echo " own BootNext instead (bootloader in charge here: $(bootctl status 2>/dev/null | awk '/Product:/ {$1=""; print substr($0,2); exit}' || echo unknown))" echo " own BootNext instead (bootloader in charge here: $(bootctl status 2>/dev/null | awk '/Product:/ {$1=""; print substr($0,2); exit}' || echo unknown))"
fi fi
# No default/auto-picked location the wrong disk here is destroyed # No default/auto-picked location: the wrong disk here is destroyed
# mid-install (see the OS-disk check below), so this always asks rather # mid-install (see the OS-disk check below), so this always asks unless
# than guessing. HOMELAB_INSTALLER_STAGE_DIR skips the prompt for scripted # HOMELAB_INSTALLER_STAGE_DIR is set for scripted use.
# use, but is otherwise just as explicit a choice as typing it in.
local stagedir="${HOMELAB_INSTALLER_STAGE_DIR:-}" local stagedir="${HOMELAB_INSTALLER_STAGE_DIR:-}"
if [ -z "$stagedir" ]; then if [ -z "$stagedir" ]; then
echo ">> currently mounted filesystems:" echo ">> currently mounted filesystems:"
@@ -323,17 +233,13 @@ local_install_prepare_and_reboot() {
|| die "couldn't read the OS disk device from hosts/$config/disk-config.nix" || die "couldn't read the OS disk device from hosts/$config/disk-config.nix"
osdisk_real="$(readlink -f "$osdisk")" osdisk_real="$(readlink -f "$osdisk")"
# --nofsroot matters: on btrfs, findmnt prints the subvolume as # --nofsroot matters: on btrfs findmnt prints the subvolume as
# `/dev/sdb2[/@]`, which is not a path lsblk can open. Without it the lookup # `/dev/sdb2[/@]`, which lsblk can't open, silently skipping the guard
# came back empty and the guard below was skipped entirely — i.e. it silently # below and allowing staging on the disk about to be wiped (terra's layout).
# allowed staging on the very disk about to be wiped. terra's current
# CachyOS root is exactly that layout.
stage_src="$(findmnt -no SOURCE --nofsroot --target "$stagedir")" \ stage_src="$(findmnt -no SOURCE --nofsroot --target "$stagedir")" \
|| die "$stagedir doesn't resolve to a mounted filesystem" || die "$stagedir doesn't resolve to a mounted filesystem"
# `|| true` so the explicit check below is what reports the problem: lsblk # `|| true` so the fail-closed check below reports the problem, rather than
# exits nonzero on a device it can't parse, and under `set -e` + pipefail a # `set -e`/pipefail silently killing the script on lsblk's nonzero exit.
# bare assignment from a failing substitution kills the script silently,
# right past the fail-closed message.
stage_disks="$(disks_backing "$stage_src" || true)" stage_disks="$(disks_backing "$stage_src" || true)"
# Fail closed. "Couldn't determine the disk" is not "different disk". # Fail closed. "Couldn't determine the disk" is not "different disk".
[ -n "$stage_disks" ] \ [ -n "$stage_disks" ] \
@@ -344,30 +250,23 @@ local_install_prepare_and_reboot() {
fi fi
done done
# stage-1 resolves findiso= by mounting each blkid-visible partition and # stage-1 mounts a btrfs volume's TOP level to resolve findiso=, so a path
# testing `-e /findiso$isoPath` (nixos/modules/system/boot/stage-1-init.sh). # inside a subvolume is unreachable and the box boots to an emergency shell
# For btrfs it mounts the volume's TOP level, so a path that lives inside a # after it's already left the working OS. Refuse btrfs staging outright.
# subvolume (/@/...) is simply not there and the box boots to an emergency
# shell — after it has already rebooted out of the working OS.
stage_fstype="$(findmnt -no FSTYPE --target "$stagedir")" stage_fstype="$(findmnt -no FSTYPE --target "$stagedir")"
[ "$stage_fstype" != btrfs ] \ [ "$stage_fstype" != btrfs ] \
|| die "$stagedir is btrfs: findiso= mounts the volume's top level, so a path inside a subvolume never resolves. Stage on a non-btrfs partition (ext4/vfat/ntfs)." || die "$stagedir is btrfs: findiso= mounts the volume's top level, so a path inside a subvolume never resolves. Stage on a non-btrfs partition (ext4/vfat/ntfs)."
# PARTUUID of the staging partition. Handed to the installer as # PARTUUID of the staging partition, handed to the installer as
# homelab.logpart= so it can mount this partition rw and persist its whole # homelab.logpart= so it can persist the whole install's log there — it's on
# run — disko + nixos-install output included — to a file next to the iso. # a different disk than the one disko wipes, so it survives a failed
# This partition is on a DIFFERENT disk from the one disko wipes (guarded # install. Best-effort: an LVM/mdraid stage_src has no PARTUUID, so logging
# above), so unlike $boot it SURVIVES the install: a failed attempt otherwise
# leaves nothing to debug, its journal having died on tmpfs at the reboot.
# Best-effort — an LVM/mdraid stage_src has no PARTUUID, in which case logging
# is simply skipped rather than blocking the install. # is simply skipped rather than blocking the install.
local stage_partuuid local stage_partuuid
stage_partuuid="$(lsblk -no PARTUUID "$stage_src" 2>/dev/null | head -1 | tr -d ' ' || true)" stage_partuuid="$(lsblk -no PARTUUID "$stage_src" 2>/dev/null | head -1 | tr -d ' ' || true)"
# Last chance to back out. This is the most destructive command in the # Last chance to back out: this reboots the machine you're typing at into an
# script — it reboots the machine you are typing at and the wipe that # unattended wipe, so it confirms like `flash`/`kexec-local` do.
# follows is unattended — so it confirms just like `flash` and `kexec-local`
# do, both of which are less final than this.
if [ "$assume_yes" != "--yes" ]; then if [ "$assume_yes" != "--yes" ]; then
echo ">> about to REINSTALL this machine from scratch:" echo ">> about to REINSTALL this machine from scratch:"
echo " hostname: $(uname -n)" echo " hostname: $(uname -n)"
@@ -392,16 +291,13 @@ local_install_prepare_and_reboot() {
initrd="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.initialRamdisk)/initrd" initrd="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.initialRamdisk)/initrd"
isodir="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.isoImage)" isodir="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.isoImage)"
iso="$(one_match 'installer iso' "$isodir"/iso/*.iso)" iso="$(one_match 'installer iso' "$isodir"/iso/*.iso)"
# The live ISO's root is a tmpfs; stage 1 finds the real system's init via # The grub/isolinux menu normally supplies init=<toplevel>/init; EFI-stub
# init=<toplevel>/init, which the grub/isolinux menu supplies on a normal # booting our own cmdline means we must pass it too, or stage 1 dies on
# boot (iso-image.nix). EFI-stub-booting our own cmdline, we must pass it too
# — omit it and stage 1 loop-mounts the iso fine, then dies on
# "stage 2 init script (/mnt-root//init) not found". # "stage 2 init script (/mnt-root//init) not found".
toplevel="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.toplevel)" toplevel="$(nix build --no-link --print-out-paths .#nixosConfigurations.installer-iso.config.system.build.toplevel)"
# A short write is not visible until the reboot, when findiso finds a # Check space before writing: a short write isn't visible until reboot,
# truncated iso and drops to an emergency shell. Check first — `install` # when findiso finds a truncated ~1GB iso and drops to an emergency shell.
# prints no progress and the iso is ~1GB.
local need_stage need_boot avail_stage avail_boot local need_stage need_boot avail_stage avail_boot
need_stage="$(stat -Lc %s "$iso")" need_stage="$(stat -Lc %s "$iso")"
need_boot="$(( $(stat -Lc %s "$kernel") + $(stat -Lc %s "$initrd") + $(stat -Lc %s "$hostkey") ))" need_boot="$(( $(stat -Lc %s "$kernel") + $(stat -Lc %s "$initrd") + $(stat -Lc %s "$hostkey") ))"
@@ -417,15 +313,9 @@ local_install_prepare_and_reboot() {
install -Dm644 "$initrd" "$boot/homelab-installer/initrd" install -Dm644 "$initrd" "$boot/homelab-installer/initrd"
install -Dm644 "$iso" "$stagedir/homelab-installer.iso" install -Dm644 "$iso" "$stagedir/homelab-installer.iso"
# The ISO is built from a PUBLIC repo and deliberately carries no # The ISO is built from a public repo with no credentials, so the host key
# credentials, so the host key has to travel with the staged installer or # must travel with the staged installer or sops can't decrypt on boot #1
# the auto-install run has nothing to seed /etc/ssh with — and without that, # (README). $boot is on the OS disk, so disko destroys this copy minutes later.
# sops can't decrypt on boot #1, /etc/shadow gets written once with a locked
# darman, and no later `deploy switch` can fix it (README).
#
# $boot lives on the OS disk, so disko destroys this copy minutes later. The
# mode is advisory on vfat (permissions come from the mount's fmask, 0077 on
# a NixOS/systemd-boot ESP) — it is the wipe, not the mode, doing the work.
install -Dm600 "$hostkey" "$boot/homelab-installer/ssh_host_ed25519_key" install -Dm600 "$hostkey" "$boot/homelab-installer/ssh_host_ed25519_key"
install -Dm644 "$hostkey.pub" "$boot/homelab-installer/ssh_host_ed25519_key.pub" install -Dm644 "$hostkey.pub" "$boot/homelab-installer/ssh_host_ed25519_key.pub"
boot_src="$(findmnt -no SOURCE --nofsroot --target "$boot")" \ boot_src="$(findmnt -no SOURCE --nofsroot --target "$boot")" \
@@ -434,22 +324,15 @@ local_install_prepare_and_reboot() {
[ -n "$boot_partuuid" ] \ [ -n "$boot_partuuid" ] \
|| die "couldn't read a PARTUUID for $boot ($boot_src) — the installer needs it to find the host key" || die "couldn't read a PARTUUID for $boot ($boot_src) — the installer needs it to find the host key"
# findiso= is a path relative to whatever partition the initrd finds it on # findiso= is relative to whichever partition the initrd finds it on, and
# (it mounts every blkid-visible partition looking for it), not to `/`, if # must KEEP its leading slash: stage-1 tests `-e /findiso$isoPath`, so a bare
# $stagedir is a subdirectory of a bigger filesystem rather than a mountpoint # `var/tmp/x.iso` becomes `/findisovar/tmp/x.iso` and never matches.
# itself. It must KEEP its leading slash: stage-1 tests `-e /findiso$isoPath`,
# so a bare `var/tmp/x.iso` becomes `/findisovar/tmp/x.iso` and never matches.
# Prefixing then squeezing handles both ends: stagedir == the mountpoint
# (strip leaves "") and mnt_point == "/" (strip leaves a relative path).
mnt_point="$(findmnt -no TARGET --target "$stagedir")" mnt_point="$(findmnt -no TARGET --target "$stagedir")"
iso_relpath="$(printf '/%s/%s' "${stagedir#"$mnt_point"}" homelab-installer.iso | tr -s /)" iso_relpath="$(printf '/%s/%s' "${stagedir#"$mnt_point"}" homelab-installer.iso | tr -s /)"
# Identical either way — only the mechanism that gets the kernel booted with # root=LABEL=<volumeID> matches what the ISO menu passes (findiso overwrites
# it differs. # /dev/root regardless); boot.shell_on_fail gives a shell instead of a
# root=LABEL=<volumeID> matches what the ISO menu passes; findiso overwrites # reboot/ignore prompt if stage 1 fails again.
# /dev/root with the loop-mounted iso regardless, but keep it honest.
# boot.shell_on_fail gives a shell instead of the reboot/ignore prompt if
# stage 1 ever fails again. init= is the one that actually made this work.
local cmdline volumeID local cmdline volumeID
volumeID="$(nix eval --raw .#nixosConfigurations.installer-iso.config.isoImage.volumeID)" volumeID="$(nix eval --raw .#nixosConfigurations.installer-iso.config.isoImage.volumeID)"
cmdline="init=$toplevel/init nohibernate root=LABEL=$volumeID boot.shell_on_fail loglevel=4 lsm=landlock,yama,bpf findiso=$iso_relpath homelab.install=$config homelab.keypart=$boot_partuuid" cmdline="init=$toplevel/init nohibernate root=LABEL=$volumeID boot.shell_on_fail loglevel=4 lsm=landlock,yama,bpf findiso=$iso_relpath homelab.install=$config homelab.keypart=$boot_partuuid"
@@ -483,31 +366,24 @@ EOF
require_tracked() { require_tracked() {
local config="$1" cfgfile="hosts/$1/configuration.nix" f local config="$1" cfgfile="hosts/$1/configuration.nix" f
[ -e "$cfgfile" ] || die "no $cfgfile in the repo" [ -e "$cfgfile" ] || die "no $cfgfile in the repo"
# No .git at all (e.g. a tarball export of the repo, no working tree), or no # No .git or no working tree (e.g. a tarball export) means nothing CAN be
# git binary, means there's nothing that CAN be untracked — nothing to check. # untracked — skip only on that, not on any other git failure.
# Only skip on that, not on any other git failure.
command -v git >/dev/null 2>&1 || return 0 command -v git >/dev/null 2>&1 || return 0
git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0
# Every .nix in hosts/<config>/, not just configuration.nix: an untracked # Every .nix in hosts/<config>/, not just configuration.nix an untracked
# disk-config.nix is exactly as invisible to the flake, and it is the file # disk-config.nix decides which disk gets wiped and is just as invisible.
# that decides which disk gets wiped.
for f in "hosts/$config"/*.nix; do for f in "hosts/$config"/*.nix; do
git -C "$REPO" ls-files --error-unmatch "$f" >/dev/null 2>&1 \ git -C "$REPO" ls-files --error-unmatch "$f" >/dev/null 2>&1 \
|| die "$f is untracked — 'git add hosts/$config' first (flakes ignore untracked files)" || die "$f is untracked — 'git add hosts/$config' first (flakes ignore untracked files)"
done done
} }
# The password field of a Proton Pass item ("--field password" prints the bare # The password field of a Proton Pass item, or empty if pass-cli is missing /
# value, one line), or empty if pass-cli is missing / logged out / has no such # logged out / has no such item — callers then fall back to an interactive
# item — every caller then falls back to the normal interactive prompt. # prompt. Resolves the title among ACTIVE items first, because `item view
# # --item-title` has no state filter and can silently match a trashed item of
# Resolve the title to an item id among ACTIVE items first, because `item view # the same title instead, returning an empty password with exit 0 (hit for
# --item-title` has no state filter: Proton Pass keeps deleted items in the # real on darman@neptun, which had both an Active and a Trashed copy).
# trash, and if a trashed item shares the title, view can match THAT one and
# return an empty password with exit 0. Empty is indistinguishable from "no such
# item", so the only symptom is a silent fall back to the interactive prompt
# even though the vault clearly holds the entry. (Hit for real on darman@neptun,
# which had an Active and a Trashed copy.)
proton_pass_password() { proton_pass_password() {
local title="$1" vault="${HOMELAB_PASS_VAULT:-HomeLab}" id pw local title="$1" vault="${HOMELAB_PASS_VAULT:-HomeLab}" id pw
command -v pass-cli >/dev/null 2>&1 || return 0 command -v pass-cli >/dev/null 2>&1 || return 0
@@ -564,15 +440,10 @@ case "$cmd" in
o=(-o ControlMaster=auto -o "ControlPath=$cm" -o ControlPersist=300 \ o=(-o ControlMaster=auto -o "ControlPath=$cm" -o ControlPersist=300 \
-o StrictHostKeyChecking=accept-new) -o StrictHostKeyChecking=accept-new)
# Root's password from Proton Pass, fed to ssh/scp via sshpass -e. Only the # Root's password from Proton Pass, fed to ssh/scp via sshpass -e; only the
# first (master) connection authenticates; the rest ride the control socket. # first (master) connection authenticates, the rest ride the control socket.
# # Exported rather than `env SSHPASS=... sshpass` to close the sub-millisecond
# SSHPASS is exported here rather than passed as `env SSHPASS=... sshpass`. # argv-exposure race before exec (either way the secret only lives in environ).
# Both end up equally safe at rest: `env` execs its target immediately, so
# the assignment is only in argv for the sub-millisecond before exec, after
# which /proc/PID/cmdline reads plain `sshpass -e`. Exporting just closes
# that race window and drops a process. Either way the secret lives in the
# child's environ, which is readable by the owner and root only.
sp=() sp=()
root_item="${HOMELAB_PASS_ROOT_ITEM:-root@$config}" root_item="${HOMELAB_PASS_ROOT_ITEM:-root@$config}"
root_pw="$(proton_pass_password "$root_item" || true)" root_pw="$(proton_pass_password "$root_item" || true)"
@@ -613,24 +484,18 @@ case "$cmd" in
ssh "${o[@]}" -O exit "root@$host" 2>/dev/null || true # close control socket ssh "${o[@]}" -O exit "root@$host" 2>/dev/null || true # close control socket
unset SSHPASS unset SSHPASS
# NB: no ssh-keygen -R here on purpose. kexec-run.sh copies /etc/ssh/ssh_host_* # NB: no ssh-keygen -R here on purpose — the kexec installer keeps the box's
# into the appended initrd and restore-remote-access.nix installs them back # ssh host key (restore-remote-access.nix), so known_hosts is still valid.
# into the installer's /etc/ssh, so the host key SURVIVES the jump. Clearing
# known_hosts would just throw away the TOFU record for no reason.
echo ">> box is kexec-ing. Wait ~1-2 min for the installer + network, then:" echo ">> box is kexec-ing. Wait ~1-2 min for the installer + network, then:"
echo " ./deploy install $config $host" echo " ./deploy install $config $host"
;; ;;
kexec-local) kexec-local)
# No ssh, no second machine: build the same RAM installer as `kexec`, but # Build the same RAM installer as `kexec`, but run it directly on this box
# run it directly on this box (you're sitting at it). The current shell # (no ssh/second machine). One-way trip on the machine you're typing at, so
# drops when the kernel switches, same as any reboot — that's expected, # every check that can fail runs BEFORE the point of no return (see the
# not a failure. Disks are untouched; only the running kernel changes. # trap discussion below).
#
# This is a one-way trip on the machine you are typing at, so every check
# that can fail is done BEFORE the point of no return, and nothing that the
# jump depends on is cleaned up behind it (see the trap discussion below).
require_root "kexec-local" require_root "kexec-local"
assume_yes="" assume_yes=""
@@ -719,11 +584,9 @@ case "$cmd" in
[ "$(cat /sys/kernel/kexec_loaded 2>/dev/null || echo 0)" = 1 ] \ [ "$(cat /sys/kernel/kexec_loaded 2>/dev/null || echo 0)" = 1 ] \
|| { rm -rf "$stage"; die "kexec reported success but no image is loaded — aborting"; } || { rm -rf "$stage"; die "kexec reported success but no image is loaded — aborting"; }
# THE trap MUST GO NOW. kexec-run.sh backgrounds `nohup sh -c "sleep 6 && # THE trap MUST GO NOW: kexec-run.sh backgrounds the actual jump ~6s in the
# $SCRIPT_DIR/kexec -e"` and returns immediately, so the binary that # future, so an EXIT trap rm -rf'ing $stage here would delete the binary
# performs the jump still has to exist ~6s after this script would normally # that performs it and the machine would silently never jump.
# exit. Letting the EXIT trap rm -rf "$stage" deletes it out from under that
# sleeping shell and the machine silently never jumps.
trap - EXIT trap - EXIT
sync sync
@@ -737,9 +600,8 @@ case "$cmd" in
install) install)
config="${2:-}"; host="${3:-}"; assume_yes="${4:-}" config="${2:-}"; host="${3:-}"; assume_yes="${4:-}"
{ [ -n "$config" ] && [ -n "$host" ]; } || die "usage: ./deploy install <config> <host> [--yes]" { [ -n "$config" ] && [ -n "$host" ]; } || die "usage: ./deploy install <config> <host> [--yes]"
# $KEYDIR, not a bare $HOME — see its definition. This same check runs # $KEYDIR, not a bare $HOME — see its definition (also runs inside
# inside installer-iso, where homelab-auto-install.service has no $HOME and # installer-iso, which has no $HOME).
# has just dropped the key into /root/.config/homelab/<config>/.
hostkey="$KEYDIR/$config/ssh_host_ed25519_key" hostkey="$KEYDIR/$config/ssh_host_ed25519_key"
[ -f "$hostkey" ] || die "missing host key: $hostkey" [ -f "$hostkey" ] || die "missing host key: $hostkey"
[ -d "./hosts/$config" ] || die "no ./hosts/$config directory in the repo" [ -d "./hosts/$config" ] || die "no ./hosts/$config directory in the repo"
@@ -761,9 +623,8 @@ case "$cmd" in
[ -f "./hosts/$config/disk-config.nix" ] || die "no ./hosts/$config/disk-config.nix" [ -f "./hosts/$config/disk-config.nix" ] || die "no ./hosts/$config/disk-config.nix"
echo ">> disko .#$config onto this box's OS disk (WILL be wiped)" echo ">> disko .#$config onto this box's OS disk (WILL be wiped)"
# `.#disko`, not github:nix-community/disko — the revision comes from this # `.#disko`, not github:nix-community/disko: pins to this repo's
# repo's flake.lock rather than upstream master-of-the-day, and resolves # flake.lock revision instead of upstream master-of-the-day.
# from the local store. See the nixos-anywhere input in flake.nix.
nix run ".#disko" -- \ nix run ".#disko" -- \
--mode disko "./hosts/$config/disk-config.nix" --mode disko "./hosts/$config/disk-config.nix"
@@ -787,9 +648,7 @@ case "$cmd" in
--target-host "root@$host") --target-host "root@$host")
# nixos-anywhere's --env-password reads root's ssh password from $SSHPASS # nixos-anywhere's --env-password reads root's ssh password from $SSHPASS
# (it ships its own sshpass), so a vault hit skips the ssh-copy-id prompt. # (its own bundled sshpass), so a vault hit skips the ssh-copy-id prompt.
# Exported rather than `env SSHPASS=...` for consistency with `kexec`;
# see the note there — it's a marginal win, not a leak fix.
root_item="${HOMELAB_PASS_ROOT_ITEM:-root@$config}" root_item="${HOMELAB_PASS_ROOT_ITEM:-root@$config}"
root_pw="$(proton_pass_password "$root_item" || true)" root_pw="$(proton_pass_password "$root_item" || true)"
if [ -n "$root_pw" ]; then if [ -n "$root_pw" ]; then
@@ -812,9 +671,7 @@ case "$cmd" in
echo ">> nixos-rebuild $cmd .#$config on darman@$host" echo ">> nixos-rebuild $cmd .#$config on darman@$host"
# --ask-sudo-password, not the deprecated --use-remote-sudo: common.nix sets # --ask-sudo-password, not the deprecated --use-remote-sudo: common.nix sets
# security.sudo.wheelNeedsPassword = true, and --use-remote-sudo only # wheelNeedsPassword = true, and --use-remote-sudo never actually prompts.
# prefixes with sudo without ever prompting. Asks for darman's password
# (the darman_password hash in each host's sops file).
rebuild=(nix run nixpkgs#nixos-rebuild -- "$cmd" rebuild=(nix run nixpkgs#nixos-rebuild -- "$cmd"
--flake ".#$config" --flake ".#$config"
--target-host "darman@$host" --target-host "darman@$host"
@@ -823,30 +680,19 @@ case "$cmd" in
item="${HOMELAB_PASS_ITEM:-darman@$config}" item="${HOMELAB_PASS_ITEM:-darman@$config}"
pw="$(proton_pass_password "$item" || true)" pw="$(proton_pass_password "$item" || true)"
if [ -n "$pw" ] && command -v setsid >/dev/null 2>&1; then if [ -n "$pw" ] && command -v setsid >/dev/null 2>&1; then
# nixos-rebuild prompts with getpass(), which reads /dev/tty and ignores a # nixos-rebuild's getpass() reads /dev/tty and ignores piped stdin; setsid
# piped stdin. setsid drops the controlling terminal, so getpass falls back # drops the controlling terminal so it falls back to stdin instead. Every
# to stdin and takes the vault password (it warns about echo — harmless, # prompt in the subtree now reads that stdin, so the password line is fed
# nothing is echoed since the password never reaches the terminal). # a few times to survive a retry — anything else that prompts still fails.
#
# Caveat of dropping the tty: EVERY prompt in the subtree now reads this
# stdin, not just the sudo one. Feed the line a few times so a retry or a
# second sudo ask doesn't hit EOF and hang. Anything else that prompts
# (an ssh key passphrase, a host-key confirmation) will still fail — fix
# those out of band rather than by feeding more lines here.
echo ">> sudo password from Proton Pass ($item)" echo ">> sudo password from Proton Pass ($item)"
printf '%s\n%s\n%s\n' "$pw" "$pw" "$pw" | setsid -w "${rebuild[@]}" printf '%s\n%s\n%s\n' "$pw" "$pw" "$pw" | setsid -w "${rebuild[@]}"
else else
"${rebuild[@]}" "${rebuild[@]}"
fi fi
# jupiter's 29G eMMC has no room to just let generations pile up between # jupiter's 29G eMMC has already filled up once waiting for the weekly gc
# gc.dates=weekly runs (common.nix) — that's exactly how it filled up # (common.nix). configurationLimit=5 only drops old generations as GC
# once already. configurationLimit=5 (also common.nix) makes # roots, so collect explicitly here rather than waiting up to a week.
# switch-to-configuration prune generations beyond 5 as part of the
# switch above, but pruning a generation only drops it as a GC root —
# the store paths themselves still need an actual collect to free the
# disk. So do that here, right after every switch, rather than waiting
# up to a week for it to matter again.
if [ "$cmd" = switch ] && [ "$config" = jupiter ]; then if [ "$cmd" = switch ] && [ "$config" = jupiter ]; then
echo ">> jupiter: collecting garbage post-switch (keeps the eMMC under the 5-generation cap)" echo ">> jupiter: collecting garbage post-switch (keeps the eMMC under the 5-generation cap)"
need ssh need ssh
@@ -889,9 +735,8 @@ case "$cmd" in
sync sync
# If this config has a dedicated sops age key, drop it on the ROOT ext4 # If this config has a dedicated sops age key, drop it on the ROOT ext4
# partition at /var/lib/sops-nix/age.txt so sops decrypts on first boot. # partition (the Pi's vfat one isn't mounted at runtime) so sops decrypts
# (The Pi's vfat partition isn't mounted at runtime, so the key can't live # on first boot. Key stays off-repo, out of the nix store and the image.
# there.) Key stays off-repo, out of the nix store, and out of the image.
keyfile="$KEYDIR/$config/age.txt" keyfile="$KEYDIR/$config/age.txt"
if [ -f "$keyfile" ]; then if [ -f "$keyfile" ]; then
echo ">> installing sops age key onto the root partition" echo ">> installing sops age key onto the root partition"
+3 -4
View File
@@ -36,10 +36,9 @@ if [ "$show" -eq 1 ]; then
exec nix shell nixpkgs#sops -c sops --decrypt "$file" exec nix shell nixpkgs#sops -c sops --decrypt "$file"
fi fi
# sops opens $EDITOR on a temp file and re-encrypts only if it changed. # sops re-encrypts only if the $EDITOR session actually changed the temp file.
# Pitfalls that cause "File has not changed, exiting": # GUI editors (code/zed) return instantly unless forced to --wait, and if
# - $EDITOR unset: no editor is on the `nix shell` PATH -> bundle one. # $EDITOR is unset no editor exists on the `nix shell` PATH, so bundle one.
# - GUI editor (code/zed) forks and returns instantly -> force --wait.
editor="${VISUAL:-${EDITOR:-}}" editor="${VISUAL:-${EDITOR:-}}"
extra=() extra=()
case "$editor" in case "$editor" in
+28 -55
View File
@@ -1,50 +1,28 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Import the OLD ZimaOS/CasaOS Immich database into the NixOS-managed one. # Import the OLD ZimaOS/CasaOS Immich database into the NixOS-managed one.
# Run this ON jupiter, as root, ONCE, AFTER the first `./deploy switch jupiter` # Run ONCE on jupiter, as root, after the first `./deploy switch jupiter` that
# that ships services/media/immich.nix (the empty `immich` DB must exist). # ships services/media/immich.nix (the empty `immich` DB must already exist).
#
# The media files are moved separately — do that FIRST, it is a rename on the
# same filesystem, so instant even at 9.1G. Move the CONTENTS, not the dir:
# systemd.tmpfiles already created /mnt/data/AppData/immich on the first
# deploy, so `mv <src> <dst>` would nest it as .../immich/upload/ and every
# thumbnail lookup would ENOENT.
# #
# Move the media files separately FIRST (a same-filesystem rename, instant
# even at 9.1G) — move the CONTENTS of /mnt/data/Immich/upload into
# /mnt/data/AppData/immich, not the directory itself, or it nests under
# .../immich/upload and every thumbnail lookup ENOENTs:
# systemctl stop immich-server immich-machine-learning # systemctl stop immich-server immich-machine-learning
# mv /mnt/data/Immich/upload/* /mnt/data/AppData/immich/ # mv /mnt/data/Immich/upload/* /mnt/data/AppData/immich/
# chown -R immich:immich /mnt/data/AppData/immich # chown -R immich:immich /mnt/data/AppData/immich && chmod 700 /mnt/data/AppData/immich
# chmod 700 /mnt/data/AppData/immich
# #
# Expected afterwards: library/ upload/ thumbs/ encoded-video/ profile/ backups/ # The legacy cluster is Postgres 14 + VectorChord 0.3.0 + pgvector 0.8.1 (the
# # same extensions nixpkgs ships), so this is a plain version-upgrade
# The legacy cluster turned out to be Postgres 14 running VectorChord 0.3.0 + # dump/restore — smart-search and face embeddings come across intact with no
# pgvector 0.8.1 (NOT pgvecto.rs), the same extensions nixpkgs ships — so this # ML rerun needed.
# is a plain version-upgrade dump/restore and the smart-search and face
# embeddings come across intact. No re-running the ML jobs over the library.
# Upstream's accepted VectorChord range is >= 0.3, < 2.0, so 0.3.0 -> 1.1.1 is
# a supported jump; the REINDEX at the end is what upstream asks for after a
# version change.
#
# What this script does:
# 1. cp -a the legacy PGDATA to a scratch dir (the original is never touched,
# never even mounted rw — postgres would replay WAL into it).
# 2. Boots that copy under immich's own PG14 image, pinned to the SAME
# VectorChord version nixpkgs has (1.1.1), and runs `ALTER EXTENSION
# vchord UPDATE` so the catalog matches the loaded library.
# 3. Dumps it with the LOCAL pg_dump (17.x) over TCP, not the container's
# pg_dump (14.x) — dumping with the newer tool is the supported direction.
# 4. Restores into a scratch DB, hands ownership to the immich role, shows
# you the row counts, and only swaps it into place after you confirm.
#
# Afterwards Immich runs its own schema migrations up to 2.7.5 on first start.
set -euo pipefail set -euo pipefail
LEGACY="${LEGACY:-/mnt/data/Immich/pg-data}" LEGACY="${LEGACY:-/mnt/data/Immich/pg-data}"
WORK="${WORK:-/var/tmp/immich-import}" WORK="${WORK:-/var/tmp/immich-import}"
# Pinned to EXACTLY what the legacy cluster records in pg_extension # Pinned to exactly what the legacy cluster's pg_extension records (vchord
# vchord 0.3.0 + pgvector 0.8.1 so the old server reads its own indexes # 0.3.0/pgvector 0.8.1) so it reads its own indexes unmodified; the dump/
# without any in-place extension upgrade. The target side is vchord 1.1.1 / # restore rebuilds indexes from scratch on the target's newer versions, so
# pgvector 0.8.2, which is fine: a dump/restore rebuilds every index from # only the index definitions need to stay valid.
# scratch, so only the index DEFINITION has to still be valid there.
IMAGE="${IMAGE:-ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvector0.8.1}" IMAGE="${IMAGE:-ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvector0.8.1}"
CTR=immich-legacy-pg CTR=immich-legacy-pg
PORT="${PORT:-15432}" PORT="${PORT:-15432}"
@@ -71,13 +49,10 @@ cp -a "$LEGACY" "$WORK/pgdata"
# A crashed cluster leaves this behind; it makes the container refuse to start. # A crashed cluster leaves this behind; it makes the container refuse to start.
rm -f "$WORK/pgdata/postmaster.pid" rm -f "$WORK/pgdata/postmaster.pid"
# The dump runs over TCP (local pg_dump 17 -> published port), and this # The marketplace app's original POSTGRES_PASSWORD is long gone, and
# cluster's own pg_hba wants a password for host connections — the marketplace # POSTGRES_HOST_AUTH_METHOD only applies when the image initializes a cluster
# app's POSTGRES_PASSWORD is long gone, and POSTGRES_HOST_AUTH_METHOD only # (not an existing one) — so pg_hba is REPLACED outright (not appended, since
# applies when the image INITIALISES a cluster, not to an existing one. This is # it's first-match-wins) to trust this scratch copy while it's dumped.
# a scratch copy bound to 127.0.0.1 for the length of one dump, so trust it.
# REPLACE the file rather than appending: pg_hba is first-match-wins, and the
# image's existing scram-sha-256 line would shadow anything added below it.
cat > "$WORK/pgdata/pg_hba.conf" <<'EOF' cat > "$WORK/pgdata/pg_hba.conf" <<'EOF'
local all all trust local all all trust
host all all 0.0.0.0/0 trust host all all 0.0.0.0/0 trust
@@ -101,10 +76,10 @@ for _ in $(seq 1 60); do
done done
[ "${ready:-}" = 1 ] || { podman logs --tail 30 "$CTR"; die "legacy postgres never became ready"; } [ "${ready:-}" = 1 ] || { podman logs --tail 30 "$CTR"; die "legacy postgres never became ready"; }
# The compose stack's POSTGRES_USER is not recorded anywhere on disk and is NOT # The original POSTGRES_USER isn't recorded on disk and wasn't necessarily
# necessarily "postgres" — the ZimaOS/CasaOS marketplace app used "casaos". # "postgres" (this marketplace app used "casaos"), and pg_isready reports
# pg_isready reports "accepting connections" even for a role that doesn't # ready even for a role that doesn't exist — so probe for one that can
# exist, so probe for one that can actually log in. # actually log in.
if [ -z "$LEGACY_USER" ] || ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqt >/dev/null 2>&1; then if [ -z "$LEGACY_USER" ] || ! podman exec "$CTR" psql -U "$LEGACY_USER" -lqt >/dev/null 2>&1; then
for candidate in casaos immich postgres; do for candidate in casaos immich postgres; do
if podman exec "$CTR" psql -U "$candidate" -lqt >/dev/null 2>&1; then if podman exec "$CTR" psql -U "$candidate" -lqt >/dev/null 2>&1; then
@@ -160,13 +135,11 @@ echo ">> errors logged: $(grep -c '^ERROR' "$WORK/restore.log" || true) (see $W
grep '^ERROR' "$WORK/restore.log" | sort -u | head -10 | sed 's/^/ /' || true grep '^ERROR' "$WORK/restore.log" | sort -u | head -10 | sed 's/^/ /' || true
step "handing ownership to the immich role" step "handing ownership to the immich role"
# --no-owner made everything owned by the restoring role (postgres); immich # immich's own ALTER TABLE migrations need it to own its schema, but plain
# connects as "immich" and its startup migrations run ALTER TABLE, so it must # `REASSIGN OWNED BY postgres` also sweeps up system objects and fails on ones
# own its own schema. NOT `REASSIGN OWNED BY postgres`that also sweeps up # the database system requires — so ownership is walked table-by-table
# system objects and fails with "cannot reassign ownership of objects owned by # instead, skipping extension-owned routines/types, which correctly stay with
# role postgres because they are required by the database system". Extension- # postgres.
# owned routines/types are excluded for the same reason; immich never alters
# those, and they correctly stay with postgres.
sudo -u postgres psql -qd "$STAGING_DB" <<'SQL' sudo -u postgres psql -qd "$STAGING_DB" <<'SQL'
ALTER SCHEMA public OWNER TO immich; ALTER SCHEMA public OWNER TO immich;
DO $$ DO $$
+14 -23
View File
@@ -9,11 +9,10 @@
enable = true; enable = true;
enableLocalDB = true; # spins up a local, unauthenticated-on-localhost mongodb enableLocalDB = true; # spins up a local, unauthenticated-on-localhost mongodb
# LibreChat's isEnabled() treats an UNSET var as false, not true — so # LibreChat's isEnabled() treats an unset var as false, not true (despite
# registration is closed unless this is explicit, despite .env.example # .env.example suggesting true is the default), so this must be explicit.
# suggesting true is the default. Only reachable over the tailnet # Fine to leave open since it's tailnet-only; flip to false once your
# (trusted interface, see module comment below), so leaving it open is # account exists to lock it down.
# fine; flip to false once your account exists if you want it locked down.
env.ALLOW_REGISTRATION = true; env.ALLOW_REGISTRATION = true;
credentials = { credentials = {
@@ -32,10 +31,9 @@
apiKey = "ollama"; apiKey = "ollama";
baseURL = "http://127.0.0.1:11434/v1"; baseURL = "http://127.0.0.1:11434/v1";
models = { models = {
# schema requires >=1 entry even though fetch=true overwrites it # Schema requires >=1 entry even though fetch=true overwrites this at
# at runtime with whatever's pulled (see loadModels in # runtime with whatever's pulled (hosts/terra/configuration.nix) —
# hosts/terra/configuration.nix) — kept roughly in sync anyway # kept roughly in sync so the UI has sane names before the first fetch.
# so the UI has sane names before the first fetch completes.
default = [ "gemma4:12b" "qwen3.6:35b-a3b" "VladimirGav/qwen3.8-27B-14GB-IQ4:latest" ]; default = [ "gemma4:12b" "qwen3.6:35b-a3b" "VladimirGav/qwen3.8-27B-14GB-IQ4:latest" ];
fetch = true; # pull the model list from ollama at startup fetch = true; # pull the model list from ollama at startup
}; };
@@ -43,22 +41,15 @@
} }
]; ];
# Persistent memory is opt-in at the CONFIG level — omitting this block # Persistent memory is opt-in at the config level — omitting this block
# (as before) leaves the feature entirely off, no matter what a user # leaves it off regardless of the user's Settings > Personalization toggle.
# toggles in Settings > Personalization. `agent.provider` must match # `agent.provider` must match endpoints.custom[].name above exactly.
# endpoints.custom[].name above exactly ("Ollama"), which is how the
# memory-extraction agent picks a backend/model.
memory = { memory = {
personalize = true; # still needs a per-user opt-in toggle in the UI personalize = true; # still needs a per-user opt-in toggle in the UI
# instructions REPLACES the default extraction prompt entirely (not # instructions REPLACES the default extraction prompt, not appends to it —
# appended to it) — the 3b model (llama3.2:3b, dropped) was # needed because the smaller llama3.2:3b (since dropped) kept saving its
# defaulting to saving things like its own "I am a helpful # own assistant boilerplate as memories, a capability ceiling rather than
# assistant..." boilerplate under an invented "user_conversation" # a prompting gap. validKeys whitelists what can be stored.
# key, and even after adding this prompt, still saved "I am an AI
# assistant with tool calling capabilities" as personal_info after
# the user introduced THEMSELVES — a capability ceiling, not a
# prompting problem. validKeys constrains it to a fixed whitelist
# and instructions spells out the bar for each one.
validKeys = [ "user_preferences" "personal_info" "ongoing_projects" "technical_context" ]; validKeys = [ "user_preferences" "personal_info" "ongoing_projects" "technical_context" ];
agent = { agent = {
enabled = true; enabled = true;
+72 -153
View File
@@ -1,14 +1,12 @@
{ config, lib, pkgs, ... }: { config, lib, pkgs, ... }:
# Gitea — self-hosted git. stateDir/repositories were migrated from the old # Gitea — self-hosted git. Repos were migrated from the old ZimaOS docker
# ZimaOS docker instance straight into stateDir's default layout, so no # instance straight into stateDir's default layout, so after first deploy
# import step is needed — just chown it to the gitea user after first deploy # just: chown -R gitea:gitea /mnt/data/AppData/gitea
# (currently darman:users from the CIFS copy):
# chown -R gitea:gitea /mnt/data/AppData/gitea
# #
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix). # HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not # SSH uses gitea's own server on :2222, since the unprivileged gitea user
# :222 — the unpriv gitea user can't bind <1024). # can't bind :22 or :222 (<1024).
let let
# Repos where the ci-bot account (see below) should be a Write collaborator # Repos where the ci-bot account (see below) should be a Write collaborator
# and whitelisted to push past branch protection. Add a repo here and # and whitelisted to push past branch protection. Add a repo here and
@@ -21,40 +19,15 @@ let
# nothing she does lands without darman clicking merge. # nothing she does lands without darman clicking merge.
lunaRepos = [ "darman/homelab" ]; lunaRepos = [ "darman/homelab" ];
# One gitea webhook per Hermes route. `route` is the path segment Hermes # One gitea webhook per Hermes route; `route` must match a key in the route
# dispatches on (http://mars.orbit.sol:8644/webhooks/<route>), so it must # config hosts/mars/hermes-agent.nix writes.
# match a key in the route config that hosts/mars/hermes-agent.nix writes.
# #
# `events` are the strings gitea's HOOK API accepts. That set is coarser # `events` must be gitea's HOOK API event names, which gitea silently drops
# than gitea's internal HookEventType set, and both collide on spelling with # if unrecognized — registering with no events and no error ("pull_request_
# the wire names Hermes matches on — three namespaces, one of which is a # review_comment" did this: a real HookEventType, but not an API name).
# trap. From routers/api/v1/utils/hook.go (updateHookEvents), # `pull_request_review` also covers approvals with no narrower option, so
# models/webhook/webhook.go (HasEvent) and modules/webhook/type.go (Event()): # those are filtered on the mars side instead (answered 200 and ignored —
# # expected, not a failure).
# api event (here) delivers wire name (mars route)
# -------------------- ------------------- ----------------------
# pull_request_comment comment on a PR issue_comment
# pull_request_review review with a body pull_request_comment
# changes requested pull_request_rejected
# approval pull_request_approved
#
# So this file and hosts/mars/hermes-agent.nix name the same event
# differently on purpose, and neither is a typo.
#
# THE TRAP: updateHookEvents silently ignores strings it does not recognise,
# so a plausible-looking but non-API name leaves the hook registered with no
# events at all, delivering nothing and reporting no error. That is exactly
# what "pull_request_review_comment" did here — a real HookEventType, and a
# real value of X-GitHub-Event-Type, but not an API event name.
#
# There is no narrower name for reviews: HasEvent collapses approved,
# rejected and review-comment onto HookEventPullRequestReview, so
# `pull_request_review` is a single switch for all three. Approvals
# therefore cannot be excluded here. They are dropped on the mars side
# instead — the route's event list has no "pull_request_approved", so Hermes
# answers {"status": "ignored"} without running the filter or spending a
# token. Expect approvals in gitea's delivery log, answered 200 and ignored;
# that is the design, not a failure.
giteaHermesHooks = [ giteaHermesHooks = [
{ {
name = "PR comments Hermes"; name = "PR comments Hermes";
@@ -81,9 +54,8 @@ in
server = { server = {
DOMAIN = "git.mgaction.town"; DOMAIN = "git.mgaction.town";
SSH_DOMAIN = "git.mgaction.town"; SSH_DOMAIN = "git.mgaction.town";
# https, not http: neptun's Caddy terminates TLS for this name. Gitea # https, not http: neptun's Caddy terminates TLS here, and gitea builds
# builds its absolute URLs (clone buttons, redirects, webhooks) from # its absolute URLs (clone buttons, webhooks) from ROOT_URL.
# ROOT_URL, so an http:// value hands out downgraded links.
ROOT_URL = "https://git.mgaction.town/"; ROOT_URL = "https://git.mgaction.town/";
HTTP_PORT = 3000; HTTP_PORT = 3000;
START_SSH_SERVER = true; START_SSH_SERVER = true;
@@ -94,20 +66,11 @@ in
DISABLE_REGISTRATION = true; DISABLE_REGISTRATION = true;
}; };
security = { security = {
# Gitea refuses to deliver a webhook to any host outside this list, # Gitea's default `external` webhook target filter treats tailnet
# which defaults to `external` — "a valid non-private unicast IP". # addresses (100.64.0.0/10, CGNAT) as neither private nor external, so
# Tailscale addresses are 100.64.0.0/10 (RFC 6598 carrier-grade NAT), # the mars hermes relay was refused until the CIDR was added here.
# which is neither RFC1918 private nor, as far as gitea's matcher is # Lives under [security], not the deprecated [webhook] key it falls
# concerned, external — so the hermes relay on mars was refused with # back to.
# deny 'mars.orbit.sol(100.64.0.6:8644)'
# even though nothing here is private in the RFC1918 sense. Adding
# the tailnet CIDR is what makes tailnet-internal webhook targets
# deliverable at all; `external` is kept so a future webhook to a
# public service (discord, slack) still works without another edit.
#
# This lives in [security], not [webhook]: the webhook-section key is
# deprecated and now just falls back to this one, which is the name
# the delivery error itself reports.
ALLOWED_HOST_LIST = "external,100.64.0.0/10"; ALLOWED_HOST_LIST = "external,100.64.0.0/10";
}; };
actions = { actions = {
@@ -118,14 +81,10 @@ in
networking.firewall.allowedTCPPorts = [ 2222 ]; networking.firewall.allowedTCPPorts = [ 2222 ];
# `gitea <args>` == the admin CLI, as the gitea user, against the real # `gitea <args>` == the admin CLI as the gitea user against the real state
# state dir — mirrors the `hermes` alias on mars. Worth having because none # dir. Not otherwise usable: the package isn't on PATH, and admin
# of that is discoverable: the package is not in systemPackages (so `gitea` # subcommands need GITEA_WORK_DIR set and root-owned files avoided by
# is not otherwise on PATH at all), every admin subcommand needs # running as gitea.
# GITEA_WORK_DIR pointed at a stateDir that is not the module default, and
# it has to run as the gitea user or it writes root-owned files into that
# directory. Both paths come from the config rather than being spelled out,
# so a package bump or a stateDir move cannot leave this stale.
# #
# Handy ones: # Handy ones:
# gitea admin user generate-access-token --username luna \ # gitea admin user generate-access-token --username luna \
@@ -138,15 +97,13 @@ in
users.users.gitea.extraGroups = [ "users" ]; users.users.gitea.extraGroups = [ "users" ];
# Runner instance registered against this same gitea. Jobs run in containers # Runner instance registered against this same gitea. Jobs run in podman
# (podman, via services/containers.nix — already enabled on jupiter), one # containers (services/containers.nix), one image per `runs-on` label, using
# image per requested `runs-on` label using the catthehacker act-compatible # the catthehacker act-compatible images.
# images (same ones upstream `act`/Forgejo docs recommend).
# #
# tokenFile points at an env file rendered by sops (TOKEN=<registration # tokenFile (not `token`) keeps the sops-rendered secret out of the Nix
# token>, see hosts/jupiter/secrets.nix) rather than a plain `token`, so the # store. The registration token isn't generated by this module — get it
# secret never lands in the Nix store. The registration token itself is NOT # from gitea once Actions is enabled:
# generated by this module — it comes from gitea once Actions is enabled:
# su gitea -s /bin/sh -c \ # su gitea -s /bin/sh -c \
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token' # 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea actions generate-runner-token'
# then written into secrets/jupiter.yaml as gitea_runner_token. # then written into secrets/jupiter.yaml as gitea_runner_token.
@@ -161,23 +118,18 @@ in
]; ];
}; };
# ci-bot: dedicated account CI workflows push as (kept separate from any # ci-bot: dedicated account CI workflows push as, so its PAT can be scoped
# human account so its own PAT can be scoped/rotated/revoked independently). # and rotated independently of any human account. Collaborator access and
# Collaborator access + branch-protection push-whitelisting have no CLI or # branch-protection whitelisting have no CLI/config-file surface in gitea —
# config-file surface in gitea — only the HTTP API — so this is the one # only the HTTP API — so this oneshot re-applies the desired state via
# part of the setup that stays imperative even though it's nix-triggered: # PUT/PATCH on every deploy (won't self-heal a manual UI revert unless
# a oneshot that PUTs/PATCHes the API into the desired state on every # restarted).
# deploy where its script changed (adding a repo to `ciBotRepos` and
# redeploying is enough to pick it up; it won't self-heal a manual revert
# done via the web UI unless the unit is also restarted).
# #
# Auth for those API calls is darman's OWN token (named # Auth is darman's own token (write:repository + write:user, see
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository + # hosts/jupiter/secrets.nix): an owner-scoped token is required by the
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos # collaborator/branch-protection endpoints, and write:user is needed to
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on # push ci-bot's token as a secret on darman's account — ci-bot can't grant
# the collaborator/branch-protection endpoints; write:user is additionally # itself access.
# needed to push ci-bot's token below as a secret on darman's own account.
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
# #
# ci-bot's own push token (separate secret, ci_bot_token) is generated # ci-bot's own push token (separate secret, ci_bot_token) is generated
# once via: # once via:
@@ -255,47 +207,26 @@ in
''; '';
}; };
# luna: Hermes Agent's own gitea identity (Hermes was renamed L.U.N.A., # luna: Hermes Agent's gitea identity, deliberately PR-tier only (not
# 2026-08-22). Deliberately PR-tier only, not push-tier like ci-bot: # push-tier like ci-bot) — Hermes runs on mars, takes Telegram instructions,
# Hermes runs on mars, takes instructions over Telegram, and can be # and can be prompt-injected via tool output, so branch protection below
# prompt-injected via tool output — a dedicated account with its own # keeps her off `master` regardless of what her token can technically do:
# scoped, revocable token keeps that blast radius off darman's own # - enable_push_whitelist(darman only): nobody but darman pushes to master.
# credentials, and the branch-protection whitelists below keep it off # - enable_merge_whitelist(darman only): opening a PR isn't merging one.
# `master` entirely regardless of what the token can technically do. # - required_approvals=1 + enable_approvals_whitelist(darman only): no
# She gets Write collaborator access (needed to push a branch and open a # self-approval from a second identity.
# PR against the same repo — this instance has no fork workflow), but: # This is the server side only; the client side (git/tea, token) is in
# - enable_push + enable_push_whitelist(darman only): nobody but darman # hosts/mars/hermes-agent.nix.
# can push straight to master; luna can only land on a side branch.
# - enable_merge_whitelist(darman only): opening a PR is not the same
# as merging one — only darman can click merge.
# - required_approvals=1 + enable_approvals_whitelist(darman only):
# an approval has to come from darman specifically, not luna
# rubber-stamping her own PR from a second identity.
# This covers the SERVER side only (account + collaborator + branch
# protection). The client side — git/tea inside the hermes-agent container,
# and the token below — lives in hosts/mars/hermes-agent.nix.
# #
# luna's own push token is generated once, the same way ci-bot's was: # luna's push token is generated once (same as ci-bot's, username luna,
# su gitea -s /bin/sh -c \ # scopes write:repository,write:issue,read:user) and stored as a secret —
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \ # NOT pushed into gitea as an Actions secret, since she's an external agent
# --username luna --scopes write:repository,write:issue,read:user' # calling in, not a CI workflow.
# then stored as a secret (e.g. secrets/mars.yaml's gitea_luna_token) —
# NOT pushed into gitea itself as an Actions secret like ci-bot's is,
# since luna isn't a CI workflow running inside gitea, she's an external
# agent calling out to it.
# #
# **write:issue is NOT optional and is easy to miss**: this token started # write:issue is required, not optional: a PR is an issue in gitea's data
# life as `write:repository` alone, which clones, fetches and pushes # model, so `tea pr create` needs it even though push/fetch work fine on
# branches perfectly well — so everything looks fine right up until the # write:repository alone. The resulting error misleadingly names read:issue
# first `tea pr create`, which gitea rejects with # (the first check tea trips), not write:issue.
# token scope=write:repository,read:user required=read:issue
# A pull request IS an issue in gitea's data model, so every /pulls
# endpoint is gated on the *issue* scope category, not the repository one.
# write:issue covers it (in gitea's scope model write:X implies read:X);
# read:issue alone would satisfy the GET half and then fail the POST that
# actually opens the PR. The error names read:issue only because that's
# the first check tea trips on. Rotating the token is free — the prepare
# oneshot on mars does delete-then-add for the tea login on every start.
systemd.services.gitea-luna-provision = { systemd.services.gitea-luna-provision = {
description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access"; description = "Provision luna (Hermes Agent) gitea account + PR-tier repo access";
after = [ "gitea.service" ]; after = [ "gitea.service" ];
@@ -359,14 +290,10 @@ in
''; '';
}; };
# Register one Gitea webhook per Hermes route (giteaHermesHooks above). # Register one Gitea webhook per Hermes route (giteaHermesHooks above),
# Idempotent: each target URL is updated if a hook for it already exists and # idempotently (update if the target URL exists, else create). Deliberately
# created otherwise. # never deletes — a hook for a route removed from the list is retired by
# # hand in Settings -> Webhooks, not silently by a redeploy.
# It deliberately does NOT delete anything, including hooks for routes that
# were removed from the list above. Retiring one is a one-off, done by hand
# in the repo's Settings -> Webhooks, so that a redeploy can never silently
# unregister a hook someone added on purpose.
systemd.services.gitea-hermes-webhook-provision = { systemd.services.gitea-hermes-webhook-provision = {
description = "Provision Gitea webhooks for Hermes routes"; description = "Provision Gitea webhooks for Hermes routes";
after = [ "gitea.service" ]; after = [ "gitea.service" ];
@@ -386,24 +313,17 @@ in
set -euo pipefail set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1 api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
# Neither secret is ever passed as an argument. This unit runs as the # Secrets never go on argv, since /proc/<pid>/cmdline is world-readable
# gitea user on a multi-user box, where /proc/<pid>/cmdline is # on this multi-user box: the token goes into a 0600 curl config file
# world-readable for the lifetime of the process — so `-H "Authorization: # (printf avoids argv entirely), the webhook secret into jq via
# token $t"` would publish the admin token, and `jq --arg secret "$s"` # --rawfile, and the body into curl via stdin.
# the webhook secret. The token goes into a 0600 curl config file
# instead (printf is a shell builtin, so the substitution below never
# reaches an argv), the webhook secret into jq via --rawfile, and the
# request body into curl on stdin with --data @-.
authcfg="$(mktemp)" authcfg="$(mktemp)"
trap 'rm -f "$authcfg"' EXIT trap 'rm -f "$authcfg"' EXIT
chmod 0600 "$authcfg" chmod 0600 "$authcfg"
printf 'header = "Authorization: token %s"\n' "$(cat "$TOKEN_FILE")" > "$authcfg" printf 'header = "Authorization: token %s"\n' "$(cat "$TOKEN_FILE")" > "$authcfg"
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision # Same readiness gate as the other provisioning units: After=gitea.service
# above: After=gitea.service only means the process started, not that it # only means the process started, not that it's serving HTTP yet.
# is serving HTTP yet. Without this the first curl below fails under
# `set -e`, and a Type=oneshot with no Restart= stays failed — leaving
# the webhooks silently unregistered until someone restarts the unit.
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1 sleep 1
@@ -413,10 +333,9 @@ in
local name="$1" route="$2" events="$3" url body hook_id local name="$1" route="$2" events="$3" url body hook_id
url="http://mars.orbit.sol:8644/webhooks/$route" url="http://mars.orbit.sol:8644/webhooks/$route"
# rtrimstr: sops stores this without a trailing newline, but one # rtrimstr: a stray trailing newline would change the HMAC key and
# slipping in would change the key the HMAC is computed with and make # break signature validation on the Hermes side, which trims the same
# every delivery fail signature validation on the Hermes side. The # way.
# same trim happens there, so both ends agree either way.
body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \ body="$(jq -n --rawfile rawSecret "$SECRET_FILE" \
--arg url "$url" --arg name "$name" --argjson events "$events" \ --arg url "$url" --arg name "$name" --argjson events "$events" \
'{type: "gitea", name: $name, active: true, events: $events, '{type: "gitea", name: $name, active: true, events: $events,
+29 -45
View File
@@ -1,66 +1,50 @@
{ config, ... }: { config, ... }:
# CouchDB, tuned as the backend for Obsidian Self-hosted LiveSync # Plain CouchDB 3 node, tuned as the backend for Obsidian Self-hosted LiveSync
# (vrtmrz/obsidian-livesync). The plugin replicates the vault into CouchDB # (vrtmrz/obsidian-livesync), which replicates the vault into it via PouchDB.
# chunk-by-chunk over PouchDB's replication protocol, so this is a plain
# CouchDB 3 node — nothing Obsidian-specific runs here.
# #
# Published PUBLICLY as https://notes.mgaction.town via neptun's caddy (see # Published PUBLICLY as https://notes.mgaction.town via neptun's caddy, since
# hosts/neptun/configuration.nix), because Obsidian's mobile apps refuse # Obsidian's mobile apps refuse cleartext HTTP and jupiter's *.jupiter.sol
# cleartext HTTP and jupiter's *.jupiter.sol names cannot get a real cert. # names can't get a real cert — so the settings below are security-relevant:
# That makes the settings below security-relevant, not cosmetic: # - `require_valid_user` in both [chttpd] and [chttpd_auth], else CouchDB
# answers unauthenticated GETs on the open internet.
# - neptun's vhost allowlists only the plugin's endpoints; Fauxton and
# cluster/config are reachable only over the tailnet.
# - Turn on the plugin's end-to-end encryption (+ "Obfuscate Properties"),
# so this server only ever holds ciphertext — what makes a
# publicly-reachable credentialed database an acceptable trade.
# #
# - `require_valid_user` in BOTH [chttpd] and [chttpd_auth]: without it # Its passphrase must stay a SEPARATE secret from couchdb_admin_password:
# CouchDB answers unauthenticated GETs on the open internet. # the CouchDB password is stored here and in secrets/jupiter.yaml, while
# - neptun's vhost allowlists only the endpoints the plugin uses, so Fauxton # the E2EE passphrase never leaves the clients (kept in the HomeLab Proton
# (/_utils) and the cluster/config endpoints are not reachable from # Pass vault, not sops) — reusing one string for both would hand the
# outside at all — reach them over the tailnet instead. # decryption key to whoever gets the CouchDB credential. Losing the
# - Turn ON end-to-end encryption in the plugin (Settings → Remote Database # passphrase costs the remote database, not the notes: wipe and
# → End-to-End Encryption, plus "Obfuscate Properties", which covers the
# paths and timestamps that E2EE alone leaves readable). Then this server
# only ever holds ciphertext, which is what makes a publicly-reachable
# credentialed database an acceptable trade rather than a bad one.
#
# Its passphrase is a SEPARATE secret from couchdb_admin_password below —
# deliberately, and it must stay that way. The couchdb password
# authenticates to this server and is stored here (hashed) and in
# secrets/jupiter.yaml; the E2EE passphrase never leaves the Obsidian
# clients and CouchDB has no idea it exists. Reusing one string for both
# hands whoever obtains that credential the decryption key as well, which
# is precisely the failure E2EE is here to prevent. The passphrase is
# therefore NOT in sops (nothing on this host consumes it) — it lives in
# the HomeLab Proton Pass vault, with the deploy credentials.
#
# Losing it costs the remote database, not the notes: wipe it and
# re-initialize from a device that still holds the plaintext vault. # re-initialize from a device that still holds the plaintext vault.
{ {
services.couchdb = { services.couchdb = {
enable = true; enable = true;
# Listens on all interfaces, same reasoning as immich: :5984 is NOT opened # Listens on all interfaces, but :5984 is not opened in the firewall, so
# in the firewall, so it is reachable over tailscale0 (trusted in # it's reachable only over tailscale0 (trusted) and localhost — the path
# common.nix) and localhost only. That is the path neptun's caddy takes. # neptun's caddy takes.
bindAddress = "0.0.0.0"; bindAddress = "0.0.0.0";
port = 5984; port = 5984;
# The vault database is the ONLY copy of the notes once LiveSync is the # The vault database is the ONLY copy of the notes once LiveSync is the
# source of truth, so it belongs on the array, not the 29G eMMC. All three # source of truth, so it belongs on the array, not the 29G eMMC — all
# of these default under /var/lib/couchdb and have to move together # three default under /var/lib/couchdb and must move together.
# configFile especially, since CouchDB writes to it at runtime (below).
databaseDir = "/mnt/data/AppData/couchdb"; databaseDir = "/mnt/data/AppData/couchdb";
viewIndexDir = "/mnt/data/AppData/couchdb"; viewIndexDir = "/mnt/data/AppData/couchdb";
configFile = "/mnt/data/AppData/couchdb/local.ini"; configFile = "/mnt/data/AppData/couchdb/local.ini";
# The admin password, as an [admins] ini fragment from sops. # [admins] ini fragment from sops; services.couchdb.adminPass would render
# services.couchdb.adminPass would render it into the world-readable # into the world-readable store instead.
# store; extraConfigFiles is the module's own documented hook for this
# (hosts/jupiter/secrets.nix renders the template).
# #
# ⚠️ CouchDB hashes a plaintext admin password at startup and persists the # ⚠️ CouchDB hashes the password at startup and persists it to local.ini
# hash to the LAST, writable file in its ini chain — local.ini above, # (above), which then takes precedence — so changing the sops value alone
# which then takes precedence over this fragment. So changing the sops # does NOT rotate it. Also delete the `[admins]` line from
# value alone does NOT rotate the password: delete the `[admins]` line # /mnt/data/AppData/couchdb/local.ini and restart.
# from /mnt/data/AppData/couchdb/local.ini and restart as well.
extraConfigFiles = [ config.sops.templates."couchdb-admins.ini".path ]; extraConfigFiles = [ config.sops.templates."couchdb-admins.ini".path ];
# Values taken from LiveSync's own CouchDB setup documentation; the plugin # Values taken from LiveSync's own CouchDB setup documentation; the plugin
+6 -7
View File
@@ -1,12 +1,11 @@
{ config, ... }: { config, ... }:
# Cinephage — indexer search + streaming/library manager. Runs the official # Cinephage — indexer search + streaming/library manager, run as the official
# container image, not upstream's nix flake module: its npmDepsHash is stale # container image rather than upstream's nix flake module (its npmDepsHash is
# against its own package-lock.json, and a transitive dep hard-enforces pnpm, # stale and a transitive dep hard-enforces pnpm, breaking the sandboxed npm
# breaking the nix-sandboxed npm build regardless. Docker is the actually- # build). BETTER_AUTH_SECRET (paired sops secret, hosts/jupiter/secrets.nix)
# maintained path. BETTER_AUTH_SECRET (paired sops secret in # signs sessions and encrypts stored API keys — keep it static, since losing
# hosts/jupiter/secrets.nix) signs sessions/encrypts stored API keys — must # it invalidates everything.
# be static, not app-generated, or losing it invalidates everything.
{ {
virtualisation.oci-containers.containers.cinephage = { virtualisation.oci-containers.containers.cinephage = {
image = "ghcr.io/moldytaint/cinephage:latest"; image = "ghcr.io/moldytaint/cinephage:latest";
+9 -9
View File
@@ -1,10 +1,10 @@
{ config, ... }: { config, ... }:
# MediaManager — media request/library manager. Module comes from the # MediaManager — media request/library manager (module from the
# community flake input `mediamanager-nix`, not nixpkgs. Paired sops secret # `mediamanager-nix` flake input, not nixpkgs). The paired sops secret
# in hosts/jupiter/secrets.nix — without it the module mints+discards a # (hosts/jupiter/secrets.nix) is required — without it the module mints a
# random auth token_secret on every restart, logging everyone out. # random token_secret every restart, logging everyone out; port 8010 since
# Port 8010: 8000 is taken by audiobookshelf on this host. # audiobookshelf already holds 8000.
{ {
services.media-manager = { services.media-manager = {
enable = true; enable = true;
@@ -45,9 +45,9 @@
MEDIAMANAGER_INDEXERS__PROWLARR__API_KEY=${config.sops.placeholder.prowlarr_api_key} MEDIAMANAGER_INDEXERS__PROWLARR__API_KEY=${config.sops.placeholder.prowlarr_api_key}
''; '';
# HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 on disk — # HighSeas/{Movies,Shows,images,Downloads} are darman:users 755 (no group
# group has no write bit. media-manager is in "users" (below); the dirs # write bit); media-manager is in "users" (below), and the dirs were
# themselves were chmod g+w by hand once (not declarative — see CLAUDE.md # chmod g+w by hand once since this is pre-existing data, not something
# gotchas), since this is pre-existing data, not something tmpfiles owns. # tmpfiles owns.
users.users.media-manager.extraGroups = [ "users" ]; users.users.media-manager.extraGroups = [ "users" ];
} }
+11 -19
View File
@@ -1,30 +1,22 @@
{ config, pkgs, inputs, ... }: { config, pkgs, inputs, ... }:
# Authentik — self-hosted identity/OIDC provider. # Authentik — self-hosted identity/OIDC provider. Replaced Zitadel because
# nixpkgs is stuck on 2.71 (no login-v2 split) with a forward-only db
# migration; authentik-nix tracks upstream closely instead.
# #
# Replaced Zitadel: nixpkgs only carries Zitadel 2.71 (no login-v2 split, and # The upstream module owns postgres and its unit ordering, and needs no redis
# a v3/v4 database migrates forward only, so an existing instance can't be # (channels/cache run on postgres). TLS terminates at Caddy; every listener
# moved onto it). authentik-nix tracks upstream closely instead. # below is pinned to loopback since only tailscale0 is trusted.
# #
# The upstream module owns postgres (createDatabase) AND orders the units # Needs an environmentFile from sops (host's secrets.nix) carrying
# against postgresql.target, so no manual After= is needed here. No redis — # AUTHENTIK_SECRET_KEY and AUTHENTIK_BOOTSTRAP_PASSWORD. Keep it root:root
# recent authentik runs channels/cache on postgres. # 0400 (systemd reads it as root before dropping to DynamicUser) — don't set
# # `owner` the way headplane's secrets need.
# TLS terminates at Caddy; every listener is pinned to loopback below so
# nothing is reachable from the tailnet (hosts trust tailscale0).
#
# Needs, wired via sops in the host's secrets.nix: an environmentFile carrying
# - AUTHENTIK_SECRET_KEY (`openssl rand -base64 60`) — signs sessions
# - AUTHENTIK_BOOTSTRAP_PASSWORD first-run akadmin password
# systemd reads EnvironmentFile as root before dropping to the service's
# DynamicUser, so the sops default root:root 0400 is correct — do NOT set
# `owner` on it the way the headplane secrets need.
{ {
imports = [ inputs.authentik-nix.nixosModules.default ]; imports = [ inputs.authentik-nix.nixosModules.default ];
# Pinned explicitly: the default tracks system.stateVersion, so editing that # Pinned explicitly: the default tracks system.stateVersion, so editing that
# line would silently demand a pg_upgrade of the identity store. Bump this # would silently demand a pg_upgrade of the identity store.
# deliberately, with a dump in hand.
services.postgresql.package = pkgs.postgresql_17; services.postgresql.package = pkgs.postgresql_17;
services.authentik = { services.authentik = {
+4 -6
View File
@@ -1,11 +1,9 @@
{ ... }: { ... }:
# Audiobookshelf audiobook/podcast server. # Audiobookshelf audiobook/podcast server, listening on all interfaces but
# Listens on all interfaces: :8000 stays closed on the LAN (no openFirewall), # reachable only via tailscale0 or local caddy (no openFirewall) — library
# but reachable over the trusted tailscale0 interface and via localhost (caddy). # paths are set in the web UI, pointed at /mnt/data/... In the "users" group
# Library/media paths are set in the web UI — point them at /mnt/data/... # so it can read the RAID's group-owned library dirs.
# Runs as user `audiobookshelf`; added to `users` so it can read group-owned
# library dirs on the RAID.
{ {
services.audiobookshelf = { services.audiobookshelf = {
enable = true; enable = true;
+39 -64
View File
@@ -1,30 +1,22 @@
{ config, pkgs, inputs, ... }: { config, pkgs, inputs, ... }:
# Immich photo/video library. Native nixpkgs module (not the upstream compose # Immich photo/video library. Native nixpkgs module, not the upstream compose
# stack) — it owns its own postgres (with the pgvector + vectorchord extensions # stack — it owns its own postgres (pgvector + vectorchord) and a unix-socket redis.
# it needs for search) and a unix-socket redis, so nothing else is required here.
# #
# Storage: everything lives under /mnt/data/AppData/immich, which is the media # Storage lives under /mnt/data/AppData/immich, migrated from the old ZimaOS/CasaOS
# store MIGRATED from the old ZimaOS/CasaOS install's UPLOAD_LOCATION # UPLOAD_LOCATION (same subfolder layout); see scripts/immich-import-legacy-db for
# (/mnt/data/Immich/upload — same layout: library/ upload/ thumbs/ # the matching DB import. The postgres cluster itself stays on the OS disk.
# encoded-video/ profile/ backups/). See scripts/immich-import-legacy-db for the
# matching database import. The postgres cluster itself stays on the OS disk.
# #
# ⚠️ The immich DB is the only copy of albums/faces/dates — the files alone # ⚠️ The immich DB is the only copy of albums/faces/dates — the files alone
# can't rebuild it. It joins the other unbacked databases on this network. # can't rebuild it. It joins the other unbacked databases on this network.
let let
# The PACKAGE comes from nixpkgs-unstable (3.0.3); the MODULE comes from the # Package pinned to nixpkgs-unstable (3.0.3) while the module stays on the 26.05
# 26.05 pin (which ships 2.7.5). That combination is safe because the two # pin (2.7.5) — safe only because the two module files are byte-identical
# module files are byte-identical — verified by diffing them at the revisions # (verified by diff; re-check on any input bump). Needed because immich's
# in flake.lock. RE-CHECK THAT DIFF on any input bump: # migrations are forward-only and jupiter's imported DB was last written by
# 3.0.0, which 2.7.5 refuses to start against; drop once the pin ships >= 3.0.0.
# diff <(nixpkgs)/nixos/modules/services/web-apps/immich.nix \ # diff <(nixpkgs)/nixos/modules/services/web-apps/immich.nix \
# <(unstable)/nixos/modules/services/web-apps/immich.nix # <(unstable)/nixos/modules/services/web-apps/immich.nix
#
# Why: jupiter's imported database was last written by immich 3.0.0, and
# immich runs its migrations forward only — 2.7.5 refuses to start against it
# with "corrupted migrations: previously executed migration
# 1776217577402-DropAuditTable is missing". Drop this override once nixos-26.11
# (or whatever the pin becomes) ships >= 3.0.0.
unstable = import inputs.nixpkgs-unstable { unstable = import inputs.nixpkgs-unstable {
inherit (pkgs.stdenv.hostPlatform) system; inherit (pkgs.stdenv.hostPlatform) system;
}; };
@@ -42,27 +34,20 @@ in
mediaLocation = "/mnt/data/AppData/immich"; mediaLocation = "/mnt/data/AppData/immich";
machine-learning.enable = true; machine-learning.enable = true;
# ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE, and # ⚠️ Setting `settings` at all switches immich to IMMICH_CONFIG_FILE mode,
# that is ALL-OR-NOTHING (dist/utils/config.js: the config is # which is all-or-nothing: undeclared keys fall back to immich's defaults, not
# `configFile ? loadFromFile(...) : metadataRepo.get(SystemConfig)` — the # the admin UI's saved values (which stay in system_metadata and return if
# database copy is IGNORED, not merged). Two consequences: # this block is deleted), and the admin settings UI goes read-only. An
# 1. Anything not declared here falls back to immich's DEFAULTS, not to # unknown/misspelled key is a hard startup failure here (just a warning
# whatever the admin UI had. The old settings stay in the # without a config file), so keys are copied verbatim from `defaults` in
# system_metadata table, so deleting this block restores them. # immich's dist/config.js.
# 2. The admin settings UI goes read-only — saving throws "Cannot update
# configuration while IMMICH_CONFIG_FILE is in use". Change settings
# HERE and redeploy.
# An unknown/misspelled key is a HARD startup failure under a config file
# (the same code path only logs a warning without one), so keys below are
# taken verbatim from `defaults` in immich's dist/config.js.
settings = { settings = {
server.externalDomain = "https://immich.mgaction.town"; server.externalDomain = "https://immich.mgaction.town";
newVersionCheck.enabled = false; # nixpkgs pins the version, not immich newVersionCheck.enabled = false; # nixpkgs pins the version, not immich
# OIDC via Authentik on neptun. The Authentik application/provider is # OIDC via Authentik on neptun; the application/provider is created by hand
# created BY HAND in its UI — same as headscale's and headplane's, which # in its UI (like headscale's and headplane's, separate apps) — only the
# are also separate apps (hosts/neptun/secrets.nix). Only the client # client secret is managed here (hosts/neptun/secrets.nix).
# secret is managed here.
oauth = { oauth = {
enabled = true; enabled = true;
# Authentik's per-application issuer. Trailing slash matters: immich # Authentik's per-application issuer. Trailing slash matters: immich
@@ -76,24 +61,18 @@ in
clientSecret._secret = config.sops.secrets.immich_oauth_client_secret.path; clientSecret._secret = config.sops.secrets.immich_oauth_client_secret.path;
scope = "openid email profile"; scope = "openid email profile";
buttonText = "Login with Authentik"; buttonText = "Login with Authentik";
# Existing accounts (the 2 imported users) keep working: matching is by # Matches by email, so the 2 imported users adopt their Authentik account
# email, so an Authentik user with the same address adopts that account # instead of getting a duplicate.
# rather than creating a second one.
autoRegister = true; autoRegister = true;
# Leave the password form reachable — autoLaunch would bounce straight # Leave the password form reachable — autoLaunch would bounce straight
# to Authentik, locking everyone out if the OIDC app is misconfigured. # to Authentik, locking everyone out if the OIDC app is misconfigured.
autoLaunch = false; autoLaunch = false;
# Land back on immich's own login page after logout. Without this, # Without this, immich falls back to the IdP's discovered
# immich falls back to the IdP's discovered end_session_endpoint # end_session_endpoint and logout dumps you on Authentik's own page
# (auth.service.js:320-326) and logout dumps you on Authentik's # instead of back here — must be an absolute url, mirroring immich's
# "you've been logged out" page instead. Must be an ABSOLUTE url — # internal LOGIN_URL. This ends the immich session only; the Authentik
# the config schema rejects a relative path — and mirrors immich's # SSO session survives, so the next login skips the credential prompt —
# internal LOGIN_URL, including autoLaunch=0. # drop this line to end both.
#
# Note this ends the IMMICH session only; the Authentik SSO session
# survives, so the next "Login with Authentik" click signs straight
# back in without a credential prompt. To end both, drop this line and
# let the IdP endpoint take over again.
endSessionEndpoint = "https://auth.mgaction.town/application/o/immich/end-session?post_logout_redirect_url=https://immich.mgaction.town"; endSessionEndpoint = "https://auth.mgaction.town/application/o/immich/end-session?post_logout_redirect_url=https://immich.mgaction.town";
# The mobile app can't follow a browser redirect back to a custom # The mobile app can't follow a browser redirect back to a custom
# scheme through Authentik, so immich bounces it via this endpoint. # scheme through Authentik, so immich bounces it via this endpoint.
@@ -101,28 +80,24 @@ in
mobileRedirectUri = "https://immich.mgaction.town/api/oauth/mobile-redirect"; mobileRedirectUri = "https://immich.mgaction.town/api/oauth/mobile-redirect";
}; };
}; };
# Hardware transcoding would need the iGPU passed in explicitly, e.g. # Hardware transcoding needs accelerationDevices set explicitly (e.g.
# accelerationDevices = [ "/dev/dri/renderD128" ]; the default [ ] means # "/dev/dri/renderD128"); default CPU-only transcode is slow on the
# PrivateDevices=yes and CPU-only transcode. The ZimaBlade's Celeron does # ZimaBlade's Celeron but only runs on upload.
# this slowly but it only runs on upload.
}; };
# /mnt/data/AppData is drwx--x--- darman:users immich needs group "users" # /mnt/data/AppData is drwx--x--- darman:users; immich only needs group "users"
# just to TRAVERSE into its own media dir. The dir itself stays 0700 # to traverse into it — the dir itself stays 0700 immich:immich (tmpfiles +
# immich:immich (the module's tmpfiles rule re-asserts that every rebuild, # UMask=0077 reassert that), so this grants nothing else.
# and UMask=0077 keeps new files private), so this grants nothing else.
users.users.immich.extraGroups = [ "users" ]; users.users.immich.extraGroups = [ "users" ];
# mediaLocation is outside /var/lib, so the module won't create it — its own # mediaLocation is outside /var/lib, so the module won't create it — this rule
# tmpfiles entry only ADJUSTS an existing dir. Harmless no-op after the # only adjusts perms on the dir the legacy import already created.
# legacy import, which puts the real store here.
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt/data/AppData/immich 0700 immich immich -" "d /mnt/data/AppData/immich 0700 immich immich -"
]; ];
# The unit's automatic RequiresMountsFor covers /run/immich and /var/lib/immich # The unit's automatic RequiresMountsFor doesn't cover mediaLocation — without
# only — nothing points it at mediaLocation. Without this immich starts with # this, immich starts before /mnt/data mounts and writes uploads onto the 29G
# the array missing and writes uploaded photos onto the 29G eMMC, into a # eMMC, invisibly, under the future mountpoint.
# directory that becomes invisible the moment /mnt/data mounts over it.
systemd.services.immich-server.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.immich-server.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
} }
+10 -13
View File
@@ -6,20 +6,17 @@
dataDir = "/mnt/data/AppData/jellyfin"; dataDir = "/mnt/data/AppData/jellyfin";
cacheDir = "/mnt/data/AppData/jellyfin/cache"; cacheDir = "/mnt/data/AppData/jellyfin/cache";
}; };
# "users" so the shared library stays readable (see the UMask note below); # "users" keeps the shared library readable (see the UMask note below);
# "video"/"render" for the DRI nodes used by hardware transcoding. renderD128 # "video"/"render" cover the DRI nodes for hardware transcoding — card1 is
# happens to be 0666 so VAAPI alone would work without this, but card1 is # 0660 root:video (not guaranteed 0666 like renderD128), so don't rely on
# 0660 root:video — and neither mode is guaranteed, so don't rely on it. The # device perms alone. Harmless on a GPU-less host: the driver itself is
# groups are harmless on a host with no GPU: they exist regardless, and this # enabled per-host (e.g. jupiter's hardware.graphics + intel-media-driver).
# module stays host-agnostic (the DRIVER is enabled per-host, e.g. jupiter's
# hardware.graphics + intel-media-driver).
users.users.jellyfin.extraGroups = [ "users" "video" "render" ]; users.users.jellyfin.extraGroups = [ "users" "video" "render" ];
# The upstream module hardcodes UMask=0077 — root cause of jellyfin writing # The upstream module hardcodes UMask=0077, which made jellyfin write
# trickplay thumbnails into stray new show folders it invented itself, # trickplay thumbnails into new folders owned jellyfin:jellyfin 700 —
# owned jellyfin:jellyfin 700, invisible to every other service sharing # invisible to every other service sharing the library (cinephage,
# the library (cinephage, mediamanager, ...). New files/dirs it creates # mediamanager). Forcing 0002 makes new files inherit group "users"
# from here on inherit group "users" (library roots are setgid, see the # (library roots are setgid via a one-time chmod g+s) and stay group-writable.
# one-time chmod g+s done by hand) and stay group-writable.
systemd.services.jellyfin.serviceConfig.UMask = lib.mkForce "0002"; systemd.services.jellyfin.serviceConfig.UMask = lib.mkForce "0002";
} }
+5 -10
View File
@@ -15,21 +15,16 @@
{ {
services.prowlarr.enable = true; services.prowlarr.enable = true;
# `nofail` is NOT optional here: without it this bind is RequiredBy # `nofail` is not optional: without it this bind is RequiredBy local-fs.target,
# local-fs.target, so an unassembled RAID array fails that target and drops # so an unassembled array drops jupiter into emergency mode — a dead end on a
# jupiter into emergency mode — which is a dead end, since root is locked and # headless box with root locked. Let this bind fail alone instead.
# sulogin has nothing to offer on a headless box. It defeats the `nofail` on
# /mnt/data itself (a mount layered on the array is what actually took the
# target down). Let this bind fail alone instead.
fileSystems."/var/lib/private/prowlarr" = { fileSystems."/var/lib/private/prowlarr" = {
device = "/mnt/data/AppData/prowlarr/config"; device = "/mnt/data/AppData/prowlarr/config";
fsType = "none"; fsType = "none";
options = [ "bind" "nofail" ]; options = [ "bind" "nofail" ];
}; };
# systemd derives RequiresMountsFor from the unit's own paths, which here is # systemd derives RequiresMountsFor only from /var/lib/prowlarr (eMMC) — pin
# only /var/lib/prowlarr on the eMMC — so without this prowlarr starts happily # it to the array too, or prowlarr starts happily and writes state to the OS disk.
# with the array absent and writes its state onto the 29G OS disk. Pin it to
# the array so it fails loudly instead.
systemd.services.prowlarr.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.prowlarr.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
} }
+5 -6
View File
@@ -1,11 +1,10 @@
{ ... }: { ... }:
# Radarr — movie library manager, feeds off SABnzbd/Prowlarr. dataDir points # Radarr — movie library manager, feeds off SABnzbd/Prowlarr; dataDir points
# at the config migrated from the old ZimaOS docker stack (indexers/download # at config migrated from the old ZimaOS docker stack. Unlike prowlarr, this
# client/history already set up). Unlike prowlarr, this module uses a static # module uses a static `radarr` user (no DynamicUser) and only auto-chowns
# `radarr` user (no DynamicUser) and only auto-chowns dataDir when it's the # dataDir at its own default path, so the migrated dir needs a manual
# module's own default path — since we point at a pre-existing migrated dir, # one-time chown after first deploy:
# chown it by hand once after first deploy:
# chown -R radarr:radarr /mnt/data/AppData/radarr/config # chown -R radarr:radarr /mnt/data/AppData/radarr/config
{ {
services.radarr = { services.radarr = {
+13 -20
View File
@@ -1,18 +1,13 @@
{ config, ... }: { config, ... }:
# SABnzbd — usenet downloader. Migrated off a reused hand-authored ini # SABnzbd — usenet downloader, migrated off a hand-authored ini (imported from
# (servers/API key/history originally imported from the old ZimaOS docker # the old ZimaOS docker stack) onto NixOS-managed `settings`. Only values that
# stack) onto NixOS-managed `settings`, per the module's own deprecation # differ from SABnzbd's own defaults are declared here.
# notice for `configFile`. Only the values that differ from SABnzbd's own
# built-in defaults are declared here — everything else falls back to the
# same defaults SABnzbd was already using.
# #
# `admin_dir`/`log_dir` MUST stay absolute: the module writes the merged ini # `admin_dir`/`log_dir` must stay absolute: the module writes the merged ini to
# to /var/lib/sabnzbd/sabnzbd.ini (eMMC), and both dirs are otherwise # /var/lib/sabnzbd/sabnzbd.ini (eMMC), so a relative default would resolve
# relative to wherever the ini lives. Pointing them back at the ORIGINAL # there instead of the original /mnt/data location — silently "resetting"
# /mnt/data location keeps the existing download queue/history database # SABnzbd to an empty queue/history on first switch, without deleting anything.
# (admin_dir) intact — a relative default here would silently "reset"
# SABnzbd to an empty queue on first switch, even though nothing was deleted.
{ {
services.sabnzbd = { services.sabnzbd = {
enable = true; enable = true;
@@ -73,17 +68,15 @@
# Write access to the shared downloads dir (owned darman:users on disk). # Write access to the shared downloads dir (owned darman:users on disk).
users.users.sabnzbd.extraGroups = [ "users" ]; users.users.sabnzbd.extraGroups = [ "users" ];
# download/complete/admin dirs all live on the array, but systemd only # download/complete/admin dirs live on the array, but systemd only derives
# derives RequiresMountsFor from /var/lib/sabnzbd (eMMC) — so with the array # RequiresMountsFor from /var/lib/sabnzbd (eMMC) — without this, a missing
# absent sabnzbd would start and download onto the 29G OS disk. # array lets sabnzbd start and download onto the 29G OS disk instead.
systemd.services.sabnzbd.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.sabnzbd.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
systemd.services.fix-downloads-perms.unitConfig.RequiresMountsFor = [ "/mnt/data" ]; systemd.services.fix-downloads-perms.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
# SABnzbd hardcodes completed job folders to 0700 on every job, ignoring # SABnzbd hardcodes completed job folders to 0700, ignoring the ini's `umask`
# the ini's `umask` (that only covers files during unpack, not the job # (unpack-only) — setgid keeps the group but perm bits still zero out and
# dir itself). setgid on Downloads keeps the group as "users" but perm # lock out cinephage/mediamanager, so sweep it clean on a timer instead.
# bits still come back zeroed, locking out cinephage/mediamanager — sweep
# it clean instead of fighting SABnzbd.
systemd.services.fix-downloads-perms = { systemd.services.fix-downloads-perms = {
description = "Fix group perms SABnzbd resets on completed downloads"; description = "Fix group perms SABnzbd resets on completed downloads";
serviceConfig.Type = "oneshot"; serviceConfig.Type = "oneshot";
+5 -8
View File
@@ -1,13 +1,10 @@
{ ... }: { ... }:
# Seerr (formerly Jellyseerr) — request manager for Jellyfin, talks to # Seerr (formerly Jellyseerr) — request manager for Jellyfin, talking to
# Sonarr/Radarr to fulfill requests. Fresh install, no migrated data. # Sonarr/Radarr; fresh install, no migrated data. configDir stays at the
# # module default, with AppData bind-mounted onto it instead (same
# configDir stays at the module default; bind-mount AppData onto it instead # DynamicUser/StateDirectory issue as prowlarr.nix — see that file for why,
# of overriding configDir, so data lives on the RAID array and survives an # and why the mount targets /var/lib/private/seerr rather than the public path).
# OS-disk reinstall (same DynamicUser/StateDirectory issue as prowlarr.nix —
# see that file for why, and why the mount targets /var/lib/private/seerr
# rather than the public path).
{ {
services.seerr.enable = true; services.seerr.enable = true;
+5 -6
View File
@@ -1,11 +1,10 @@
{ ... }: { ... }:
# Sonarr — TV library manager, feeds off SABnzbd/Prowlarr. dataDir points at # Sonarr — TV library manager, feeds off SABnzbd/Prowlarr; dataDir points at
# the config migrated from the old ZimaOS docker stack (indexers/download # config migrated from the old ZimaOS docker stack. Unlike prowlarr, this
# client/history already set up). Unlike prowlarr, this module uses a static # module uses a static `sonarr` user (no DynamicUser) and only auto-chowns
# `sonarr` user (no DynamicUser) and only auto-chowns dataDir when it's the # dataDir at its own default path, so the migrated dir needs a manual
# module's own default path — since we point at a pre-existing migrated dir, # one-time chown after first deploy:
# chown it by hand once after first deploy:
# chown -R sonarr:sonarr /mnt/data/AppData/sonarr/config # chown -R sonarr:sonarr /mnt/data/AppData/sonarr/config
{ {
services.sonarr = { services.sonarr = {
+17 -31
View File
@@ -15,10 +15,9 @@
prometheusConfig = { prometheusConfig = {
global.scrape_interval = "5s"; global.scrape_interval = "5s";
# Explicit, and equal to the interval on purpose. The Prometheus default # Explicit and equal to the interval on purpose: VictoriaMetrics silently
# is 10s, and VictoriaMetrics silently clamps scrape_timeout down to # clamps scrape_timeout down to scrape_interval, so leaving the Prometheus
# scrape_interval rather than erroring — so leaving it implicit means the # default (10s) here would misstate what actually happens.
# config says 10s while the scraper uses 5s. Say what actually happens.
global.scrape_timeout = "5s"; global.scrape_timeout = "5s";
scrape_configs = [ scrape_configs = [
@@ -44,14 +43,10 @@
]; ];
} }
# mercury is a Pi scraped over the tailnet, so it gets its own job at a # mercury is a Pi scraped over the tailnet, so it gets its own job at a
# slower cadence: at the 5s global it would time out (see above) and # slower cadence to avoid timing out at the 5s global. A separate cadence
# the series would show gaps rather than late samples. # requires a separate job (scrape_interval is per-job), so mercury's
# # `job` label differs from every other host's — select on `host` in
# A separate cadence REQUIRES a separate job — scrape_interval is a # dashboards/alerts, not job="node-exporter", or mercury drops out silently.
# per-job setting and job_name has to be unique — which means mercury's
# `job` label differs from every other host's. Select on `host` (set on
# every target below) rather than job="node-exporter" in dashboards and
# alerts, or mercury drops out of them silently.
{ {
job_name = "node-exporter-mercury"; job_name = "node-exporter-mercury";
scrape_interval = "15s"; scrape_interval = "15s";
@@ -81,31 +76,22 @@
# another host or the tailnet is temporarily unavailable. # another host or the tailnet is temporarily unavailable.
systemd.services.victoriametrics.after = [ "tailscaled-autoconnect.service" ]; systemd.services.victoriametrics.after = [ "tailscaled-autoconnect.service" ];
# Keep the TSDB off jupiter's 29G eMMC. The module hardcodes # Keep the TSDB off jupiter's 29G eMMC: the module hardcodes
# -storageDataPath=/var/lib/<stateDir> and runs DynamicUser, so without this # -storageDataPath=/var/lib/<stateDir> under DynamicUser, so without this bind
# the data lands on the OS disk — a continuous small-write workload aimed at # a continuous small-write workload lands on the one disk with no headroom.
# the one disk here with no headroom and finite write endurance. Same # Same /var/lib/private bind pattern as prowlarr.nix and seerr.nix — see
# bind-onto-/var/lib/private pattern as prowlarr.nix and seerr.nix; see # prowlarr.nix for why it targets the private path, and why `nofail` here is
# prowlarr.nix for why the mount targets the private path and not the public # not optional.
# /var/lib/victoriametrics.
#
# `nofail` is NOT optional — again see prowlarr.nix: without it this bind is
# RequiredBy local-fs.target, so an unassembled array drops jupiter into an
# emergency shell that a headless box cannot be rescued from.
fileSystems."/var/lib/private/victoriametrics" = { fileSystems."/var/lib/private/victoriametrics" = {
device = "/mnt/data/AppData/victoriametrics"; device = "/mnt/data/AppData/victoriametrics";
fsType = "none"; fsType = "none";
options = [ "bind" "nofail" ]; options = [ "bind" "nofail" ];
}; };
# The bind above needs its SOURCE to exist or the mount fails — and because # The bind above needs its source dir to exist or it quietly fails (`nofail`)
# it is `nofail` that failure is quiet: RequiresMountsFor below is satisfied # and VictoriaMetrics falls through to writing the eMMC anyway — this is a
# by /mnt/data itself, so VictoriaMetrics would start regardless and write to # fresh service so, unlike prowlarr.nix's pre-existing dir, it must create its
# the eMMC, which is the exact thing the bind exists to prevent. prowlarr.nix # own (same as seerr.nix). 0755 darman:users matches the other AppData dirs.
# gets away without this only because its directory predates the module
# (migrated from ZimaOS). This is a fresh service, so it creates its own,
# same as seerr.nix. 0755 darman:users matches the other AppData dirs, which
# matters because /mnt/data/AppData itself is drwx--x--- darman:users.
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt/data/AppData/victoriametrics 0755 darman users -" "d /mnt/data/AppData/victoriametrics 0755 darman users -"
]; ];
+5 -11
View File
@@ -53,17 +53,11 @@ in
}; };
}; };
# Bind-mount source must exist (podman won't create it), and it must be # Bind-mount source must exist (podman won't create it) and be owned by 1000,
# owned by 1000 — the `pihole` user FTL drops to after the entrypoint's root # the `pihole` user FTL drops to (rootful podman, no userns remapping, so the
# phase. Podman here is rootful with no userns remapping, so that number is # uid is the same inside and out). Must be the whole DIRECTORY, not just
# the same inside and out (on the host it collides with darman, harmlessly). # gravity.db — sqlite needs to create a sibling gravity.db-journal per write,
# # and a root-owned dir makes that fail with a misleading "readonly database".
# Ownership of gravity.db alone is not enough: sqlite creates a sibling
# gravity.db-journal for every write transaction, so FTL needs to CREATE
# files in this directory. Root-owned, it fails with
# open(/etc/pihole/gravity.db-journal) - (14)
# attempt to write a readonly database
# which reads like a corrupt or read-only database and is neither.
systemd.tmpfiles.rules = [ "d /var/lib/pihole 0750 1000 1000 -" ]; systemd.tmpfiles.rules = [ "d /var/lib/pihole 0750 1000 1000 -" ];
# Seed the adlists above into gravity. `INSERT OR IGNORE` keyed on the URL # Seed the adlists above into gravity. `INSERT OR IGNORE` keyed on the URL
+5 -6
View File
@@ -22,14 +22,13 @@
}; };
}; };
# Samba keeps its own NTLM password DB, separate from the system password; # Samba keeps its own NTLM password DB, separate from the system password
# `services.samba` never sets it, so logins fail until provisioned. Runs # `services.samba` never sets it, and this runs as a service (not an
# AFTER samba-smbd so its state dir exists — an activation script runs too # activation script, which fires too early for smbpasswd's passdb) after
# early and smbpasswd fails to init the passdb. Reads a single-line # samba-smbd. Reads a single-line password from the first existing file,
# password from the first file that exists: # feeding it twice since smbpasswd prompts new+confirm:
# Real host: /run/secrets/samba_password (sops-nix, see secrets.nix) # Real host: /run/secrets/samba_password (sops-nix, see secrets.nix)
# VM test: /etc/samba/smb-password (plaintext, see vm.nix) # VM test: /etc/samba/smb-password (plaintext, see vm.nix)
# smbpasswd prompts new + confirm, so the value is fed twice.
systemd.services.samba-smbpasswd = { systemd.services.samba-smbpasswd = {
description = "Provision Samba password for darman"; description = "Provision Samba password for darman";
after = [ "samba-smbd.service" ]; after = [ "samba-smbd.service" ];
+3 -3
View File
@@ -1,8 +1,8 @@
{ ... }: { ... }:
# Local recursive DNS resolver (privacy + DNSSEC). Your adblock DNS # Local recursive DNS resolver (privacy + DNSSEC) that the adblock DNS
# (pihole/AdGuard) forwards to this instead of a public upstream. # (pihole/AdGuard) forwards to instead of a public upstream — listens on
# Listens on 127.0.0.1:5335 point the adblock engine's upstream there: # 127.0.0.1:5335, so point the adblock engine's upstream there:
# AdGuard: dns.upstream_dns = [ "127.0.0.1:5335" ]; # AdGuard: dns.upstream_dns = [ "127.0.0.1:5335" ];
# pihole: upstream = "127.0.0.1#5335"; # pihole: upstream = "127.0.0.1#5335";
{ {
+12 -20
View File
@@ -1,25 +1,20 @@
{ config, ... }: { config, ... }:
# Headplane — web UI for headscale (services/vpn/headscale.nix; must be enabled # Headplane — web UI for headscale (services/vpn/headscale.nix; enable first),
# first), running as headscale's own OS user. # running as headscale's OS user.
# #
# It reads headscale's config from the nix store, which is read-only — so the # It reads headscale's config from the nix store, so the UI DISPLAYS settings
# UI DISPLAYS the settings but can't change them. That's the intended shape # but can't change them (edit here and rebuild instead) — except DNS
# for a declaratively-configured box (config_strict already defaults off # extra-records, which are data rather than config, hence the writable
# upstream for exactly this reason); edit them here and rebuild instead. # extra_records file below.
# DNS extra-records are the one thing worth making editable, since they're
# data rather than config — hence the writable extra_records file below,
# which also spares headplane from restarting headscale on every change.
# #
# Served at vpn.mgaction.town/admin (path-routed alongside headscale itself, # Served at vpn.mgaction.town/admin (path-routed with headscale, see
# see hosts/neptun/configuration.nix). base_url is the site root WITHOUT the # hosts/neptun/configuration.nix); base_url excludes the /admin prefix, which
# /admin prefix — Headplane appends that itself, including for the OIDC # Headplane appends itself including for the OIDC callback.
# callback.
# #
# Auth is Authentik (services/identity/authentik.nix) via OIDC. client_id, # Auth is Authentik via OIDC; client_id/client_secret/API key are placeholders
# client_secret, and the headscale API key can't be known until # until Authentik/headscale are deployed (direct API-key login works as a
# Authentik/headscale are actually deployed, so they're placeholders below; # fallback until then). Once live:
# direct API-key login still works as a fallback until then. Once live:
# 1. In Authentik: create an OAuth2/OpenID Provider + Application with slug # 1. In Authentik: create an OAuth2/OpenID Provider + Application with slug
# `headplane` and redirect URI # `headplane` and redirect URI
# https://vpn.mgaction.town/admin/oidc/callback. Copy the generated # https://vpn.mgaction.town/admin/oidc/callback. Copy the generated
@@ -28,9 +23,6 @@
# headplane_oidc_client_secret with the provider's client secret. # headplane_oidc_client_secret with the provider's client secret.
# 3. `headscale apikeys create` on the box, and replace # 3. `headscale apikeys create` on the box, and replace
# headplane_headscale_api_key the same way. # headplane_headscale_api_key the same way.
#
# NOTE: Authentik issues per-application, so the issuer carries the app slug —
# it is NOT the bare host the way Zitadel's was.
{ {
# Writable DNS extra-records, shared by both services (they run as the same # Writable DNS extra-records, shared by both services (they run as the same
# user). tmpfiles seeds an empty JSON array — headscale won't start against # user). tmpfiles seeds an empty JSON array — headscale won't start against
+39 -77
View File
@@ -1,14 +1,10 @@
{ config, ... }: { config, ... }:
# Headscale — self-hosted control server for the tailnet. Every host's # Headscale — self-hosted control server for the tailnet; every host's
# services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town # services/vpn/tailscale.nix points --login-server at https://vpn.mgaction.town.
# (this host). MagicDNS base_domain "orbit.sol" matches the # TLS terminates at Caddy; headscale itself only listens on localhost. Changing
# "jupiter.orbit.sol" names used in this repo's Caddy vhosts # base_domain below also means updating this repo's Caddy vhosts and neptun's
# (hosts/neptun/configuration.nix) — changing base_domain means changing # dnsmasq stub, which assume "orbit.sol".
# those too, and re-pointing neptun's dnsmasq stub at the new suffix.
#
# TLS terminates at Caddy (see the host's configuration.nix); headscale
# itself only listens on localhost.
{ {
services.headscale = { services.headscale = {
enable = true; enable = true;
@@ -18,89 +14,55 @@
server_url = "https://vpn.mgaction.town"; server_url = "https://vpn.mgaction.town";
dns = { dns = {
# Deliberately OUTSIDE mgaction.town. That zone has a wildcard A+AAAA # Deliberately outside mgaction.town: that zone has a wildcard A+AAAA at
# pointing at neptun, and DNS wildcards match multi-label names — so # neptun, so a name under it would resolve publicly to neptun and Caddy
# with base_domain = hosts.mgaction.town, `jupiter.hosts.mgaction.town` # would proxy to itself. Nested under `.sol` (pihole's LAN domain) so
# resolved publicly to NEPTUN and Caddy proxied to itself: a silent # jupiter.sol (LAN) and jupiter.orbit.sol (tailnet) resolve unambiguously
# loop rather than a lookup failure. # — tailscale matches by longest suffix. Never name a LAN host `orbit`:
# # pihole's `address=/<host>.sol/<ip>` would swallow this whole zone.
# `.sol` is the LAN domain pihole serves, so this nests the tailnet
# inside it: planets sit on the LAN as jupiter.sol, and reach each
# other in orbit as jupiter.orbit.sol. Resolution is unambiguous
# because tailscale matches routes by LONGEST suffix, so orbit.sol
# goes to MagicDNS even when everything else funnels to pihole.
#
# Never give a LAN host the name `orbit`: pihole's
# `address=/<host>.sol/<ip>` lines match a name AND everything under
# it, so an `orbit` host would swallow this entire zone.
base_domain = "orbit.sol"; base_domain = "orbit.sol";
# pihole on mercury, over the tailnet so every roaming device gets # pihole on mercury, over the tailnet, so roaming devices get ad blocking
# ad blocking and .sol names wherever it is, not just on the LAN. # and .sol names everywhere. Deliberately no public fallback — tailscale
# Deliberately NO public fallback: tailscale treats the list as a set, # treats this as a set, so adding one would let queries slip past the
# so adding 9.9.9.9 here would let queries slip past the filter # filter whenever mercury is briefly slow, at the cost of mercury being a
# whenever mercury is briefly slow. Strict blocking, at the cost of # single point of failure for tailnet DNS.
# mercury being a single point of failure for tailnet DNS. # ⚠️ Hardcoded tailnet address — check `headscale nodes list` if it
# # changes (mercury re-enrolled) and DNS dies tailnet-wide.
# ⚠️ A hardcoded tailnet address, so it changes if mercury re-enrols
# — check `headscale nodes list` if DNS dies tailnet-wide.
nameservers.global = [ "100.64.0.7" ]; nameservers.global = [ "100.64.0.7" ];
# Must be set, and must be HERE rather than via the module's # Must be set here, not via the module's `dns.split` option — nixpkgs
# `dns.split` option. nixpkgs renders that option one level too high # renders that one level too high, but headscale (and headplane) read
# (a sibling of `nameservers:`), but headscale reads # dns.nameservers.split; the missing key crashes headplane's DNS page.
# dns.nameservers.split (hscontrol/types/config.go:722) and so does
# headplane. So the module's option is dead, and the missing key makes
# headplane's DNS page die with
# TypeError: Cannot convert undefined or null to object
# from Object.keys(config.dns.nameservers.split).
nameservers.split = { }; nameservers.split = { };
# Point every node's resolver at MagicDNS, which forwards on to the # Routes every node's resolver through MagicDNS to the global nameserver
# global nameserver above. That is the only way to get pihole onto a # above — the only way pihole reaches a roaming device (otherwise it
# roaming device: with this false, globalResolvers land in the # lands in netmap's FallbackResolvers and carrier DNS never consults it).
# netmap's FallbackResolvers (hscontrol/types/config.go:826-830) and a # Cost: all DNS now depends on mercury and the home connection; neptun
# phone with carrier DNS never consults them. # and mercury opt out individually with --accept-dns=false.
#
# The cost is that every node's DNS now depends on mercury and on the
# home connection, so mercury going down costs name resolution
# everywhere, not just `.sol`. neptun and mercury opt out of this
# individually with --accept-dns=false — see their configuration.nix.
override_local_dns = true; override_local_dns = true;
}; };
# Authentik as the login provider, so `tailscale up --login-server ...` # Authentik as the login provider (own application, slug `headscale`,
# sends you to a browser instead of needing a pre-auth key. This is a # separate from headplane's) so `tailscale up --login-server ...` opens a
# SEPARATE Authentik application from headplane's — its own provider, # browser instead of needing a pre-auth key; headless hosts still use those.
# slug `headscale`, redirect https://vpn.mgaction.town/oidc/callback # ⚠️ headscale does OIDC discovery at startup and a failure is fatal — it
# (headscale's own callback; headplane's is under /admin). # won't boot, taking the whole control plane with it. Never point `issuer`
# # at an application that doesn't exist yet; verify with
# ⚠️ headscale performs OIDC discovery at STARTUP and a failure is
# FATAL ("creating OIDC provider from issuer config: 404 Not Found") —
# it will not boot, taking the whole tailnet's control plane with it.
# Never point `issuer` at an application that doesn't exist yet; verify
# with:
# curl -s <issuer>.well-known/openid-configuration # curl -s <issuer>.well-known/openid-configuration
# # Users created here are matched by OIDC `sub`, so `headscale users
# Headless hosts still enrol with pre-auth keys. Note also that users # create`-made users never link to one (0.28 dropped map_legacy_users).
# created here are distinct from `headscale users create` ones: matching
# is by the OIDC `sub` claim against the user's providerId, and 0.28
# dropped map_legacy_users, so CLI-made users never gain one.
oidc = { oidc = {
issuer = "https://auth.mgaction.town/application/o/headscale/"; issuer = "https://auth.mgaction.town/application/o/headscale/";
client_id = "14vhRYaLiONHmI2YFIxbQEveJDLu5cCvzSkTb9oq"; client_id = "14vhRYaLiONHmI2YFIxbQEveJDLu5cCvzSkTb9oq";
client_secret_path = config.sops.secrets.headscale_oidc_client_secret.path; client_secret_path = config.sops.secrets.headscale_oidc_client_secret.path;
}; };
# Run our own DERP relay instead of pulling Tailscale's map. # Run our own DERP relay instead of pulling Tailscale's map: the default
# # fetches that map at startup and treats a failure as fatal, so a DNS blip
# With the default (urls = [controlplane.tailscale.com/derpmap/default], # or Tailscale outage would stop this control server from booting at all.
# auto_update_enabled = true) headscale fetches that map at startup and # The relay rides Caddy on :443 (hence flush_interval -1 on that vhost);
# treats failure as FATAL — so a DNS blip or a Tailscale outage stops the # only STUN needs its own UDP port.
# control server from booting at all. A self-hosted control plane that
# can't start without Tailscale's infrastructure rather misses the point.
#
# The relay itself rides Caddy on :443 (hence the flush_interval -1 on
# that vhost); only STUN needs its own UDP port.
derp = { derp = {
urls = [ ]; urls = [ ];
auto_update_enabled = false; auto_update_enabled = false;
+9 -9
View File
@@ -1,9 +1,9 @@
{ config, ... }: { config, ... }:
# Tailscale node joined to the self-hosted headscale control server. # Tailscale node joined to the self-hosted headscale control server,
# Auto-registers on boot from a sops pre-auth key. Requires the importing host # auto-registering on boot from a sops pre-auth key importing hosts must
# to declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix). # declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix).
# Not for the VM (no sops). # Not used by the VM target (no sops there).
{ {
services.tailscale = { services.tailscale = {
enable = true; enable = true;
@@ -14,11 +14,11 @@
# Reach the host's services over the tailnet without opening LAN ports. # Reach the host's services over the tailnet without opening LAN ports.
networking.firewall.trustedInterfaces = [ "tailscale0" ]; networking.firewall.trustedInterfaces = [ "tailscale0" ];
# The upstream unit is a one-shot with no Restart, so a login attempt made # The upstream unit is a one-shot with no Restart, so a login attempted
# before the control server is reachable fails permanently until someone # before the control server is up fails permanently until restarted by
# starts it by hand. That's the norm on a first boot neptun hosts headscale # hand — the norm on first boot, since neptun hosts headscale itself and
# itself, and the other hosts race it. 30s spacing also keeps restarts clear # other hosts race it. 30s spacing keeps retries clear of systemd's default
# of systemd's default start limit (5 within 10s). # start limit (5 within 10s).
systemd.services.tailscaled-autoconnect.serviceConfig = { systemd.services.tailscaled-autoconnect.serviceConfig = {
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 30; RestartSec = 30;