New on-site host mars runs Hermes Agent as its sole service: joins the
tailnet, mounts jupiter's samba share at /mnt/jupiter (doubling as
Hermes's shared dropbox), and hosts state locally under /var/lib/hermes.
Same Authentik OIDC app/Telegram bot as before, just relocated — neptun's
hermes.mgaction.town vhost now points at mars.orbit.sol instead of jupiter.
hosts/jupiter/hermes-agent.nix and its three sops secrets are removed;
jupiter's Caddy vhost for it is gone too. Also refreshes tailscale_authkey
across all hosts and fixes two stale "erik@laptop" keys in flake.nix's
kexec/installer-iso images (leftover from a previous laptop, already
swapped out of common.nix back in 2fd5752) to darman@terra.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011FHr5ug9pu8q4XPrRkFnzJ
208 lines
11 KiB
Nix
208 lines
11 KiB
Nix
{ config, pkgs, lib, ... }:
|
|
|
|
# ZimaBlade NAS host: hardware + disk + the services it runs.
|
|
{
|
|
imports = [
|
|
./hardware-configuration.nix
|
|
./disk-config.nix # disko: OS-disk partitions + filesystems
|
|
./secrets.nix # sops-nix: samba password, tailscale key, ...
|
|
../../common.nix # shared base: user / ssh / nix / firewall
|
|
../../services/network/samba.nix
|
|
../../services/network/avahi.nix
|
|
../../services/media/audiobookshelf.nix
|
|
../../services/containers.nix
|
|
../../services/network/caddy.nix
|
|
../../services/vpn/tailscale.nix
|
|
../../services/media/jellyfin.nix
|
|
../../services/media/sabnzbd.nix
|
|
../../services/media/prowlarr.nix
|
|
../../services/media/sonarr.nix
|
|
../../services/media/radarr.nix
|
|
../../services/media/clonarr.nix
|
|
../../services/media/seerr.nix
|
|
../../services/media/immich.nix
|
|
../../services/dev/gitea.nix
|
|
];
|
|
|
|
# sabnzbd's unrar dependency is unfree; scope the allowance to just that
|
|
# package rather than blanket-allowing unfree across the host.
|
|
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "unrar" ];
|
|
|
|
# ---- Host identity ----
|
|
networking.hostName = "jupiter";
|
|
networking.networkmanager.enable = true;
|
|
users.users.darman.extraGroups = [ "docker" ]; # merges with common.nix
|
|
|
|
# ---- Boot ----
|
|
# 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.
|
|
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.
|
|
systemd.services.gc-on-boot = {
|
|
description = "Full nix-collect-garbage on every boot";
|
|
wantedBy = [ "multi-user.target" ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${pkgs.nix}/bin/nix-collect-garbage -d";
|
|
};
|
|
};
|
|
|
|
# Root lives on the ZimaBlade eMMC (mmcblk0). nixos-generate-config runs in
|
|
# the RAM installer and does NOT detect these, so pin them here (merged with
|
|
# hardware-configuration.nix) or stage-1 can't mount root and the box panics.
|
|
boot.initrd.availableKernelModules = [ "mmc_block" "sdhci_pci" "sdhci_acpi" ];
|
|
|
|
# Warm reboot hangs at firmware reset on this board (cold power-cycle works).
|
|
# Force the PCI-chipset reset method. If a warm `reboot` still hangs, try the
|
|
# next value: acpi -> bios -> cold -> efi.
|
|
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.
|
|
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.
|
|
|
|
# ---- 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.
|
|
boot.swraid.mdadmConf = "MAILADDR root";
|
|
fileSystems."/mnt/data" = {
|
|
# fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0.
|
|
device = "/dev/disk/by-uuid/dadbff6f-652e-49b2-bfed-eb1308ab8b78";
|
|
fsType = "ext4";
|
|
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.
|
|
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).
|
|
|
|
# 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.)
|
|
systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
|
systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
|
|
|
|
# ---- Caddy vhosts (LAN) ----
|
|
# Reached via pihole local-DNS names -> jupiter IP.
|
|
services.caddy.virtualHosts = {
|
|
"http://audiobookshelf.jupiter.sol".extraConfig = "reverse_proxy localhost:8000";
|
|
"http://jellyfin.jupiter.sol".extraConfig = "reverse_proxy localhost:8096";
|
|
"http://sabnzbd.jupiter.sol".extraConfig = "reverse_proxy localhost:8085";
|
|
"http://prowlarr.jupiter.sol".extraConfig = "reverse_proxy localhost:9696";
|
|
"http://sonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:8989";
|
|
"http://radarr.jupiter.sol".extraConfig = "reverse_proxy localhost:7878";
|
|
"http://clonarr.jupiter.sol".extraConfig = "reverse_proxy localhost:6060";
|
|
"http://seerr.jupiter.sol".extraConfig = "reverse_proxy localhost:5055";
|
|
"http://gitea.jupiter.sol".extraConfig = "reverse_proxy localhost:3000";
|
|
# Immich uploads are large: raise the body limit off caddy's default and
|
|
# give slow phone uploads room before the proxy gives up.
|
|
"http://immich.jupiter.sol".extraConfig = ''
|
|
request_body {
|
|
max_size 50GB
|
|
}
|
|
reverse_proxy localhost:2283
|
|
'';
|
|
};
|
|
|
|
system.stateVersion = "26.05";
|
|
}
|