Merge remote-tracking branch 'origin/master' into feat/mars-hermes-mnemosyne

# Conflicts:
#	README.md
#	hosts/jupiter/secrets.nix
#	services/dev/gitea-hermes-webhook-relay.nix
#	services/dev/gitea.nix
This commit is contained in:
2026-09-18 22:59:28 +00:00
138 changed files with 11652 additions and 2633 deletions
+39 -85
View File
@@ -14,6 +14,7 @@
../../services/network/caddy.nix
../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/monitoring/victoriametrics.nix
../../services/media/jellyfin.nix
../../services/media/sabnzbd.nix
../../services/media/prowlarr.nix
@@ -23,6 +24,7 @@
../../services/media/seerr.nix
../../services/media/immich.nix
../../services/dev/gitea.nix
../../services/dev/obsidian-livesync.nix
];
# sabnzbd's unrar dependency is unfree; scope the allowance to just that
@@ -38,16 +40,11 @@
# systemd-boot for UEFI. If ZimaBlade boots legacy/BIOS, switch to grub.
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
# common.nix's cap of 5 comes from this box's own 34-generation incident,
# but at ~5G free on a 29G eMMC even 5 is too many — override down to 2.
# common.nix's default of 5 is still too many boot entries for a 29G eMMC — override down to 2.
boot.loader.systemd-boot.configurationLimit = lib.mkForce 2;
# A `switch` pins the old generation as a GC root until the box reboots onto
# the new one (booted-system vs current-system) — common.nix's nix.gc is
# 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.
# A `switch` pins the old generation as a GC root until reboot; common.nix's weekly
# nix.gc is too slow for a 29G eMMC, so collect garbage on every boot instead.
systemd.services.gc-on-boot = {
description = "Full nix-collect-garbage on every boot";
wantedBy = [ "multi-user.target" ];
@@ -68,46 +65,24 @@
boot.kernelParams = [ "reboot=pci" ];
# ---- GPU (jellyfin hardware transcoding) ----
# Apollo Lake N3450 / HD Graphics 500 (Gen9, pci 8086:5A85). The i915 KERNEL
# driver binds on its own — /dev/dri/{card1,renderD128} exist without this —
# but the libva USERSPACE driver only ships when hardware.graphics is on, and
# 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.
# Apollo Lake N3450 / HD Graphics 500 (Gen9). i915 binds on its own, but VAAPI needs
# the iHD userspace driver (Gen9; i965 is Gen8-only) or jellyfin-ffmpeg exits 251 on
# every transcode with no clearer error than "FFmpeg exited with code 251" in the log.
hardware.graphics = {
enable = true;
extraPackages = [ pkgs.intel-media-driver ];
};
# ⚠️ This buys VAAPI only — jellyfin must be set to VAAPI, NOT QSV, in its
# web UI (Dashboard -> Playback -> Transcoding). QSV needs an MFX runtime on
# top of the libva driver: ffmpeg's `-init_hw_device qsv=qs@va` dies with
# "Error creating a MFX session: -9" -> exit 171, the SECOND failure hiding
# 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.
# ⚠️ Use VAAPI, not QSV, in jellyfin's UI — QSV needs an MFX runtime not safely
# available for this Gen9 chip (only insecure/EOL options) and fails with exit 171.
# 4K HDR remuxes also can't be tone-mapped here (needs OpenCL or Gen11+); keep those
# as 1080p SDR or let them direct-play.
# ---- NAS data array ----
# Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T).
# Mounted, NOT formatted; kept out of disko so it is never wiped.
# ⚠️ RAID0 = no redundancy: either 16TB disk failing loses ALL data.
boot.swraid.enable = true; # assemble the mdadm array at boot
# Silences "mdmon service will crash" eval warning. RAID0 here uses native
# superblocks so mdmon (external-metadata arrays only) never actually runs,
# but the module warns unconditionally without SOME MAILADDR/PROGRAM set.
# Existing ext4 on mdadm RAID0 (sda+sdb, md0, 29.1T) — mounted, not formatted, kept
# out of disko. ⚠️ RAID0 has no redundancy: either disk failing loses ALL data.
boot.swraid.enable = true;
# Silences the "mdmon service will crash" eval warning — mdmon never actually runs
# here (native superblocks, not external-metadata) but the module warns regardless.
boot.swraid.mdadmConf = "MAILADDR root";
fileSystems."/mnt/data" = {
# fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0.
@@ -116,69 +91,48 @@
options = [ "nofail" ]; # don't block boot if the array is degraded/absent
};
# `nofail` above is necessary but NOT sufficient — any mount layered on the
# array (prowlarr/seerr binds) is RequiredBy local-fs.target and will fail it
# regardless, and emergency mode on this box is a dead end: root is locked, so
# sulogin drops you at a prompt you cannot answer, with no ssh. 2026-08-06: a
# 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.
# `nofail` alone isn't enough — mounts layered on the array (prowlarr/seerr binds)
# are RequiredBy local-fs.target and can still trip Emergency Mode, which is a dead
# end here (root locked, no ssh). Boot as far as possible instead; the array-backed
# services carry RequiresMountsFor=/mnt/data so they still won't write to the eMMC.
systemd.enableEmergencyMode = false;
# ---- Heavy state moved off the eMMC ----
# A deploy holds TWO full closures (~9G each) on a 29G disk at once, so the
# OS disk has no room for state that grows on its own. 2026-08-09: it hit 0
# bytes free with both gen 39 and gen 40 resident, and postgres died on
# "No space left on device" — note ext4 reserves 5% for root, so non-root
# 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).
# A deploy holds two full closures (~9G each) on this 29G disk at once, so state
# that grows on its own can't live there — moved under /mnt/data/AppData like every
# other service's state. Settings below are jupiter-only; services/containers.nix
# stays engine/host-agnostic (mercury runs podman with no array).
# podman: CI images dominate and keep growing — the gitea runner's
# act-latest is 1.7G, and the act-22.04 label in services/dev/gitea.nix
# 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.
# runroot stays on /run (per-boot tmpfs, doesn't grow); graphroot moves to the array
# since the gitea runner's CI images alone run several GB.
virtualisation.containers.storage.settings.storage = {
driver = "overlay";
graphroot = "/mnt/data/AppData/containers/storage";
runroot = "/run/containers/storage";
};
# immich's postgres cluster. Version component mirrors the upstream default
# (`/var/lib/postgresql/${psqlSchema}`) so a major bump gets its own dir
# instead of silently reusing the old cluster's files.
# ⚠️ This puts the DB in the SAME failure domain as the photos it indexes:
# /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.
# immich's postgres cluster. Version-qualified path (matches upstream default) so a
# major bump gets a fresh dir instead of reusing the old cluster's files.
# ⚠️ Puts the DB in the same RAID0 failure domain as the photos it indexes —
# deliberate (the two are useless apart) but neither is backed up.
services.postgresql.dataDir =
"/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}";
# /mnt/data/AppData is drwx--x--- darman:users, so postgres needs group
# "users" just to TRAVERSE into its own dataDir — exactly the reason immich
# has the same line. The cluster dir itself keeps the mode it was initdb'd
# with (0750 postgres:postgres) — postgres only accepts 0700, or 0750 when
# the cluster was created with group access, and refuses to start otherwise.
# /mnt/data/AppData is drwx--x--- darman:users, so postgres needs the "users" group
# just to traverse into its dataDir (same reason immich needs it) — postgres itself
# refuses to start unless the cluster dir is 0700 or 0750.
users.users.postgres.extraGroups = [ "users" ];
# Neither path is under /var/lib, so no module creates it: the postgresql
# module's own tmpfiles entry only adjusts a dataDir that already exists,
# the same way immich's mediaLocation rule does.
# Neither path is under /var/lib, so no module creates it automatically — same
# reason immich needs its own mediaLocation tmpfiles rule.
systemd.tmpfiles.rules = [
"d /mnt/data/AppData/postgresql 0750 postgres postgres -"
"d /mnt/data/AppData/containers 0700 root root -"
];
# graphroot is not a systemd path dependency the way dataDir is, so nothing
# derives a mount ordering from it. Without these, podman would recreate an
# empty store on the eMMC under the mountpoint when the array is late or
# absent, and the runner would re-pull every image into it.
# (podman-clonarr already carries this from services/media/clonarr.nix.)
# Without this, podman would recreate an empty store on the eMMC if the array mounts
# late or is absent, and the runner would re-pull every image.
# (podman-clonarr already sets this in services/media/clonarr.nix.)
systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
+28 -19
View File
@@ -1,14 +1,8 @@
{ config, ... }:
# sops-nix secret wiring (real host only; not imported by vm.nix).
# Encrypted values live in ../../secrets/jupiter.yaml, decrypted at activation to
# /run/secrets/<name>.
#
# 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-nix secret wiring (real host only; not imported by vm.nix). Decrypts with the
# host's own SSH host key (ssh-to-age), shipped once at install via nixos-anywhere
# --extra-files, so there's no separate sops-only key to manage.
{
sops.defaultSopsFile = ../../secrets/jupiter.yaml;
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
@@ -26,18 +20,14 @@
# Headscale pre-auth key for tailscale auto-registration (see configuration.nix).
sops.secrets.tailscale_authkey = { };
# Immich's OIDC client secret, from its Authentik application (a SEPARATE
# app from headscale's and headplane's — see hosts/neptun/secrets.nix).
# Referenced as settings.oauth.clientSecret._secret in
# 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`.
# Immich's OIDC client secret (separate Authentik app from headscale/headplane, see
# hosts/neptun/secrets.nix). Resolved via systemd LoadCredential as root before
# privilege drop, so sops's default root:root 0400 is correct — do NOT set `owner`.
sops.secrets.immich_oauth_client_secret = { };
# Gitea Actions runner registration token (services/dev/gitea.nix). Gitea
# generates this itself once Actions is enabled — it is not a password
# chosen up front. Rendered into a `TOKEN=...` env file because
# gitea-actions-runner takes an EnvironmentFile, not a raw secret path.
# Gitea Actions runner registration token — gitea generates this itself once Actions
# is enabled. Rendered into an env file since gitea-actions-runner takes an
# EnvironmentFile, not a raw secret path.
sops.secrets.gitea_runner_token = { };
sops.templates."gitea-runner.env".content =
"TOKEN=${config.sops.placeholder.gitea_runner_token}";
@@ -48,6 +38,11 @@
# ci-bot access token to allow the ci-bot user to push to repos
sops.secrets.gitea_ci_bot_token.owner = "gitea";
# Add the same value to secrets/jupiter.yaml before deploying Jupiter.
sops.secrets.gitea_hermes_webhook_secret = {
owner = "gitea";
};
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) —
# migrated off the reused ini in services/media/sabnzbd.nix into
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this
@@ -64,4 +59,18 @@
sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd";
sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd";
# CouchDB admin account for Obsidian LiveSync — rendered into an [admins] ini
# fragment instead of services.couchdb.adminPass, which would put 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
# configured (every request 401s).
sops.secrets.couchdb_admin_password.owner = "couchdb";
sops.templates."couchdb-admins.ini" = {
owner = "couchdb";
content = ''
[admins]
obsidian = ${config.sops.placeholder.couchdb_admin_password}
'';
};
}