Files
nixos-config/configuration.nix
Harley 03ded14b9f gitea: ROOT_URL -> https://gitea.wingard.pro
Second step of the HTTPS front, deliberately its own generation: pointing
ROOT_URL at an HTTPS name that served nothing would have broken the UI's
absolute links. The vhost (40ae3e00) is proven — ACME issued over HTTP-01 and
https://gitea.wingard.pro/ serves 200 with a chain that validates against the
system trust store, so it is a real Let's Encrypt cert rather than self-signed.

Measured before flipping: Gitea already answers the registry Bearer realm on
https, because it derives the scheme from X-Forwarded-Proto. So Docker's auth
flow was already fully TLS and this flip is for the web UI's own links, so
browsing over https stops emitting http URLs.

DOMAIN stays 10.0.1.49: it only affects newly copied SSH clone URLs, and
existing remotes should not churn.
2026-09-27 16:04:14 -04:00

2569 lines
107 KiB
Nix

{ config, lib, pkgs, hermes-agent, nixpkgs-held, ... }:
{
############################################################
## Table of Contents ##
## 1) Nix Configs/Flakes ##
## 2) Boot Option ##
## 3) Filesystems ##
## 4) System Configuration ##
## 5) Networking ##
## |_5.1) Firewall ##
## |_5.2) Wireguard ##
## |_5.3) Pihole ##
## |_5.4) Nginx ##
## 6) Users and Sudo ##
## 7) General Packages ##
## 8) Services, Daemons, Scripts ##
## |_8.1) Docker & Containers ##
## |_8.2) Arrs suite ##
## 9) Customizations & variables ##
## |_9.1) Keyd keyboard settings ##
## |_9.2) Enviroment Variables ##
## ##
## ##
############################################################
############################################################
## ##
## 1: Inital Nix configuration ##
## ##
############################################################
imports = [ ./hardware-configuration.nix ];
## Flakes and nix-command
nix.settings = {
experimental-features = [ "nix-command" "flakes" ];
trusted-users = [ "harley" ];
# Bound build parallelism. Defaults (`max-jobs = auto`, `cores = 0`) resolve on
# this 32-thread host to 32 builders, each running a fully parallel `make` --
# up to ~1024 concurrent writers on the single dm-crypt+btrfs root device. That
# collapses the box: measured 2026-09-11, load 100, PSI 92% some / 88% full, 61
# tasks in D-state, systemd-journald killed 34x in 8h, and no build could finish.
# 6 x 6 = 36 threads (matches the CPU) but only SIX concurrent writers.
# Verified 2026-09-12: the same advance that collapsed on 09-11 built clean.
max-jobs = 6;
cores = 6;
};
## Allow unfree packages
nixpkgs.config.allowUnfree = true;
## Cap the journal. Left unset, systemd-journald defaults to 10% of the FILESYSTEM
## -- on this 468 GiB root that is up to ~46 GiB -- and it lives on the SAME
## dm-crypt+btrfs device as /nix/store, so it competes with store writes and was
## repeatedly watchdog-killed under build I/O.
## 2026-09-18: raised 1G -> 4G. Measured journal volume on this host is ~19.5 MB/h,
## so 1G held under two days of history and the journal sat pinned at its ceiling
## (986M, 37.9M free). Evidence from 2026-09-17: between 01:33 and 08:40 a runaway
## container (hexstrike, removed 08:39 the same day) logged ~150k msgs/h into the
## journal through docker's journald log driver -- 99.8% of that boot's docker
## volume, ~1.05M entries. In that window systemd SIGABRT'd journald twice for
## missing its 3-minute watchdog (02:03:21 and 02:10:49; a core dump each time, and
## the active system/user journal files were renamed as "corrupted or uncleanly shut
## down" on both restarts). logrotate and qbittorrent were blocked at the same time,
## waiting on the stalled journal.
## CAUSE NOT FULLY PINNED: the flood rate and the cap pressure were both present, and
## the cores yield no internal assertion -- SIGABRT was sent by PID 1 on watchdog
## expiry, so there is no stack to blame. The flood source is gone; this bump removes
## the other condition. 4G is ~11x under the unset default, buys ~8 days of history,
## and puts normal operation near 25% of the cap, so "at the ceiling" stops being a
## routine state. If journald ever watchdogs again with the flood absent, treat the
## 3-minute WatchdogSec as the suspect rather than the cap.
## NOTE: this nixpkgs renamed the option -- `extraConfig` now trips a dead-option
## assertion ("no longer has any effect"); the settings submodule is the current form.
services.journald.settings.Journal.SystemMaxUse = "4G";
## Stub loader for dynamically-linked generic binaries (ESP-IDF toolchains)
programs.nix-ld.enable = true;
## Extra shared libs for ESP-IDF tools (openocd: libusb/libftdi; USB-JTAG: hidapi)
programs.nix-ld.libraries = lib.mkAfter [ pkgs.libusb1 pkgs.libftdi1 pkgs.hidapi ];
## Overlay: stub broken python doc build (docutils bug in 26.11)
nixpkgs.overlays = [
(final: prev: {
python312 = prev.python312.overrideAttrs (old: {
passthru = old.passthru // { doc = null; };
});
})
## Overlay: add mnemosyne memory packages and inject into hermes-agent
(final: prev:
let
inherit (final) fetchurl;
hermesPinnedPkgs = import hermes-agent.inputs.nixpkgs {
system = prev.stdenv.hostPlatform.system;
config.allowUnfree = true;
};
py = (hermesPinnedPkgs.python312.override {
# inline-snapshot's build-time test suite is flaky on recent
# nixpkgs (3 fail / 1428 pass). It is only a test gate; the
# package builds and runs fine. Disable its test phase --
# the documented durable fix for this failure class.
packageOverrides = self: super: {
inline-snapshot = super.inline-snapshot.overridePythonAttrs (old: { doCheck = false; });
};
}).pkgs;
# Build mnemosyne-memory
mnemosyne-memory = py.buildPythonPackage rec {
pname = "mnemosyne-memory";
version = "3.14.0";
format = "pyproject";
src = fetchurl {
url = "https://files.pythonhosted.org/packages/c1/b1/a3b8a18828aadd4fc7e67fb262294ea0038dbf130c8aac23196e998542d7/mnemosyne_memory-3.14.0.tar.gz";
hash = "sha256-EQbl7GmsIkncre0bepSNj1zux5WaMXbMbvvdD6QSdus=";
};
propagatedBuildInputs = [ py.fastembed py.sqlite-vec ];
nativeBuildInputs = [ py.setuptools py.wheel ];
doCheck = false;
pythonImportsCheck = [ "mnemosyne" ];
meta.description = "Zero-dependency AI memory. SQLite-backed. Sub-millisecond.";
meta.homepage = "https://github.com/mnemosyne-oss/mnemosyne";
meta.license = lib.licenses.mit;
};
# Build mnemosyne-hermes
mnemosyne-hermes = py.buildPythonPackage rec {
pname = "mnemosyne-hermes";
version = "0.4.0";
format = "pyproject";
src = fetchurl {
url = "https://files.pythonhosted.org/packages/45/da/77d0f0cb636b896f1c23449541a0d6a9e3a5301a6dee7a2064082a4d8583/mnemosyne_hermes-0.4.0.tar.gz";
hash = "sha256-fkh+cNVXIJXOQDxf8ZQxQSBgp+9WyH/ERW2kmbBHTrg=";
};
propagatedBuildInputs = [ mnemosyne-memory ];
nativeBuildInputs = [ py.setuptools py.wheel ];
doCheck = false;
meta.description = "Mnemosyne memory provider for Hermes Agent";
meta.homepage = "https://github.com/mnemosyne-oss/mnemosyne";
meta.license = lib.licenses.mit;
};
# ONLY packages the sealed venv does not already ship. PYTHONPATH
# precedes site-packages, so injecting a package the venv provides puts
# a different version in front of it (measured here: 15 such collisions
# — numpy 2.5.2 vs 2.4.3, tokenizers 0.23.2 vs 0.22.2, huggingface-hub
# 1.30.0 vs 1.24.0, ...). Upstream's wrapper refuses such a set outright,
# which is why extraPythonPackages below stays empty. fastembed cannot be
# passed through it either: its closure always reaches huggingface-hub.
# This list is the venv-authoritative delta; the missing transitives
# (loguru, mmh3, py-rust-stemmers, pystemmer, coloredlogs, humanfriendly)
# are what silently kept vector search dead — recall fell back to FTS5
# and kept working, so nothing looked broken. Re-derive: vault
# notes/memory-hygiene.md. 2026-09-23.
mnemosynePyModules = [
mnemosyne-memory
mnemosyne-hermes
py.fastembed
py.sqlite-vec
py.loguru
py.mmh3
py.py-rust-stemmers
py.pystemmer
py.coloredlogs
py.humanfriendly
];
mnemosyneSitePkgs = lib.makeSearchPath py.python.sitePackages mnemosynePyModules;
hermesWithPath = prev.hermes-agent.override {
extraPythonPackages = [ ];
};
# sitecustomize shim: make ctypes.util.find_library() fall back to
# LD_LIBRARY_PATH. NixOS has no ld.so.cache, so ctypes-based libs
# (sounddevice -> libportaudio) can't be found via the default path.
hermes-ctypes-shim = final.stdenv.mkDerivation {
pname = "hermes-ctypes-shim";
version = "1.0.0";
dontUnpack = true;
dontBuild = true;
installPhase = ''
mkdir -p $out/site-packages
cat > $out/site-packages/sitecustomize.py <<'PYEOF'
import ctypes.util as _cu
import os as _os
_orig = _cu.find_library
def _find_library(name):
res = _orig(name)
if res is not None:
return res
# NixOS: no ld.so.cache; scan LD_LIBRARY_PATH ourselves
for d in _os.environ.get('LD_LIBRARY_PATH', "").split(':'):
if not d:
continue
for candidate in ('lib%s.so' % name, 'lib%s.so.2' % name):
p = _os.path.join(d, candidate)
if _os.path.exists(p):
return p
return None
_cu.find_library = _find_library
PYEOF
'';
};
# Wrap again to add mnemosyne and dependency site-packages to PYTHONPATH
hermes-wrapped = final.stdenv.mkDerivation {
pname = "hermes-agent-mnemosyne";
version = hermesWithPath.version;
nativeBuildInputs = [ final.makeWrapper ];
dontUnpack = true;
dontBuild = true;
installPhase = ''
mkdir -p $out/bin
for bin in hermes hermes-agent hermes-acp; do
if [ -f ${hermesWithPath}/bin/$bin ]; then
makeWrapper ${hermesWithPath}/bin/$bin $out/bin/$bin --prefix PYTHONPATH : "${hermes-ctypes-shim}/site-packages" --suffix PYTHONPATH : "${mnemosyneSitePkgs}" --suffix LD_LIBRARY_PATH : "${final.portaudio}/lib" --suffix LD_LIBRARY_PATH : "${final.gcc-unwrapped.lib}/lib"
fi
done
'';
meta = hermesWithPath.meta;
# Expose the sealed uv2nix venv (has run_agent.py + hermes_cli + all
# agent deps) so the WebUI service can run the agent in-process via
# `pkgs.hermes-agent.passthru.hermesVenv`.
passthru = {
inherit (hermesWithPath) hermesVenv;
};
};
in {
inherit mnemosyne-memory mnemosyne-hermes;
# Resolved site-packages string, consumed by environment.etc."hermes-desktop-env".
hermesMnemosynePyPath = mnemosyneSitePkgs;
hermes-agent = hermes-wrapped;
# home-assistant builds with python314Packages (not python3Packages), so
# the package must be injected into that set for the module's
# extraPackages callback to see it.
python314Packages = prev.python314Packages // {
zhaquirks = prev.python314Packages.buildPythonPackage rec {
pname = "zha-quirks";
version = "2.1.1";
format = "wheel";
src = prev.fetchurl {
url = "https://files.pythonhosted.org/packages/50/e3/caf8ad9ab5232f78e07298a03ef460e291809efabaca0ecd669a211f0278/zha_quirks-2.1.1-py3-none-any.whl";
hash = "sha256-TYxtKANd1VkT1+sEQnYJoTDTIAxb84L77Sq6dWp13QA=";
};
propagatedBuildInputs = [
prev.python314Packages.zha
prev.python314Packages.zigpy
];
doCheck = false;
pythonImportsCheck = [ "zhaquirks" ];
meta.description = "Library implementing Zigpy quirks for ZHA in Home Assistant";
meta.homepage = "https://github.com/zigpy/zha-device-handlers";
meta.license = lib.licenses.asl20;
};
};
}
)
## Overlay: arduino-ide AppImage wrapper missing libxkbfile in its FHS sandbox
## (keymapping.node dlopen fails -> Theia keyboard service dies -> stuck splash).
## Re-wrap with libxkbfile added to extraPkgs. Version must track nixpkgs.
(final: prev:
let
aiVersion = "2.3.7";
aiSrc = prev.fetchurl {
url = "https://github.com/arduino/arduino-ide/releases/download/${aiVersion}/arduino-ide_${aiVersion}_Linux_64bit.AppImage";
hash = "sha256-m4RYtjJMZ01M1qwKc70Gkey9QLQ4Gk59rwpunm4TY2g=";
};
aiContents = prev.appimageTools.extractType2 { pname = "arduino-ide"; version = aiVersion; src = aiSrc; };
in {
arduino-ide = prev.appimageTools.wrapType2 {
pname = "arduino-ide";
version = aiVersion;
src = aiSrc;
extraInstallCommands = ''
install -Dm444 ${aiContents}/arduino-ide.desktop -t $out/share/applications/
install -Dm444 ${aiContents}/arduino-ide.png -t $out/share/icons/hicolor/512x512/apps
substituteInPlace $out/share/applications/arduino-ide.desktop --replace-fail 'Exec=AppRun --no-sandbox %U' 'Exec=arduino-ide --use-angle=swiftshader %U'
'';
extraPkgs = pkgs: [ pkgs.libsecret pkgs.libxkbfile ];
meta = {
description = "Open-source electronics prototyping platform";
homepage = "https://www.arduino.cc/en/software";
license = prev.lib.licenses.agpl3Only;
mainProgram = "arduino-ide";
platforms = [ "x86_64-linux" ];
};
};
}
)
## Overlay: restore executable bit on VS Code's extension signature verifier
## Nix's VSIX unpacking leaves the native verifier mode 0444, causing
## marketplace installs to fail with "Signature verification failed: EACCES".
(final: prev:
{
vscode = prev.vscode.overrideAttrs (old: {
postFixup = (old.postFixup or "") + ''
verifier="$out/lib/vscode/resources/app/node_modules/@vscode/vsce-sign/bin/vsce-sign"
if [ -e "$verifier" ]; then
chmod +x "$verifier"
fi
'';
});
}
)
# Packages HELD at the pinned nixpkgs (the `nixpkgs-held` flake input), which is
# the revision the running system was built with.
#
# Why: this block pins upstream tag v2026.826.1804, and LizardByte DELETED that
# tag (tags now jump 2026.516 -> 2026.906). The git fetch therefore fails, and
# because Sunshine sits in the system closure via system-path, that one failure
# blocks EVERY rebuild with:
# Cannot build '...-source.drv': Unable to checkout refs/tags/v2026.826.1804
# Cannot build '...-system-path.drv': Reason: 1 dependency failed.
# The deleted source can't be recovered either: no fork carries the tag (55
# checked) and the repo uses submodules, so no archive can reproduce the hash.
#
# Building Sunshine against the same nixpkgs it was built with reproduces the
# derivation byte-for-byte, so Nix reuses the ALREADY-BUILT output: nothing is
# fetched, nothing is compiled, and the resulting store path is unchanged -- so
# the generated ExecStart is unchanged and a switch does not restart Sunshine.
# Frozen as-is on Harley's call, 2026-09-11.
#
# The 2026.826 pin itself exists because 2026.516 has the NV12/BGR0 capture-frame
# conversion bug ("Couldn't scale frame" / black screen on nvenc).
#
# To move off this some day: pick a tag that still exists
# (git ls-remote --tags https://github.com/LizardByte/Sunshine), set `version`
# and the fetchFromGitHub hash (nix prints the correct hash on mismatch), drop
# the nixpkgs-held indirection, and re-check ./memory-capture.patch -- it
# still applies to v2026.910.221003 (hunk #1 succeeds, 28 lines lower).
(final: prev:
let
heldPkgs = import nixpkgs-held {
inherit (prev.stdenv.hostPlatform) system;
config = prev.config;
};
in
{
# rpcs3: HELD at the previous nixpkgs. The new nixpkgs bumps it to
# 0.0.42-unstable-2026-08-15, which fails to build with a GCC internal
# compiler error (-> no binary-cache entry either), and rpcs3 sits in
# environment.systemPackages, so a failed build blocks system-path and the
# whole toplevel. Holding it reproduces the already-built 0.0.40 derivation,
# so the existing binary is reused and rpcs3 behaves exactly as it does today.
# Revisit when the upstream build is fixed: check whether a newer nixpkgs
# builds rpcs3, then drop this line and the nixpkgs-held indirection for it.
rpcs3 = heldPkgs.rpcs3;
sunshine = heldPkgs.sunshine.overrideAttrs (finalAttrs: old: {
version = "2026.826.1804";
src = heldPkgs.fetchFromGitHub {
owner = "LizardByte";
repo = "Sunshine";
tag = "v${finalAttrs.version}";
hash = "sha256-5wPahJawQaXme/33e6ANjV3gNN20P7q6HiS/Agkwi+M=";
fetchSubmodules = true;
};
# Force CUDA (nvenc) capture to use memory buffers (not DMA-BUF) --
# workaround for the upstream DMA-BUF+sws "Couldn't scale frame" bug.
patches = [ ./memory-capture.patch ];
buildInputs = old.buildInputs ++ [ heldPkgs.qt5.qtbase heldPkgs.qt5.qtsvg ];
nativeBuildInputs = old.nativeBuildInputs ++ [ heldPkgs.qt5.wrapQtAppsHook ];
ui = heldPkgs.buildNpmPackage {
inherit (finalAttrs) src version;
pname = "sunshine-ui";
npmDepsHash = "sha256-gtyogpsvdMU4JuYb4LBdDzESB8uxj8Tu2thh2zCac2s=";
installPhase = ''
runHook preInstall
mkdir -p "$out"
cp -a . "$out"/
runHook postInstall
'';
};
appendRunpaths = (old.appendRunpaths or [ ]) ++ [ "/run/opengl-driver/lib" ];
});
})
];
## Auto-upgrade
system.autoUpgrade.enable = false;
############################################################
## ##
## 2: Boot and Kernel ##
## ##
############################################################
## btrfs support in the kernel and systemd boot stuff
boot.loader.efi.canTouchEfiVariables = true;
boot.loader.systemd-boot.enable = true;
boot.initrd.supportedFilesystems = [ "btrfs"];
## Serial kernel-console parameters are intentionally disabled.
## Generations 257, 258, and 260 all failed in initrd after adding
## console=ttyS0; Generation 260 intentionally omitted earlycon but
## still failed cryptsetup and left /dev/mapper/nixroot unavailable.
## Keep the ESP32 bridge available only through a non-boot-critical path.
## SSH and remote LUKS unlocking in initramfs
boot.initrd.network.ssh.enable = true;
boot.initrd.network.ssh.hostKeys = [ ./secrets/initrd/ssh_host_ed25519_key ];
boot.initrd.network.ssh.authorizedKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH4OAJ8iC7CQIcedVbU86xgoL4YkMWAG9bU6aj9v4Skb harley@utumno"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOnvzomqQoTWI7H2GpnqZO8tR7CwwwfUsylGu9IHWpAe u0_a392@localhost"
];
## Initrd convenience: 'unlock' command
boot.initrd.systemd.contents."/root/unlock".text = ''
#!/bin/sh
exec cryptsetup luksOpen /dev/disk/by-uuid/7d3b9cf3-0444-4cc9-80ba-e1401b783b6c nixroot
'';
boot.initrd.systemd.contents."/root/.profile".text = ''
export PATH=$PATH:/root
'';
## DHCP for initrd network (systemd stage 1)
boot.initrd.systemd.network.enable = true;
boot.initrd.systemd.network.networks."10-eno1" = {
matchConfig.Name = "eno1";
networkConfig.DHCP = "ipv4";
};
###########################################################
## ##
## 3: File systems ##
## ##
###########################################################
## Root, 512-GB, SSD-SATA, btrfs
fileSystems."/".options = [ "compress=zstd" "noatime" "space_cache=v2" ];
## 20TB Storage drive, LUKS-encrypted btrfs on a Seagate HDD.
## Not a fileSystem entry: it is unlocked + mounted by unlock-20tb.service
## below (same pattern as the 8TB data drive). Mounted at /storage/20TB.
## Exos SMB share (served and administered entirely by Exos)
fileSystems."/storage/Exos" = {
device = "//10.10.10.2/Exos_12TB_Share";
fsType = "cifs";
options = [
"guest"
"uid=1000"
"gid=100"
"file_mode=0664"
"dir_mode=0775"
"vers=3.0"
"iocharset=utf8"
"_netdev"
"nofail"
# Systemd dependencies
"x-systemd.requires=wireguard-wg0.service"
"x-systemd.after=wireguard-wg0.service"
"x-systemd.automount"
"x-systemd.idle-timeout=10min"
"x-systemd.mount-timeout=15s"
];
};
## Hermes <-> Exos command transport (Samba share). Agents drop requests into
## /storage/hermes-q/inbox; Exos runs the local whitelist and writes results to
## outbox/. Scoped to the WireGuard tunnel only (hosts allow = Exos).
services.samba = {
enable = true;
openFirewall = false;
smbd.enable = true;
nmbd.enable = false;
winbindd.enable = false;
settings = {
global = {
"server role" = "standalone server";
"server string" = "Utumno hermes-q transport";
"workgroup" = "WORKGROUP";
"security" = "user";
"map to guest" = "bad user";
"guest account" = "nobody";
"hosts allow" = "10.10.10.2";
"hosts deny" = "0.0.0.0/0";
};
"hermes-q" = {
path = "/storage/hermes-q";
"read only" = "no";
"browseable" = "yes";
"guest ok" = "yes";
"force user" = "harley";
"force group" = "users";
"create mask" = "0664";
"directory mask" = "0775";
};
};
};
systemd.services."samba-smbd" = {
after = [ "network.target" "wireguard-wg0.service" ];
requires = [ "wireguard-wg0.service" ];
};
## Post-boot unlock + mount 8TB LUKS data drive
systemd.services.unlock-8tb = {
description = "Unlock and mount 8TB storage";
after = [ "local-fs.target" ];
wants = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
serviceConfig.RemainAfterExit = true;
script = ''
if ! /run/current-system/sw/bin/cryptsetup status 8tb >/dev/null 2>&1; then
/run/current-system/sw/bin/cryptsetup luksOpen --key-file /root/keyfiles/8tb.key /dev/disk/by-uuid/2bc4df99-677b-46d4-a1b8-38dd3c636a2b 8tb
fi
if ! /run/current-system/sw/bin/mountpoint -q /storage/8TB; then
/run/current-system/sw/bin/mount /dev/mapper/8tb /storage/8TB
fi
if ! /run/current-system/sw/bin/mountpoint -q /storage/8TB/arrs-tmp/sabnzbd; then
/run/current-system/sw/bin/mount /storage/8TB/arrs-tmp/sabnzbd
fi
'';
};
## Post-boot unlock + mount 20TB LUKS data drive
systemd.services.unlock-20tb = {
description = "Unlock and mount 20TB storage";
after = [ "local-fs.target" ];
wants = [ "local-fs.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
serviceConfig.RemainAfterExit = true;
script = ''
if ! /run/current-system/sw/bin/cryptsetup status 20tb >/dev/null 2>&1; then
/run/current-system/sw/bin/cryptsetup luksOpen --key-file /root/keyfiles/20tb.key /dev/disk/by-uuid/772e960b-85bd-4076-bbce-3cd87d5fd09f 20tb
fi
if ! /run/current-system/sw/bin/mountpoint -q /storage/20TB; then
/run/current-system/sw/bin/mount /dev/mapper/20tb /storage/20TB
fi
'';
};
## Order storage-backed services after the 8TB unlock (boot race fix: services
## starting before /storage/8TB is mounted initialize against an empty mountpoint)
systemd.services = {
gitea = { after = [ "unlock-8tb.service" ]; requires = [ "unlock-8tb.service" ]; };
sonarr = { after = [ "unlock-8tb.service" ]; };
radarr = { after = [ "unlock-8tb.service" ]; };
sabnzbd = { after = [ "unlock-8tb.service" ]; };
jellyfin = { after = [ "unlock-8tb.service" ]; };
home-assistant = { after = [ "unlock-8tb.service" ]; };
kiwix-serve = { after = [ "unlock-20tb.service" ]; };
## libvirt -- added 2026-09-22. The nyra-lab storage pool and its guest disks
## live on /storage/8TB/VMs, so libvirtd must not autostart that pool (and
## libvirt-guests must not resume the guest) before the volume is mounted.
## Observed: "Failed to autostart storage pool 'nyra-lab': cannot open
## directory '/storage/8TB/VMs/nyra-lab'" then libvirt-guests "Cannot access
## storage file .../nyra-lab/seed.iso" -> unit FAILED on every boot.
## `after` only, deliberately: arch-vm / arch-vm-clone live on the ROOT
## filesystem, so a failed unlock must not take libvirtd (and Harley's VMs)
## down with it. NEVER requires/RequiresMountsFor storage-8TB.mount -- that
## mount unit has no fragment and the unit is silently discarded at boot.
libvirtd = { after = [ "unlock-8tb.service" ]; };
"libvirt-guests" = { after = [ "unlock-8tb.service" ]; };
};
## Pin SABnzbd's completed-category dirs to 2775 sabnzbd:media.
##
## Root cause (2026-09-20): SABnzbd applies `permissions = 775` via Python's
## os.chmod() -- a bit-exact syscall. That CLEARS the setgid bit on any directory
## SAB creates. (GNU's `chmod` CLI *preserves* setuid/setgid for numeric modes,
## which is exactly why this is easy to mis-test.) A release folder created inside
## a setgid-less dir therefore inherits SAB's PRIMARY group (`sabnzbd`) instead of
## `media`. radarr/sonarr (group `media`) can then read+copy the file out but NOT
## unlink the source -- unlink needs write+execute on the PARENT dir -- so the
## import dies with System.UnauthorizedAccessException and churns every ~90s,
## re-cloning into the library and permanently deleting the destination each pass.
##
## SAB creates a category dir only once, so an EXISTING correct dir stays correct.
## The landmine is a category dir that does not yet exist (a new category) or one
## that gets recreated: SAB creates it wrong, and EVERY release in that category
## then fails to import. Observed live on Scavengers Reign S01 (folder group
## `sabnzbd`, files from 2025-04) and Bob's Burgers S13 (2023-10).
##
## The glob is deliberate: it self-covers a newly added SAB category, which is the
## case that actually bites. Non-recursive -- only category dirs get pinned, never
## the release folders inside them.
##
## MUST require unlock-20tb.service, NEVER storage-20TB.mount: /storage/20TB is
## fragmentless (unlocked+mounted by a oneshot), so that mount unit is absent and
## RequiresMountsFor only ORDERS (same rule as the 8TB services above, 2026-09-16).
##
## ⚠️ Deliberately NOT a systemd.tmpfiles rule: a `d` rule for this path would
## CREATE it on the root fs if the pool were unmounted, shadowing the mountpoint.
## This service only ever chgrp/chmods paths that already exist.
systemd.services.sabnzbd-category-perms = {
description = "Pin SABnzbd category dirs to 2775 sabnzbd:media";
after = [ "unlock-20tb.service" ];
requires = [ "unlock-20tb.service" ];
wantedBy = [ "multi-user.target" ];
# NOTE: deliberately NO RemainAfterExit. It would leave the unit
# active(exited), and a timer activating an already-active unit is a
# NO-OP -- the 15-min re-assert would never run. Verified live 2026-09-20.
serviceConfig = { Type = "oneshot"; };
script = ''
for d in /storage/20TB/Downloads/complete \
/storage/20TB/Downloads/complete/*/ ; do
[ -d "$d" ] || continue
/run/current-system/sw/bin/chgrp media "$d"
/run/current-system/sw/bin/chmod 2775 "$d"
done
'';
};
## ...and re-assert periodically, because a NEW category dir is created by SAB at
## first use, long after boot -- it would otherwise stay wrong until a reboot.
systemd.timers.sabnzbd-category-perms = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5min";
OnCalendar = "*:0/15";
Unit = "sabnzbd-category-perms.service";
};
};
## 1TB storage drive, SSD-NVME, btrfs
#fileSystems."/storage/ssd/" = {
#device = " ";
#fstype = "btrfs";
#};
## automated Snapper snapshots for the root filesystem
services.snapper = {
configs."root" = {
SUBVOLUME = "/";
ALLOW_USERS = [ "harley" ];
TIMELINE_CREATE = true;
TIMELINE_CLEANUP = true;
TIMELINE_MIN_AGE = "1800";
TIMELINE_LIMIT_HOURLY = "6";
TIMELINE_LIMIT_DAILY = "7";
TIMELINE_LIMIT_WEEKLY = "4";
TIMELINE_LIMIT_MONTHLY = "6";
TIMELINE_LIMIT_YEARLY = "0";
NUMBER_CLEANUP = true;
NUMBER_LIMIT = "50";
};
};
## snapper for recov
services.snapper.configs."file7" = {
SUBVOLUME = "/mnt/.recov";
ALLOW_USERS = [ "harley" ];
TIMELINE_CREATE = false;
NUMBER_CLEANUP = false;
};
############################################################
## ##
## 4: System Configuration ##
## ##
############################################################
## Time/Locale
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_US.UTF-8";
## Nvidia GPU
services.xserver.videoDrivers = [ "nvidia" ];
hardware.graphics.enable = true;
hardware.nvidia = {
modesetting.enable = true;
powerManagement.enable = true;
open = false;
nvidiaSettings = true;
};
## Greeter user for greetd login on tty6 LOOK INTO THIS, IDK WHAT IT IS
users.users.greeter = {
isSystemUser = true;
group = "nogroup";
extraGroups = [ "video" "input" "seat" ];
description = "greetd login greeter";
};
## greetd PAM service
security.pam.services.greetd = {};
## Display Manager (SDDM) and Plasma 6
services.xserver.enable = true;
services.xserver.displayManager.sddm.enable = true;
services.desktopManager.plasma6.enable = true;
programs.kdeconnect.enable = true;
## Pipewire
services.pipewire = {
enable = true;
pulse.enable = true;
alsa.enable = true;
alsa.support32Bit = true;
};
# Xpadneo for xbox controller bluetooth
hardware.xpadneo.enable = true;
############################################################
## ##
## 5) Networking ##
## ##
############################################################
networking.hostName = "utumno";
networking.networkmanager.enable = true;
# Don't block boot waiting for all network profiles to come online
systemd.services.NetworkManager-wait-online.enable = lib.mkForce false;
hardware.bluetooth = {
enable = true;
powerOnBoot = true;
settings.General.Experimental = true;
};
##
##
## 5.1 Firewall
##
##
networking.firewall = {
## UDP ports and ranges
allowedUDPPorts = [ 51820 51812 5113 ];
allowedUDPPortRanges = [
{ from = 47884; to = 48010; }
];
## TCP ports and ranges
allowedTCPPorts = [ 22 222 8084 443 80 8743 8787 9119 8180 7777 3180 47985 47989 47990 47991 47992 48010 48011 5113 8777 8642 8767 3000 8087 8123 3233 8099 8097];
# Enable IP forwarding (needed for Wireguard routing)
enable = true;
};
## Vaultwarden: reachable from LAN + WireGuard only (8443 is NOT port-forwarded)
networking.firewall.interfaces.wg0.allowedTCPPorts = [ 445 8443 8444 8744 8770 8771 8790 ];
networking.firewall.interfaces.eno1.allowedTCPPorts = [ 8443 8444 8744 ];
## RP guest VM (rp-tavern on virbr0) -> host llama-server on 11434.
## Scoped to the bridge on purpose: 11434 is NOT in allowedTCPPorts, which
## would expose the model on eno1 and wg0 as well.
networking.firewall.interfaces.virbr0.allowedTCPPorts = [ 11434 ];
## Name resolution for this host itself. Utumno uses Comcast DNS directly
## (/etc/resolv.conf -> 75.75.75.75) and never queries its own Pi-hole, so
## *.wingard.pro resolves to the WAN address -- while the LAN-only ports are
## (correctly) not port-forwarded and Comcast hairpinning is broken. Net effect:
## the host cannot reach its own services by name. Measured 2026-09-23: the joplin
## CLI failed with `connect ECONNREFUSED 71.61.169.167:8444` (that is the WAN IP).
## Same five names as the Pi-hole records in 5.3, from the host side.
networking.hosts = {
"10.0.1.49" = [
"jellyfin.wingard.pro"
"speedtest.wingard.pro"
"vault.wingard.pro"
"gitea.wingard.pro"
"joplin.wingard.pro"
];
};
## Roman's WebUI (8790) is deliberately **WireGuard-only** (Harley's call, 2026-09-16). He
## reaches it over the tunnel, so every other device on the LAN - ESP fleet, tablets, the
## arch-vm - has no route to a login page for an agent that runs shell commands. It is NOT in
## allowedTCPPorts (which accepts on every interface) and NOT on eno1. See
## systemd.services.hermes-webui-roman + vault notes/roman-access.md.
## Gitea (3010): SOURCE-scoped, deliberately NOT interface-scoped.
## It previously sat in allowedTCPPorts, which accepts it on EVERY interface --
## and eno1 carries this host's global IPv6 addresses, so the host itself was not
## what limited reachability. Interface scoping would not have fixed that either:
## internet traffic arrives on eno1 just like LAN traffic does. Allowlisting the
## SOURCE networks is what actually closes it.
## Deliberately NO IPv6 accept: Gitea is reached as http://10.0.1.49:3010, and the
## LAN IPv6 prefix (2601:540:cc03:4760::/64) is globally routable, so any v6 accept
## would re-open the same hole.
## If a legitimate client outside these ranges is ever blocked, it appears in the
## journal as 'refused connection:' -- add its subnet here rather than reverting.
networking.firewall.extraCommands = ''
# host-local, LAN, WireGuard
iptables -w -A nixos-fw -p tcp --dport 3010 -s 127.0.0.0/8 -j nixos-fw-accept
iptables -w -A nixos-fw -p tcp --dport 3010 -s 10.0.1.0/24 -j nixos-fw-accept
iptables -w -A nixos-fw -p tcp --dport 3010 -s 10.10.10.0/24 -j nixos-fw-accept
# Palantir's segment and the libvirt VM network
iptables -w -A nixos-fw -p tcp --dport 3010 -s 192.168.2.0/24 -j nixos-fw-accept
iptables -w -A nixos-fw -p tcp --dport 3010 -s 192.168.122.0/24 -j nixos-fw-accept
# docker bridges (host-local only)
iptables -w -A nixos-fw -p tcp --dport 3010 -s 172.16.0.0/12 -j nixos-fw-accept
'';
## Enable Ip forwarding at the kernel level
boot.kernel.sysctl."net.ipv4.ip_forward" = 1;
## CrowdSec REMOVED 2026-09-12 (Harley's call).
## It had been inert for days: the firewall bouncer never applied bans at nftables,
## and the agent itself broke on restart over a /var/lib/crowdsec symlink that a
## tmpfiles `d` rule cannot replace. Removing it is deliberate, and it means this
## host had NO intrusion-prevention layer -- no ban enforcement at all -- until
## 2026-09-20, when fail2ban took over ban enforcement. See 5.1b below.
## To bring it back: services.crowdsec + services.crowdsec-firewall-bouncer, and give
## StateDirectory a REAL directory (tmpfiles type `R` first) rather than a symlink.
##
## 5.1b) fail2ban -- SSH ban enforcement
##
## Added 2026-09-20. A 24h audit found ~1,470 failed root logins/day from a botnet
## (the 45.148.10.0/24 cluster, 193.47.62.69, 62.60.130.0/24, 195.178.110.218, ...).
## sshd already refuses every one of them: PasswordAuthentication = false and the
## generated PAM auth stack is a bare `pam_deny`, so no attempt could ever succeed.
## The cost was purely operational -- unlimited retries and ~1,100 journal err/day,
## about 75% of all error lines on the host.
##
## banaction is left at the module default. networking.nftables.enable is false here,
## so it resolves to iptables-multiport against the live `nixos-fw` chain; the module
## also puts config.networking.firewall.package on the unit's PATH, so the daemon has
## its own iptables and does not depend on it being in the system profile.
## backend = systemd (module default) -- jails read the journal, not /var/log.
##
## Pre-enable check: fail2ban-regex matched 2,364 of 2,659 real sshd lines from the
## last 24h (0 missed) with the stock sshd filter, so the jail will see this traffic.
## That check exists because crowdsec was removed on 2026-09-12 for being silently
## inert; this one is not taken on faith -- the ban count is re-read after activation.
##
## ignoreIP covers every trusted route in -- LAN (eno1/wlp7s0), WireGuard, Exos's
## routed subnets, Palantir, the arch-vm libvirt network (the guest reaches the host
## over virbr0, not eno1) and the docker bridges. 127.0.0.1/8 and ::1 are added by
## the module. No trusted host can lock itself out.
services.fail2ban = {
enable = true;
## Defaults are bantime 10m / maxretry 3; a botnet that returns wants longer.
bantime = "1h";
maxretry = 3;
## findtime widened to 1h, deliberately NOT the fail2ban default of 600s.
## Measured 2026-09-20 21:30 EDT: ~62 failures/hour arrive from ~19 rotating
## IPs, but most of those IPs only manage 1-4 attempts per 10 minutes. At the
## default findtime they never reached maxretry and were never banned -- only
## the single heaviest scanner tripped it. A one-hour window catches the slow
## drip as well as the bursty scanners.
## This cannot misfire on a real user: authentication is pubkey-only, and every
## trusted route (LAN, WireGuard, Palantir, libvirt, docker) is in ignoreIP.
jails.DEFAULT.settings.findtime = 3600;
## Repeat offenders escalate 1h -> 2h -> 4h ... capped at 48h, with +/-8m of
## jitter so a ban cannot be timed precisely to the second.
bantime-increment = {
enable = true;
rndtime = "8m";
maxtime = "48h";
};
ignoreIP = [
"10.0.1.0/24" # LAN -- eno1 / wlp7s0
"10.0.0.0/24" # Exos routed subnet
"10.10.10.0/24" # WireGuard
"192.168.2.0/24" # Palantir segment
"192.168.3.0/24" # Maroon / Roman phone + Exos route
"192.168.122.0/24" # libvirt net -- arch-vm arrives here
"172.16.0.0/12" # docker bridges
];
};
## SSHD
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "prohibit-password";
};
};
##
##
## 5.2: Wireguard
##
##
networking.wireguard.interfaces = {
wg0 = {
ips = [ "10.10.10.1/24" ];
listenPort = 51820;
privateKeyFile = "/etc/nixos/secrets/wireguard/wg0-key.key";
# IP forwarding + NAT for wg0 clients to internet/LAN
postSetup = ''
${pkgs.iptables}/bin/iptables -A FORWARD -i wg0 -j ACCEPT
${pkgs.iptables}/bin/iptables -t nat -A POSTROUTING -o eno1 -j MASQUERADE
'';
postShutdown = ''
${pkgs.iptables}/bin/iptables -D FORWARD -i wg0 -j ACCEPT
${pkgs.iptables}/bin/iptables -t nat -D POSTROUTING -o eno1 -j MASQUERADE
'';
peers = [
# Exos — routes remote subnets
{
publicKey = "8rO4j18cPAw6zv8b26PNXFHpEwxqjBq1r8yRmWNOMXY=";
endpoint = "briggs.wiki:51820";
allowedIPs = [
"10.10.10.2/32"
"10.0.0.0/24"
"192.168.3.0/24"
];
}
# Maroon / Roman Phone
{
publicKey = "Sih2qcJtmhdlSwxlaRont7ezwazo0FMyMxTjhWBtpjA=";
allowedIPs = [ "192.168.3.1/32" ];
}
# Palantir (Phone)
{
publicKey = "RUPXirLLTY1KeFIjfwKQtVg3ckj8sLz2QN/AiLZOmFw=";
allowedIPs = [ "192.168.2.1/32" ];
}
# arch-vm — Arch Linux KVM guest (libvirt domain "arch-vm")
#
# Guest MUST use Endpoint = 192.168.122.1:51820 (virbr0 path).
# Its primary NIC is macvtap-on-eno1, and macvtap in bridge mode cannot
# reach the parent host, so 10.0.1.49 is unreachable from the guest.
# The WAN endpoint (wingard.pro) also fails — the router does not
# hairpin NAT for LAN clients. virbr0 is the only working path.
#
# NOTE: this peer was previously mislabeled "Menegroth"; the guest's
# hostname was confirmed as arch-vm on 2026-08-31.
{
publicKey = "lmjsGro0zeIWzznkNa0dSNvFWupz/4iX1Fz6gidFbyA=";
allowedIPs = [ "192.168.2.7/32" ];
}
# iPad
{
publicKey = "lqTK2qoaNJ+Md8RZT3TVxN+fAHllTwIG2znZKI3Rt2c=";
allowedIPs = [ "192.168.2.3/32" ];
}
];
};
};
##
## 5.2b) wg0 peer resilience (Exos) - added 2026-09-15
##
## network-online.target guarantees nothing on this host: NetworkManager-wait-online
## is force-disabled above for boot speed, so the target is "reached" instantly and a
## peer unit can start before the network is usable. The Exos peer (above) is our ONLY
## peer whose endpoint is a hostname (briggs.wiki:51820) -> it needs DNS at unit start.
## On 2026-09-14 23:23 it lost that race, exited 1, and Type=oneshot + Restart=no made
## the loss permanent: /storage/Exos stayed dead 11h and the no-SSH transport to Roman
## went blind with it. pihole-ftl-setup exited 1 the same second, same cause.
##
## Restart=on-failure IS permitted for Type=oneshot (only always/on-success are not),
## so this unit now retries ~every 6s until DNS answers: self-healing, boot unaffected.
## The unit name carries systemd's escaping of the "=" in the public key (\x3d), which
## is exactly the name NixOS generates for it.
systemd.services."wireguard-wg0-peer-8rO4j18cPAw6zv8b26PNXFHpEwxqjBq1r8yRmWNOMXY\\x3d".serviceConfig = {
Restart = "on-failure";
RestartSec = "5s";
};
##
##
## 5.3) Pi-hole DNS
##
##
services.pihole-ftl = {
enable = true;
openFirewallDNS = true;
## Upstream DNS servers (Cloudflare and Quad9)
settings = {
dns = {
upstreams = [
"1.1.1.1#53"
"1.0.0.1#53"
"9.9.9.9#53"
];
## Listen on all interfaces
listeningMode = "ALL";
## Block ESNI, ICloud Private Relay, Firefox canary, etc.
blocking.active = true;
blocking.mode = "NULL";
## Rate limiting
rateLimit.count = 1000;
rateLimit.interval = 60;
## Cache
cache.size = 10000;
cache.optimizer = 3600;
## DNSSEC
dnssec = false;
bogusPriv = true;
domainNeeded = false;
## Local DNS records
hosts = [
"10.0.1.49 jellyfin.wingard.pro"
"10.0.1.49 speedtest.wingard.pro"
"10.0.1.49 vault.wingard.pro"
"10.0.1.49 gitea.wingard.pro"
"10.0.1.49 joplin.wingard.pro"
];
};
## Web server on 8999, admin page at /admin/
webserver = {
port = lib.mkForce "8999";
interface.boxed = "true";
interface.theme = "default-dark";
};
## Privacy level 0 = full stats
misc.privacylevel = 0;
};
## Blocklist: Steven Black's unified hosts
lists = [
{
url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts";
description = "Steven Black's unified adlist";
}
];
};
## Pi-hole web admin dashboard
services.pihole-web.enable = true;
systemd.services.pihole-ftl-setup.script = lib.mkForce ''
# Can't use -u (unset) because api.sh uses API_URL before it is set
set +u
set -eo pipefail
pihole="${pkgs.pihole}/bin/pihole"
jq="${pkgs.jq}/bin/jq"
${pkgs.curl}/bin/curl --retry 3 --retry-delay 5 "https://ftl.pi-hole.net/macvendor.db" -o "/var/lib/pihole/macvendor.db" || echo "Failed to download MAC database from https://ftl.pi-hole.net/macvendor.db"
if [ ! -f '/var/lib/pihole/gravity.db' ]; then
$pihole -g
${pkgs.procps}/bin/kill -s SIGRTMIN "$(systemctl show --property MainPID --value pihole-ftl.service)"
fi
source ${pkgs.pihole}/share/pihole/advanced/Scripts/api.sh
source ${pkgs.pihole}/share/pihole/advanced/Scripts/utils.sh
any_failed=0
ensureList() {
local payload="$1" address type existing result error id
address=$($jq -r '.address' <<< "$payload")
type=$($jq -r '.type' <<< "$payload")
existing=$(GetFTLData "lists?type=$type")
if $jq -e --arg address "$address" '.lists[]? | select(.address == $address)' >/dev/null <<< "$existing"; then
echo "List already present: $address"
return
fi
echo "Adding list: $payload"
result=$(PostFTLData "lists?type=$type" "$payload")
error="$($jq '.error' <<< "$result")"
if [[ "$error" != "null" ]]; then
echo "Error: $error"
any_failed=1
return
fi
id="$($jq '.lists.[].id?' <<< "$result")"
if [[ "$id" == "null" ]]; then
any_failed=1
error="$($jq '.processed.errors.[].error' <<< "$result")"
echo "Error: $error"
return
fi
echo "Added list ID $id: $result"
}
for _ in 1 2 3; do
(TestAPIAvailability) && break
echo "Retrying API shortly..."
${pkgs.coreutils}/bin/sleep .5s
done
LoginAPI
ensureList '{"address":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","comment":"Steven Black unified adlist","enabled":true,"type":"block"}'
$pihole -g
exit $any_failed
'';
## pihole-ftl-setup resilience - added 2026-09-15
## Same class of failure as the wg0 Exos peer above: this unit needs the network (it
## curls ftl.pi-hole.net and talks to the FTL API) but network-online.target is hollow
## here, so it exited 1 in the same second as that peer on 2026-09-14 23:23 and has sat
## in `systemctl --failed` ever since. Retry a BOUNDED number of times - 5 attempts
## inside 10 minutes, then give up and stay failed. Bounded on purpose: each attempt
## runs `pihole -g`, so an unbounded retry loop would hammer blocklist downloads.
systemd.services.pihole-ftl-setup = {
unitConfig = {
StartLimitIntervalSec = 600;
StartLimitBurst = 5;
};
serviceConfig = {
Restart = "on-failure";
RestartSec = "20s";
};
};
##
##
## 5.4: Nginx
##
##
## ACME / Let's Encrypt — HTTP-01 via the nginx webroot, auto-renewing.
## Replaces the hand-copied certs that previously sat in /etc/letsencrypt/live/
## with no renewal automation whatsoever.
security.acme = {
acceptTerms = true;
## No ACME contact address set: lego registers the account without one.
## Add `defaults.email = "you@example.com";` here if you want Let's Encrypt
## expiry notices (note: changing it creates a new ACME account + re-issues).
};
services.nginx = {
enable = true;
recommendedProxySettings = true;
virtualHosts = {
## Gitea — the HTTPS front for both the web UI and the Docker registry.
##
## Why TLS rather than `insecure-registries`: that daemon option needs a
## config change (and a daemon restart) on every client that pulls —
## including the arch-vm guest — and what it really buys is "don't verify
## the certificate". With a real certificate on a name the clients already
## resolve, `docker push/pull gitea.wingard.pro/...` needs no client
## configuration at all, and the TLS is genuinely verified.
##
## Gitea serves its registry on the same port as the web UI (3010), so one
## vhost covers both: /v2/ is the registry, everything else is the UI.
## The body and timeout settings below exist because image layers are
## GB-scale — nginx's defaults (1m body, 60s read) reject a real push.
## Do not add proxy_http_version here: proxyWebsockets already emits it,
## and a duplicate directive makes nginx refuse to start.
"gitea.wingard.pro" = {
enableACME = true;
acmeRoot = "/var/lib/acme/acme-challenge";
addSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:3010";
proxyWebsockets = true;
extraConfig = ''
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
'';
};
};
"jellyfin.wingard.pro" = {
## Cert issued + renewed automatically by security.acme
enableACME = true;
acmeRoot = "/var/lib/acme/acme-challenge";
addSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8096";
proxyWebsockets = true;
};
};
"speedtest.wingard.pro" = {
## Cert issued + renewed automatically by security.acme
enableACME = true;
acmeRoot = "/var/lib/acme/acme-challenge";
addSSL = true;
root = "/opt/openspeedtest";
locations."/" = {
index = "index.html";
tryFiles = "$uri $uri/ /index.html";
};
locations."~ \\.php$" = {
extraConfig = ''
fastcgi_pass unix:/run/phpfpm-openspeedtest.sock;
fastcgi_index index.php;
include ${pkgs.nginx}/conf/fastcgi.conf;
fastcgi_param SCRIPT_FILENAME /opt/openspeedtest$fastcgi_script_name;
'';
};
};
"speedtest-http" = {
listen = [ { addr = "0.0.0.0"; port = 8084; } ];
root = "/opt/openspeedtest";
locations."/" = {
index = "index.html";
tryFiles = "$uri $uri/ /index.html";
};
locations."~ \\.php$" = {
extraConfig = ''
fastcgi_pass unix:/run/phpfpm-openspeedtest.sock;
fastcgi_index index.php;
include ${pkgs.nginx}/conf/fastcgi.conf;
fastcgi_param SCRIPT_FILENAME /opt/openspeedtest$fastcgi_script_name;
'';
};
};
## LAN dashboard (Hermes) - self-signed TLS for secure-context (mic) access
"dashboard-local" = {
addSSL = true;
listen = [ { addr = "0.0.0.0"; port = 8743; ssl = true; } ];
sslCertificate = "/etc/nixos/secrets/dashboard/dashboard.crt";
sslCertificateKey = "/etc/nixos/secrets/dashboard/dashboard.key";
locations."/" = {
proxyPass = "http://10.10.10.1:9119";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
'';
};
};
## SillyTavern (local RP) - runs INSIDE the rp-tavern KVM guest, so it is
## NOT directly reachable: libvirt rejects forwarding into its NAT network
## (-A LIBVIRT_FWI -o virbr0 -j REJECT) and no wg0 peer advertises
## 192.168.122.0/24. The host is already on that bridge as 192.168.122.1,
## so we hop through nginx here instead of opening the guest subnet up.
##
## Self-signed, same cert as the LAN dashboard: access is by IP over
## WireGuard/LAN, so there is no public name to issue an ACME cert for.
## Expect a one-time browser warning on the phone.
##
## Host header: SillyTavern's CSRF middleware compares the request against
## the origin the browser used, so forward $host (NOT the upstream host).
"sillytavern-local" = {
addSSL = true;
listen = [ { addr = "0.0.0.0"; port = 8744; ssl = true; } ];
sslCertificate = "/etc/nixos/secrets/dashboard/dashboard.crt";
sslCertificateKey = "/etc/nixos/secrets/dashboard/dashboard.key";
locations."/" = {
proxyPass = "http://192.168.122.65:8000";
proxyWebsockets = true;
extraConfig = ''
## DO NOT re-set Host / X-Real-IP / X-Forwarded-* here.
## services.nginx.recommendedProxySettings already sets those four,
## and nginx sends a duplicate when a header is set twice. Node then
## joins the copies into "192.168.122.1, 192.168.122.1", which is not
## a valid IP; SillyTavern's whitelist middleware feeds it to
## ip-matching, whose IPv6.matches() throws "The given value is not a
## valid IP" -> HTTP 500 on every proxied request. Verified by
## source trace: whitelist.js:80 -> index.js:29 -> ip.js:334.
## (proxy_http_version 1.1 is already emitted by
## services.nginx.proxyWebsockets = true - do not repeat it here,
## a duplicate directive makes nginx refuse to start.)
## Generations stream token-by-token over a held-open connection;
## the default 60s read timeout truncates long replies.
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
## Character cards are PNGs and run to several MB.
client_max_body_size 64m;
'';
};
};
## ACME HTTP-01 challenge for the vault. Lives on public :80 but only ever
## serves challenge tokens — the vault vhost itself never listens there.
## (It gets its own vhost because an explicit port-80 entry in a vhost's
## listen list gets auto-expanded into an SSL listener on port 80.)
"vault-acme" = {
listen = [ { addr = "0.0.0.0"; port = 80; } ];
serverName = "vault.wingard.pro";
locations."/.well-known/acme-challenge/" = {
root = "/var/lib/acme/acme-challenge";
};
locations."/".return = "404";
};
## Vaultwarden — LAN + WireGuard only. Port 8443 is NOT port-forwarded, so
## the *.wingard.pro wildcard pointing home cannot expose the vault.
"vault.wingard.pro" = {
enableACME = true;
acmeRoot = "/var/lib/acme/acme-challenge";
addSSL = true;
listen = [ { addr = "0.0.0.0"; port = 8443; ssl = true; } ];
locations."/" = {
proxyPass = "http://127.0.0.1:8222";
proxyWebsockets = true;
extraConfig = ''
client_max_body_size 128m;
proxy_read_timeout 300s;
'';
};
};
## ACME HTTP-01 challenge for Joplin Server. Lives on public :80 but only ever
## serves challenge tokens — the Joplin vhost itself never listens there.
"joplin-acme" = {
listen = [ { addr = "0.0.0.0"; port = 80; } ];
serverName = "joplin.wingard.pro";
locations."/.well-known/acme-challenge/" = {
root = "/var/lib/acme/acme-challenge";
};
locations."/".return = "404";
};
## Joplin Server — LAN + WireGuard only. Port 8444 is NOT port-forwarded, so the
## *.wingard.pro wildcard pointing home cannot expose the notes store.
##
## The Host header MUST carry the port. Joplin Server compares Host against
## APP_BASE_URL strictly (verified 2026-09-23 against the live container):
## joplin.wingard.pro:8444 -> 200 joplin.wingard.pro -> 404
## wrong port / wrong name / X-Forwarded-Host -> 404 "Invalid origin"
## `$http_host` would forward the port but gixy rejects it at BUILD time as
## host_spoofing, so use `$host:$server_port`. That value is effective even though
## the module appends its own `Host $host` include afterwards: measured with a
## throwaway nginx (nx01 test, 2026-09-23), the FIRST proxy_set_header Host in a
## location is the one the upstream sees.
"joplin.wingard.pro" = {
enableACME = true;
acmeRoot = "/var/lib/acme/acme-challenge";
addSSL = true;
listen = [ { addr = "0.0.0.0"; port = 8444; ssl = true; } ];
locations."/" = {
proxyPass = "http://127.0.0.1:22300";
proxyWebsockets = true;
extraConfig = ''
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
## Attachments (photos) sync through here; nginx defaults to 1m and a phone
## photo would 413. Same 128m as the vault vhost, and at or above Joplin
## Server's own content limit.
client_max_body_size 128m;
proxy_read_timeout 300s;
'';
};
};
};
};
############################################################
## ##
## 5.5) Vaultwarden (self-hosted Bitwarden-compatible) ##
## ##
############################################################
## Reachable at https://vault.wingard.pro:8443 — LAN + WireGuard only.
## nginx terminates TLS on 8443; Vaultwarden itself binds loopback only.
## Data lives in /var/lib/vaultwarden (StateDirectory). Daily sqlite-consistent
## snapshots plus attachments/rsa_key copies land in /var/backup/vaultwarden.
services.vaultwarden = {
enable = true;
dbBackend = "sqlite";
backupDir = "/var/backup/vaultwarden";
config = {
## Must match the exact URL clients use — port included.
DOMAIN = "https://vault.wingard.pro:8443";
ROCKET_ADDRESS = "127.0.0.1";
ROCKET_PORT = 8222;
ROCKET_LOG = "critical";
## Closed 2026-09-10: Harley's account is registered. Signups off.
## INVITATIONS_ALLOWED=false too, so no new accounts without an invite.
SIGNUPS_ALLOWED = false;
INVITATIONS_ALLOWED = false;
};
};
############################################################
## ##
## 6: Users ##
## ##
############################################################
## setting up sudo wheel group
security.sudo.extraRules = [
{ groups = [ "wheel" ]; commands = [ { command = "ALL"; options = [ "NOPASSWD" ]; } ]; }
];
## Harley
users.users.harley = {
isNormalUser = true;
shell = lib.mkForce pkgs.zsh;
extraGroups = [ "wheel" "networkmanager" "docker" "libvirtd" "input" "uinput" "video" "dialout" ];
openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH4OAJ8iC7CQIcedVbU86xgoL4YkMWAG9bU6aj9v4Skb harley@utumno" ];
};
## Roman
users.users.roman = {
isNormalUser = true;
description = "Roman";
home = "/home/roman/";
createHome = true;
shell = pkgs.bash;
extraGroups = [ "wheel" "users" ];
openssh.authorizedKeys.keys = [ " " ];
};
############################################################
## ##
## 7: General Packages ##
## ##
############################################################
environment.systemPackages = with pkgs; [
## Cli tools
vim wget curl progress git btop neovim tmux lsof bat ncdu nvtopPackages.full fastfetch glow
unzip unrar p7zip xclip
## Joplin Server's terminal client — Nyra's headless interface to the notes store
## (read/write notes + notebooks, attach files and photos, to-dos with due dates,
## E2EE, export/import). Joplin's REST Data API on :41184 is hosted by the DESKTOP
## app, so it does not exist on a headless server; this is the equivalent.
joplin-cli
testdisk btdu e2fsprogs
## Games
lutris rpcs3 mednafen mednafen-server mednaffe dolphin-emu
## System
adwaita-icon-theme tela-circle-icon-theme
cifs-utils sshpass pciutils usbutils (python312.withPackages (ps: with ps; [ pyserial ])) (pkgs.kdePackages.bluedevil) gtkgreet
cage guake kitty android-tools pv ghostscript imagemagick nodejs go deno socat
fzf smartmontools shadow ripgrep fd jq tree qdirstat uv python312Packages.evdev python3Packages.segno
## Window-presence oracle for hermes-desktop-health (~/bin/hermes-desktop-health).
## The desktop app's Electron single-instance lock turns a window-less primary
## into a silent no-op on every click, and backend sockets cannot distinguish
## "window up" from "no window" — the browser process and the network-service
## utility both hold those connections with nothing on screen. So the health
## probe asks KWin directly: `kdotool search --pid <primary> -a '.*'`.
## 2026-09-12: without this the probe reported healthy forever and the
## 3-strike heal timer never fired.
kdotool
## ESP development (VS Code + PlatformIO, Arduino CLI, ESP-IDF)
## pyserial arrives via the wrapped python312 above so that
## `python3 -c 'import serial'` works for serial-monitor scripts.
## esptool ships its own copy in its wrapper; it is not importable
## from the bare system interpreter.
vscode platformio esptool arduino-cli picocom arduino-ide
## Virtualization (KVM/QEMU)
qemu libvirt virt-manager virt-viewer OVMF swtpm virtiofsd parted dosfstools btrfs-progs squashfsTools
moonlight-qt
firefox
chromium # headless CDP backend for agent browser automation
obsidian
## Multimedia
mpv geeqie ffmpeg yt-dlp
## Virtual display for Sunshine headless streaming (krfb-virtualmonitor)
kdePackages.krfb
## Roman
tldr
## Local voice inference — llama-server, CUDA build (added 2026-09-17).
## Serves the realtime speech pipeline that backs hermes-live-voice's
## `local` provider. This is only the *verbalizer* model (turn-taking +
## speaking Hermes' answers aloud) — Hermes stays the agent brain, so a
## small quantized model is correct here, not a compromise.
## Must be the -cuda build so the RTX 4060 does the work; the plain build
## would silently fall back to CPU and wreck voice latency.
## Weights: /storage/8TB/models/gguf/. See vault notes/telephony-voice.md.
llama-cpp-cuda
];
############################################################
## ##
## 8) Services ##
## ##
############################################################
## Desktop app python path -> /etc/hermes-desktop-env
## hermes-desktop.service spawns the sealed venv python itself and passes only
## its own source root as PYTHONPATH, so it never sees the wrapper's paths and
## Mnemosyne never instantiated there ("provider 'mnemosyne' loaded but no
## provider instance found", every turn since 2026-09-10). The app *appends* an
## inherited PYTHONPATH to its own, which is what makes the drop-in
## EnvironmentFile work. Generated from the config so a nixpkgs bump cannot
## leave stale store paths behind — the hand-pinned /opt symlinks have done
## exactly that. 2026-09-23.
environment.etc."hermes-desktop-env".text = "PYTHONPATH=${pkgs.hermesMnemosynePyPath}\n";
## Hermes WebUI (port 8787)
## Runs the agent in-process from the sealed uv2nix venv. `HERMES_WEBUI_PYTHON`
## is pinned to the venv python because the WebUI spawns `hermes_cli/main.py`
## gateway/cron subprocesses; without it they'd fall back to system python3
## (no agent deps) and die. `HERMES_WEBUI_AGENT_DIR` points at the real source
## checkout so `_discover_agent_dir` finds `run_agent.py` + `hermes_cli`.
systemd.services.hermes-webui = let
webuiVenv = pkgs.hermes-agent.passthru.hermesVenv;
in {
description = "Hermes Web UI";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HERMES_WEBUI_HOST = "127.0.0.1";
HERMES_WEBUI_PORT = "8787";
HERMES_HOME = "/var/lib/agents/.hermes";
HERMES_WEBUI_STATE_DIR = "/var/lib/agents/.hermes/webui";
HERMES_WEBUI_AGENT_DIR = "/opt/hermes-agent-standalone";
HERMES_WEBUI_PYTHON = "${webuiVenv}/bin/python3";
# In-process agent: the sealed venv alone cannot import fastembed's
# runtime deps, so Mnemosyne loads, finds no provider instance, and recall
# silently degrades to FTS5. Same store paths the wrapper injects.
PYTHONPATH = "/opt/hermes-agent-standalone:${pkgs.hermesMnemosynePyPath}";
SEARXNG_URL = "http://10.0.1.49:8888";
};
serviceConfig = {
Type = "simple";
User = "harley";
Group = "users";
WorkingDirectory = "/opt/hermes-webui";
ExecStart = "${webuiVenv}/bin/python3 /opt/hermes-webui/server.py";
Restart = "on-failure";
RestartSec = 5;
};
};
## Hermes WebUI for Roman (port 8790) - a SECOND, deliberately isolated instance.
##
## Same front-end code as the :8787 service above, but everything that decides identity is
## different: it runs as the `roman` system user, and its HERMES_HOME is Roman's own profile
## under /home/roman/hermes - NOT inside Harley's shared .hermes home. Every tool call this
## agent makes therefore runs as Roman: no sudo (no sudoers entry), and no read access to
## Harley's profile (0700).
##
## `HERMES_WEBUI_ISOLATED_PROFILE=1` is the load-bearing flag: it pins the process to this one
## profile and makes cross-profile queries fail closed (api/profiles.py `_is_isolated_profile_mode`).
## It is an explicit opt-in precisely because the `*/profiles/<name>` path shape alone is
## byte-identical to an ordinary single-user running under a named profile.
##
## Why his profile does NOT live in /var/lib/agents/.hermes/profiles/:
## that shared home holds world-readable files by design (logs/agent.log, cron/executions.db,
## .hermes_history, runtime/active_sessions.json). Putting a profile inside it would need a
## traverse bit that exposes those too, and every new 0644 log file would silently re-open it.
## Keeping his home separate needs no permission change to Harley's data at all.
##
## His WebUI password lives in an EnvironmentFile owned by him (0600), never in this unit.
systemd.services.hermes-webui-roman = let
webuiVenv = pkgs.hermes-agent.passthru.hermesVenv;
in {
description = "Hermes Web UI (Roman, isolated profile)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HERMES_WEBUI_HOST = "0.0.0.0";
HERMES_WEBUI_PORT = "8790";
HERMES_HOME = "/home/roman/hermes/profiles/roman";
HERMES_WEBUI_ISOLATED_PROFILE = "1";
HERMES_WEBUI_STATE_DIR = "/home/roman/hermes/webui";
HERMES_WEBUI_AGENT_DIR = "/opt/hermes-agent-standalone";
HERMES_WEBUI_PYTHON = "${webuiVenv}/bin/python3";
PYTHONPATH = "/opt/hermes-agent-standalone";
SEARXNG_URL = "http://10.0.1.49:8888";
HOME = "/home/roman";
};
serviceConfig = {
Type = "simple";
User = "roman";
Group = "users";
WorkingDirectory = "/opt/hermes-webui";
EnvironmentFile = "/home/roman/hermes/webui.env";
ExecStart = "${webuiVenv}/bin/python3 /opt/hermes-webui/server.py";
Restart = "on-failure";
RestartSec = 5;
};
};
## Hermes Dashboard (Port 9119)
systemd.services.hermes-dashboard = let
mnemosynePythonPath = pkgs.lib.makeSearchPath "lib/python3.12/site-packages" [
pkgs.mnemosyne-memory
pkgs.mnemosyne-hermes
];
in {
description = "Hermes Dashboard";
after = [ "network-online.target" "wireguard-wg0.service" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment = {
HERMES_HOME = "/var/lib/agents/.hermes";
PYTHONPATH = mnemosynePythonPath;
# Raise WS-orphan reap grace from default 20s → 120s: voice messages
# detach the phone's WS during audio upload/transcription while the
# turn is still staging; 20s was reaping the session mid-message
# ("unfinished message was not found on the server, please resend").
HERMES_TUI_WS_ORPHAN_REAP_GRACE_S = "120";
};
serviceConfig = {
Type = "simple";
User = "harley";
Group = "users";
WorkingDirectory = "/var/lib/agents/nyra";
ExecStart = "${pkgs.hermes-agent}/bin/hermes -p nyra dashboard --host 0.0.0.0 --port 9119 --no-open";
Restart = "on-failure";
RestartSec = 5;
};
};
## SearXNG meta search engine (Port 8888)
services.searx = {
enable = true;
openFirewall = true;
redisCreateLocally = true;
settings = {
general.instance_name = "Utumno Search";
search = {
safe_search = 0;
formats = [ "html" "json" ];
autocomplete = "";
};
server = {
port = 8888;
bind_address = "0.0.0.0";
secret_key = "@SEARXNG_SECRET@";
limiter = false;
method = "POST";
image_proxy = false;
};
ui = {
default_theme = "simple";
theme_args.simple_style = "dark";
query_in_title = false;
};
outgoing = {
request_timeout = 5.0;
max_request_timeout = 15.0;
pool_connections = 100;
pool_maxsize = 20;
enable_http2 = true;
};
};
environmentFile = "/opt/searxng/secrets.env";
};
## Gitea (Port 3010)
services.gitea = {
enable = true;
stateDir = "/storage/8TB/Backups/Gitea";
repositoryRoot = "/storage/8TB/Backups/Gitea/repositories";
database.type = "sqlite3";
settings = {
server = {
DOMAIN = "10.0.1.49";
ROOT_URL = "https://gitea.wingard.pro";
HTTP_PORT = 3010;
SSH_PORT = 222;
DISABLE_SSH = false;
LFS_START_SERVER = false;
};
service = {
# Closed 2026-09-11: CVE-2026-60004 (Gitea < 1.27.1 RCE) only requires
# any account with repo write access, and open registration is how an
# outside attacker gets one. The patched Gitea 1.27.3 is now installed;
# this closes the acquisition path too (defence in depth).
# Revert to false if a second account is ever needed.
DISABLE_REGISTRATION = true;
REQUIRE_SIGNIN_VIEW = false;
};
security.INSTALL_LOCK = true;
};
};
## Home Assistant (port 8123)
services.home-assistant = {
enable = true;
configDir = "/storage/8TB/Backups/homeassistant";
# Python deps for the rest sensor / rest_command integrations
# (jsonpath-python: rest sensor json_path; xmltodict: rest XML).
# zha: Zigbee Home Automation integration (ZBDongle-P V2 on /dev/ttyUSB0).
extraPackages = python3Packages: with python3Packages; [
jsonpath-python
xmltodict
zha
# The HA `zha` integration pulls zha-quirks, and `homeassistant_hardware`
# (imported by zha) pulls universal-silabs-flasher + ha-silabs-firmware-client.
# nixpkgs' zha component mapping omits all three, and the module wraps the
# package's python set in overrideScope, so `with python3Packages` can't
# see overlay-injected packages. Reference them by absolute path.
pkgs.python314Packages.zhaquirks
pkgs.python314Packages.universal-silabs-flasher
pkgs.python314Packages.ha-silabs-firmware-client
];
};
## Shared media group so sabnzbd/sonarr/radarr/jellyfin can all read/write
## the media + download trees (Jellyfin writes .nfo, arrs import, etc.).
users.groups.media = {
members = [ "sabnzbd" "sonarr" "radarr" "jellyfin" ];
};
## hass needs dialout (mode 660 root:dialout) to open the Zigbee USB dongle.
users.users.hass.extraGroups = [ "dialout" ];
## The home-assistant module hardens the unit with DevicePolicy=closed, which
## blocks /dev/ttyUSB* unless a serial-using component is enabled (e.g. zha via
## extraComponents). We add zha via extraPackages, so grant the serial devices
## explicitly or ZHA fails with EPERM opening the dongle.
systemd.services.home-assistant.serviceConfig.DeviceAllow = [ "char-ttyACM rw" "char-ttyAMA rw" "char-ttyUSB rw" ];
## Matter server (controller/commissioner for Matter devices, port 5580)
## Home Assistant's Matter integration connects to this over websocket
## (ws://localhost:5580/ws). Starts BEFORE home-assistant.service.
services.matter-server = {
enable = true;
# opens TCP 5580 in the firewall if the HA web UI needs to reach it remotely
openFirewall = true;
# Override python-matter-server so a single malformed PAA root certificate
# in the CSA DCL (e.g. the NXP Semiconductors NV PAA) is skipped instead of
# crashing the startup certificate fetch and leaving the websocket unbound.
package = pkgs.python3Packages.python-matter-server.overridePythonAttrs (
old: {
patches = (old.patches or []) ++ [ ./matter-server-paa-skip.patch ];
# `overridePythonAttrs` re-derives the package and drops the "server"
# extra (the optional-dependencies), which this override doesn't
# provide. Re-add them explicitly so the runtime closure keeps
# cryptography / chip-core / zeroconf.
dependencies = (old.dependencies or []) ++ [
pkgs.python3Packages.cryptography
pkgs.python3Packages.home-assistant-chip-core
pkgs.python3Packages.zeroconf
];
}
);
};
## Kiwix ZIM server (port 8098)
services.kiwix-serve = {
enable = true;
port = 8098;
openFirewall = true;
library = {
# Update paths when ZIM files are added/removed
wikipedia = "/storage/20TB/Media/Kiwix/wikipedia_en_all_maxi_2026-02.zim";
archlinux = "/storage/20TB/Media/Kiwix/archlinux_en_all_maxi_2025-08.zim";
ifixit = "/storage/20TB/Media/Kiwix/ifixit_en_all_2025-06.zim";
khanacademy = "/storage/20TB/Media/Kiwix/khanacademy_en_all_2023-03.zim";
survivorlibrary = "/storage/20TB/Media/Kiwix/survivorlibrary.com_en_all_2025-06.zim";
urbanprepper = "/storage/20TB/Media/Kiwix/urban-prepper_en_all_2025-06.zim";
anonymousplanet = "/storage/20TB/Media/Kiwix/anonymousplanet.org_en_all_2025-08.zim";
basedcooking = "/storage/20TB/Media/Kiwix/based.cooking_en_all_2025-08.zim";
};
extraArgs = [ "--skipInvalid" ];
};
## OpenSpeedTest (Port 8084)
services.phpfpm.pools.openspeedtest = {
user = "nginx";
group = "nginx";
settings = {
pm = "dynamic";
"pm.max_children" = 5;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 1;
"pm.max_spare_servers" = 3;
"listen" = "/run/phpfpm-openspeedtest.sock";
};
};
## Immich (Port 2283)
#services.immich = {
#enable = true;
#host = "10.10.10.1";
#mediaLocation = "/storage/8TB/Documents/immich";
#port = 2283;
#openFirewall = true;
#database.enable = true;
#redis.enable = true;
#};
## Roku Remote (Port 7777)
## btop-web system monitor (Port 8777)
systemd.services.btop-web = {
description = "btop-style web system monitor";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "harley";
WorkingDirectory = "/opt/btop-web";
ExecStart = "${pkgs.python3}/bin/python server.py";
Restart = "always";
RestartSec = 5;
};
};
systemd.services.roku-remote = {
description = "Roku TV Web Remote";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "harley";
WorkingDirectory = "/opt/roku-remote";
ExecStart = "${pkgs.python3}/bin/python server.py";
Restart = "on-failure";
RestartSec = 5;
};
};
## RSS recency filter proxy (Port 8087)
## General feed truncation for Karakeep: fetches any upstream RSS/Atom feed,
## keeps only items newer than N days (default 30), re-serves trimmed XML.
## Karakeep feed URLs point to http://10.0.1.49:8087/feed?url=<enc>&days=30
## so old backfill never enters the pool. Replaces the old Ollama-only proxy
## (port 8234, ollama-feed-proxy) which was single-feed with a fixed 20-item cap.
systemd.services.rss-filter-proxy = {
description = "RSS recency filter proxy for Karakeep feeds (port 8087)";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = "harley";
WorkingDirectory = "/opt/rss-filter-proxy";
ExecStart = "${pkgs.python3}/bin/python rss_filter_proxy.py";
Restart = "always";
RestartSec = 5;
};
};
## KVM/QEMU virtualization
virtualisation.libvirtd = {
enable = true;
qemu = {
swtpm.enable = true;
runAsRoot = true;
vhostUserPackages = [ pkgs.virtiofsd ];
};
};
## Sunshine
## Restrict Avahi to LAN + loopback so Moonlight/iPad does not resolve utumno.local
## to Docker bridge addresses (172.x.x.x), which makes Sunshine discovery connect to
## an unreachable address.
services.avahi = {
enable = true;
nssmdns4 = true;
openFirewall = true;
allowInterfaces = [ "eno1" "lo" ];
};
services.sunshine = {
enable = true;
autoStart = true;
capSysAdmin = true; # only needed for Wayland -- omit this when using with Xorg
openFirewall = true;
};
## uninput enable for Sunshine.
hardware.uinput.enable = true;
##
##
## 8.1: Docker
##
##
virtualisation.docker = {
enable = true;
# Allow non-root users (harley is in docker group)
enableOnBoot = true;
# Use the overlay2 storage driver (default on modern kernels)
storageDriver = "overlay2";
# Auto-prune unused data
autoPrune.enable = true;
autoPrune.dates = "weekly";
};
## Camofox stealth browser (Port 9377) -- anti-bot/Cloudflare browser fallback.
##
## This replaces a container that was created by hand with `docker run`
## (2026-09-11). That made it runtime drift: no rebuild could reproduce it, and
## its browser-profile state was silently thrown away on every recreate.
##
## The IMAGE IS BUILT LOCALLY from /storage/8TB/Backups/Agents/camofox-browser
## (upstream v1.15.0 + Camoufox 152.0.4-beta.28). It comes from no registry, so
## pull = "never" is deliberate: fail loudly rather than silently fetch some
## same-named image off Docker Hub. Rebuild with the docker build command in the
## `camofox-browser` skill (or vault notes/browser-backends.md).
##
## Ports stay on 0.0.0.0 on purpose: the ESP32 status dashboard probes :9377
## from the LAN. Docker's own iptables chain publishes it, so -- as before --
## no entry is needed in networking.firewall.allowedTCPPorts.
##
## --restart=unless-stopped matches every other Docker workload on this host
## (/opt/*/docker-compose.y*ml) so the container also survives a docker daemon
## restart; the generated systemd unit (docker-camofox-browser.service)
## additionally owns start/stop and boot ordering.
virtualisation.oci-containers = {
backend = "docker";
containers.camofox-browser = {
image = "camofox-browser:152.0.4-x86_64";
pull = "never";
ports = [ "9377:9377" ];
volumes = [ "/storage/8TB/Backups/Agents/camofox-browser/state:/root/.camofox" ];
environment = { CAMOFOX_PORT = "9377"; };
# --rm (the module default) cannot be combined with --restart: docker
# aborts with "conflicting options: cannot specify both --restart and
# --rm". The unit's ExecStopPost already removes the container.
autoRemoveOnStop = false;
extraOptions = [ "--restart=unless-stopped" ];
autoStart = true;
};
};
## The container binds /storage/8TB/Backups/Agents/camofox-browser/state. The generated
## unit name is docker-<container>.service; without a mount dependency an absent pool
## binds a directory on ROOT instead (the 2026-09-13 mount-shadow trap).
##
## 2026-09-16 -- require unlock-8tb.service, NEVER storage-8TB.mount. /storage/8TB is
## deliberately not a `fileSystems` entry, so storage-8TB.mount has no fragment and only
## exists once unlock-8tb.service has mounted the pool -- which is *after* the boot
## transaction is built. A hard Requires= on it therefore created no start job at all and
## systemd silently discarded this unit at every clean boot: no journal line, no failed
## unit, no retry, unit merely absent. unlock-8tb.service HAS a fragment, so requiring it
## is satisfiable at boot and still fails LOUDLY ("Dependency failed") if the pool never
## unlocks. Same pattern as gitea/karakeep/pia-qbit.
systemd.services."docker-camofox-browser".unitConfig.RequiresMountsFor = [ "/storage/8TB" ];
systemd.services."docker-camofox-browser".requires = [ "unlock-8tb.service" ];
systemd.services."docker-camofox-browser".after = [ "unlock-8tb.service" ];
## Firecrawl web-scraper (Port 3002)
systemd.services.firecrawl = {
description = "Firecrawl web scraping stack";
after = [ "docker.service" "network-online.target" ];
wants = [ "docker.service" "network-online.target" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.docker ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/opt/firecrawl";
ExecStart = "${pkgs.docker}/bin/docker compose up -d";
ExecStop = "${pkgs.docker}/bin/docker compose down";
};
};
## Karakeep bookmark/read-later app (Port 3000)
systemd.services.karakeep = {
description = "Karakeep bookmark manager stack (web + meilisearch + chrome)";
after = [ "docker.service" "network-online.target" "unlock-8tb.service" ];
wants = [ "docker.service" "network-online.target" ];
# Explicit hard requirement -- and it must name unlock-8tb.service, never
# storage-8TB.mount: that unit is fragmentless (see the camofox note above) so it
# cannot satisfy a Requires= at boot, and the unit is silently skipped instead.
requires = [ "unlock-8tb.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.docker ];
# Binds /storage/8TB/karakeep/{data,meili_data}. Without this dependency, a boot
# where the 8TB pool is absent makes the bind source resolve to a plain directory
# on the ROOT filesystem and karakeep writes its data there -- the mount-shadow
# trap that filled the 468G root SSD on 2026-09-13.
unitConfig.RequiresMountsFor = [ "/storage/8TB" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/opt/karakeep";
ExecStart = "${pkgs.docker}/bin/docker compose up -d";
ExecStop = "${pkgs.docker}/bin/docker compose down";
};
};
## Joplin Server — self-hosted Joplin sync target (notes, to-dos, attachments).
## Deployed 2026-09-23. Container :22300 published on LOOPBACK only; nginx terminates
## TLS on :8444. Data: /storage/8TB/joplin/postgres (Postgres 16), config in
## /opt/joplin (compose + root-only .env).
systemd.services.joplin = {
description = "Joplin Server stack (server + postgres)";
after = [ "docker.service" "network-online.target" "unlock-8tb.service" ];
wants = [ "docker.service" "network-online.target" ];
# Name unlock-8tb.service, never storage-8TB.mount — see the karakeep note above:
# storage-8TB.mount is fragmentless, so a Requires= on it can never be satisfied
# at boot and the unit is silently skipped rather than failing loudly.
requires = [ "unlock-8tb.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.docker ];
# Binds /storage/8TB/joplin/postgres. Without this dependency, a boot with the pool
# absent makes the bind source resolve onto the ROOT filesystem — the mount-shadow
# trap that filled the 468G root SSD on 2026-09-13.
unitConfig.RequiresMountsFor = [ "/storage/8TB" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/opt/joplin";
ExecStart = "${pkgs.docker}/bin/docker compose up -d";
ExecStop = "${pkgs.docker}/bin/docker compose down";
};
};
## Immich photo server (Port 2283) (Docker compose)
systemd.services.immich = {
description = "Immich photo server stack (server + machine-learning + valkey + postgres)";
after = [ "docker.service" "network-online.target" "unlock-8tb.service" ];
wants = [ "docker.service" "network-online.target" ];
# Name unlock-8tb.service, never storage-8TB.mount -- see the karakeep note above.
# storage-8TB.mount is fragmentless, so a Requires= on it can never be satisfied
# at boot and the unit is silently SKIPPED rather than failing loudly.
requires = [ "unlock-8tb.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.docker ];
# The stack binds /storage/8TB/Immich/{media,Pics,immich-postgres} -- the restored
# photo library, the external-library source, and the database. Without this
# dependency, a boot with the pool absent makes those bind sources resolve onto
# the ROOT filesystem (the mount-shadow trap that filled the 468G root SSD on
# 2026-09-13) and Immich comes up against empty storage with a fresh database.
unitConfig.RequiresMountsFor = [ "/storage/8TB" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/opt/immich";
ExecStart = "${pkgs.docker}/bin/docker compose up -d";
ExecStop = "${pkgs.docker}/bin/docker compose down";
};
};
########## ##########
########## ##########
########## 8.2: ARRS ##########
########## ##########
########## ##########
## PIA VPN + qBittorrent (Port 8088) + SOCKS5 (Port 1080) (Docker compose)
systemd.services.pia-qbit = {
description = "PIA VPN + qBittorrent + SOCKS5 proxy stack";
after = [ "docker.service" "network-online.target" "unlock-8tb.service" ];
wants = [ "docker.service" "network-online.target" ];
# RequiresMountsFor below only produced an ORDERING dep on systemd 261 -- verified live
# 2026-09-13: `systemctl show -p Requires` came back EMPTY while After=storage-8TB.mount
# was present, and `list-dependencies --reverse storage-8TB.mount` listed no dependents.
# So the hard requirement is stated explicitly: an absent pool must FAIL this unit, not
# merely start after it, or the bind silently resolves onto the root SSD again.
#
# 2026-09-16 -- but it must name unlock-8tb.service, NOT storage-8TB.mount. The latter
# has no fragment (see the camofox note above), so this Requires= created no start job
# and the unit was discarded silently at every CLEAN boot: enabled, symlinked from
# multi-user.target.wants, `systemctl --failed` empty, `journalctl -b -u` empty. It
# survived only crash boots, where docker's restart policy resurrected the containers
# and looked identical to the unit having started.
requires = [ "unlock-8tb.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.docker ];
# The stack bind-mounts /storage/8TB/Downloads. Without this dependency systemd
# starts `docker compose up -d` even when the 8TB pool is NOT unlocked/mounted --
# the bind source then silently resolves to a plain directory on the ROOT
# filesystem and torrents fill the 468G root SSD. (Root hit literally 0 bytes
# free on 2026-09-13 this way.) RequiresMountsFor only ORDERS after
# storage-8TB.mount (it does not create a hard dep on systemd 261), so the
# enforcement is the `requires` above; this line keeps the ordering edge for
# the cases where the mount unit does exist.
unitConfig.RequiresMountsFor = [ "/storage/8TB" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
WorkingDirectory = "/opt/pia-qbit";
ExecStart = "${pkgs.docker}/bin/docker compose up -d";
ExecStop = "${pkgs.docker}/bin/docker compose down";
# Give VPN time to establish before declaring "done"
ExecStartPost = "${pkgs.coreutils}/bin/sleep 10";
};
};
## Bitmagnet (Port 3333)
#services.bitmagnet = {
# enable = true;
#openFirewall = true;
#settings = {
#http_server.local_address = ":3333";
#dht_server.port = 3334;
#postgres = {
#name = "bitmagnet";
#user = "bitmagnet";
#password = "";
#};
#};
#};
#services.postgresql = {
#dataDir = "/storage/20TB/Computer-Related/Bitmagnet-data";
#package = pkgs.postgresql_16;
#};
## Jellyfin (Port 8096)
services.jellyfin = {
enable = true;
dataDir = "/storage/8TB/arrs-tmp/jellyfin";
cacheDir = "/storage/8TB/arrs-tmp/jellyfin-cache";
openFirewall = true;
};
## Prowlarr (port 9696)
services.prowlarr = {
enable = true;
dataDir = "/storage/8TB/arrs-tmp/prowlarr";
openFirewall = true;
};
## Sonarr (port 8989)
services.sonarr = {
enable = true;
dataDir = "/storage/8TB/arrs-tmp/sonarr";
openFirewall = true;
};
## Radarr (port 7878)
services.radarr = {
enable = true;
dataDir = "/storage/8TB/arrs-tmp/radarr";
openFirewall = true;
};
## SABnzbd (port 8180)
fileSystems."/var/lib/sabnzbd" = {
device = "/storage/8TB/arrs-tmp/sabnzbd";
options = [ "bind" "noauto" ];
fsType = "none";
};
services.sabnzbd = {
enable = true;
openFirewall = true;
# Sabnzbd must be able to write its own config (quota tracking, web-UI settings).
# Without this the module sets the ini to -m 400 (read-only) and Sabnzbd errors
# 'Cannot write INI file' on any config change.
allowConfigWrite = true;
settings = {
host = "0.0.0.0";
port = 8180;
misc = {
host = "0.0.0.0";
port = 8180;
enable_https = false;
html_login = true;
inet_exposure = 0;
auto_browser = false;
config_lock = false;
check_new_rel = false;
cache_limit = "";
api_key = "41c545585b4a47aa973ad6b713cebec6";
download_dir = "/storage/20TB/Downloads/incomplete";
complete_dir = "/storage/20TB/Downloads/complete";
permissions = "775";
};
# Category folder routing. The arr services send category=movies / category=tv.
categories.movies = {
dir = "/storage/20TB/Downloads/complete/movies";
pp = "3";
script = "Default";
};
categories.tv = {
dir = "/storage/20TB/Downloads/complete/tv";
pp = "3";
script = "Default";
};
# Newshosting usenet provider (SSL, 50 connections)
servers.Newshosting = {
enable = true;
name = "Newshosting";
displayname = "Newshosting";
host = "news.newshosting.com";
port = 563;
connections = 50;
ssl = true;
ssl_verify = "strict";
username = "kednvxhak";
password = "Password1!";
};
};
secretFiles = [ "/storage/8TB/arrs-tmp/sabnzbd/secrets.ini" ];
};
############################################################
## Local realtime voice stack (added 2026-09-17)
##
## Gives hermes-live-voice a fully local `local` provider, so the realtime
## path needs no OpenAI/Gemini key. Two units, in dependency order:
##
## 1. llama-server-local -- the *verbalizer* LLM (Qwen3-4B-Instruct-2507
## Q4_K_M) on :8080, OpenAI-compatible. NOT the agent brain: Hermes
## still produces every answer. The realtime model does turn-taking and
## calls tools (continue_hermes_conversation / start_background_task),
## so a small quantised model is right here, not a compromise.
## --jinja is REQUIRED: without the Jinja chat template llama-server
## cannot emit tool calls at all.
##
## 2. speech-to-speech -- HF pipeline (Silero VAD -> Parakeet TDT STT ->
## llama-server -> Kokoro TTS) speaking OpenAI Realtime GA on
## ws://127.0.0.1:8765/v1/realtime, exactly what the gateway's `local`
## provider expects. Runs headless (`serve`), so no PortAudio or mic is
## needed on the host; the browser is the mic.
##
## VRAM budget on the 8 GB RTX 4060 (measured 2026-09-17):
## desktop ~276 MiB | speech-to-speech ~1990 MiB | llama-server ~2870 MiB
## -> ~1180 MiB free. Context is 4096 with q8_0 KV, and Kokoro is pinned to
## CPU, because the card is the binding constraint and an OOM here shows up
## as silent TTS failure rather than an obvious crash.
##
## LD_LIBRARY_PATH needs BOTH entries:
## /run/opengl-driver/lib -> libcuda.so.1; without it torch reports
## cuda_available=False and everything silently runs on CPU.
## gcc libstdc++ -> the pip/venv wheels link against it. Nix-built binaries
## carry their own RPATH, pip wheels do not, so omitting this breaks the
## venv with "libstdc++.so.6: cannot open shared object file".
##
## Weights live on /storage/8TB, so both units require unlock-8tb.service --
## NEVER storage-8TB.mount, which has no fragment and is simply absent at boot.
## The venv at /home/harley/speech-to-speech is deliberately outside Nix (a
## ~6 GB torch/CUDA resolve), the same pragmatic pattern as /opt/hermes-webui.
## See vault notes/telephony-voice.md.
############################################################
systemd.services.llama-server-local = {
description = "llama.cpp server for local realtime voice (verbalizer LLM)";
wantedBy = [ "multi-user.target" ];
after = [ "unlock-8tb.service" "network.target" ];
requires = [ "unlock-8tb.service" ];
environment.LD_LIBRARY_PATH = "/run/opengl-driver/lib:${pkgs.gcc-unwrapped.lib}/lib";
serviceConfig = {
Type = "simple";
User = "harley";
Group = "users";
ExecStart = ''
${pkgs.llama-cpp-cuda}/bin/llama-server \
--model /storage/8TB/models/gguf/Qwen3-4B-Instruct-2507-Q4_K_M.gguf \
--alias local-qwen \
--host 127.0.0.1 --port 8080 \
-ngl all -np 1 \
-c 4096 -ctk q8_0 -ctv q8_0 \
-fa on \
--jinja
'';
Restart = "on-failure";
RestartSec = 5;
};
};
systemd.services.speech-to-speech = {
description = "Hugging Face speech-to-speech realtime server (local voice pipeline)";
wantedBy = [ "multi-user.target" ];
after = [ "llama-server-local.service" ];
wants = [ "llama-server-local.service" ];
environment = {
LD_LIBRARY_PATH = "/run/opengl-driver/lib:${pkgs.gcc-unwrapped.lib}/lib";
# Fragmentation is what turns "should just fit" into an OOM on a shared card.
PYTORCH_CUDA_ALLOC_CONF = "expandable_segments:True";
};
serviceConfig = {
Type = "simple";
User = "harley";
Group = "users";
WorkingDirectory = "/home/harley/speech-to-speech";
ExecStart = ''
/home/harley/speech-to-speech/.venv/bin/speech-to-speech serve \
--stt parakeet-tdt --parakeet_tdt_device cuda \
--llm_backend responses-api \
--model_name local-qwen \
--responses_api_base_url http://127.0.0.1:8080/v1 \
--responses_api_api_key "none" \
--responses_api_stream \
--tts kokoro --kokoro_voice af_bella --kokoro_lang_code a --kokoro_device cpu \
--enable_live_transcription
'';
Restart = "on-failure";
RestartSec = 10;
};
};
############################################################
## Local agent model -- Qwen3.6-35B-A3B (added 2026-09-24)
##
## A second, much larger local model for agent work, on :11434,
## OpenAI-compatible, alias `local-qwen35`. Where llama-server-local is a 4B
## verbalizer for the realtime voice path, this one is meant to be a real
## worker: 35B total / 3B active MoE, 256 experts with 8 routed + 1 shared
## per token, and 30 of its 40 layers are Gated DeltaNet (linear attention).
## Only the 10 Gated Attention layers carry a context-growing KV cache, which
## is the whole reason a 256K context fits an 8 GB card at all.
##
## Measured in service on this host 2026-09-24 (UD-IQ3_S, 13.7 GB, 8 GB RTX
## 4060, -fa on, i9-13900K, t=16, after --fit sees a free card). "long" is
## after a ~6.7K-token prompt, i.e. what an agent turn actually looks like:
## context KV cache decode short decode long VRAM
## 64K q8_0/q8_0 44.5 43.1 6702
## 128K q8_0/q8_0 44.7 42.9 6718 <-- shipped
## 256K q8_0/q8_0 31.9 27.0 6686
## 256K q8_0/q4_0 42.4 37.4 6674
## 128K is FREE: 44.7/42.9 against 44.5/43.1 at 64K, same VRAM. Only 256K
## costs anything, and quantising just the V cache to q4_0 recovers most of
## that (42.4 short / 37.4 long) at no VRAM cost -- so 256K is a one-line
## change away if the window is ever the binding constraint.
## Earlier standalone llama-cli probes reported 32/18/11 tok/s across these
## three contexts; those were WRONG below 256K and are superseded by this
## table. Measure context cost in service, never with a bare llama-cli.
## Thread count is not the bottleneck (t8 42.3 / t16 45.1 / t24 43.4 / t32 44.3,
## i.e. within run-to-run noise); --fit placement is. Reasoning is left ON -- this
## model has only 3B active params and thinking measurably helps it -- but the
## trace is NOT preserved across turns (--no-reasoning-preserve), which keeps
## prior-turn thinking out of the window on long agent runs.
## All three load without OOM. -c 65536 is the default: it is exactly Hermes'
## minimum context and the fastest of the three. Decode cost scales with
## context, because those 10 attention layers still attend over full history.
##
## NOT enabled at boot, deliberately. The 8 GB card cannot hold this model and
## the voice stack at once (voice = ~1990 MiB speech-to-speech + ~2870 MiB
## verbalizer), so unitConfig.Conflicts below makes them mutually exclusive:
##
## systemctl start llama-server-35b # takes the card from the voice pair
## systemctl start llama-server-local # hands it back
##
## Picking a winner at boot would be nondeterministic, so neither is automatic.
##
## LimitMEMLOCK is required by `-lm mmap+mlock`: the default RLIMIT_MEMLOCK is
## a few tens of KB, and llama.cpp falls back to pageable memory *silently*
## when the lock fails -- no error, just experts paged out under pressure and
## random slow tokens hours later.
############################################################
systemd.services.llama-server-35b = {
description = "llama.cpp server for local Qwen3.6-35B-A3B (agent heavy mode)";
## Ordering is load-bearing, not cosmetic. Conflicts= stops the voice pair,
## but Conflicts alone adds NO ordering -- without After= this unit can start
## while the voice stack is still releasing VRAM, and --fit then samples a
## partly-occupied card and under-allocates. Measured: 16.7 tok/s with a stale
## sample vs 42-46 tok/s when --fit sees the card free. Ordering --fit behind the
## stops is worth 2.7x.
after = [ "unlock-8tb.service" "network.target"
"llama-server-local.service" "speech-to-speech.service" ];
requires = [ "unlock-8tb.service" ];
## 8 GB card: this model and the voice stack cannot both be resident, so the
## switch is explicit and reversible instead of a silent CUDA OOM.
unitConfig.Conflicts = [ "llama-server-local.service" "speech-to-speech.service" ];
environment.LD_LIBRARY_PATH = "/run/opengl-driver/lib:${pkgs.gcc-unwrapped.lib}/lib";
serviceConfig = {
Type = "simple";
User = "harley";
Group = "users";
LimitMEMLOCK = "infinity";
ExecStart = ''
${pkgs.llama-cpp-cuda}/bin/llama-server \
--model /storage/8TB/models/gguf/Qwen3.6-35B-A3B-UD-IQ3_S.gguf \
--alias local-qwen35 \
--host 127.0.0.1 --port 11434 \
-np 1 \
-c 131072 -ctk q8_0 -ctv q8_0 \
-t 16 \
-lm mmap+mlock \
-fa on \
--no-reasoning-preserve \
--jinja
'';
Restart = "on-failure";
RestartSec = 5;
};
};
## RP roleplay model server (2026-09-26): uncensored Gemma-4 26B-A4B served to the
## rp-tavern libvirt guest over virbr0. Measured 14.3 tok/s on CPU at 32K context.
## Deliberately NOT enabled -- ~17 GB resident and only wanted while roleplaying:
## systemctl start llama-server-rp
##
## --device none is load-bearing. With the default device selection llama.cpp
## offloads the mmproj vision projector onto the 4060 (~1.8 GB) even though
## -ngl 0 keeps every weight on CPU. Measured 2026-09-26: 3237 MiB used with the
## projector on the card vs 1436 MiB (desktop only) with --device none. This keeps
## vision working AND leaves the GPU untouched.
##
## --chat-template-kwargs disables thinking. Without it Gemma-4 spends the whole
## token budget in the reasoning channel and returns empty content -- verified.
systemd.services.llama-server-rp = {
description = "llama.cpp server for the RP guest (uncensored Gemma-4 26B-A4B, CPU)";
after = [ "unlock-8tb.service" "network.target" "libvirtd.service" ];
requires = [ "unlock-8tb.service" ];
serviceConfig = {
Type = "simple";
User = "harley";
Group = "users";
LimitMEMLOCK = "infinity";
ExecStart = ''
${pkgs.llama-cpp}/bin/llama-server \
--model /storage/8TB/models/gguf/rp/Gemma4-26B-A4B-QAT-Uncensored-HauhauCS-Balanced-Q4_K_M.gguf \
--mmproj /storage/8TB/models/gguf/rp/mmproj-Gemma4-26B-A4B-QAT-Uncensored-HauhauCS-Balanced-BF16.gguf \
--alias rp \
--host 192.168.122.1 --port 11434 \
-np 1 \
-c 32768 -ctk q8_0 -ctv q4_0 \
-t 16 \
--device none \
--jinja \
--chat-template-kwargs '{"enable_thinking": false}'
'';
Restart = "on-failure";
RestartSec = 5;
};
};
############################################################
## ##
## 9: Customization ##
## ##
############################################################
##
##
## 9.1: Keyd keyboard remappings
##
##
services.keyd = {
enable = true;
keyboards = {
default = {
ids = [ "*" ];
settings = {
main = {
esc = "capslock";
capslock = "overloadt(nav, esc, 200)";
};
nav = {
w = "up";
a = "left";
s = "down";
d = "right";
b = "C-left";
u = "pageup";
x = "delete";
g = "home";
n = "end";
h = "left";
j = "down";
k = "up";
l = "right";
space = "capslock";
grave = "f14";
"1" = "f15";
};
};
};
q = {
ids = [ "*" ];
settings = {
main = {
esc = "capslock";
capslock = "overloadt(nav, esc, 200)";
};
nav = {
w = "up";
a = "left";
s = "down";
d = "right";
h = "left";
j = "down";
k = "up";
l = "right";
space = "capslock";
grave = "f14";
"1" = "f15";
};
};
};
};
};
##
##
## 9.2 Enviroment Variables
##
##
environment.variables = {
GTK_THEME = "Adwaita:dark";
GTK_ICON_THEME = "Tela-circle-dark";
HERMES_HOME = "/var/lib/agents/.hermes";
};
## ZSH with Oh-My-Zsh
programs.zsh.enable = true;
programs.zsh.ohMyZsh = {
enable = true;
theme = "jonathan";
plugins = [
"git"
"sudo"
"web-search"
"history"
"extract"
"colored-man-pages"
];
customPkgs = [
(pkgs.runCommand "jonathan-zsh-theme" {
themeFile = builtins.readFile ./jonathan.zsh-theme;
passAsFile = [ "themeFile" ];
} ''
mkdir -p $out/share/zsh/themes
cp "$themeFilePath" $out/share/zsh/themes/jonathan.zsh-theme
'')
];
};
programs.zsh.syntaxHighlighting.enable = true;
programs.zsh.interactiveShellInit = ''
source ${pkgs.zsh-autosuggestions}/share/zsh-autosuggestions/zsh-autosuggestions.zsh
'';
programs.zsh.promptInit = ''
bindkey '^F' autosuggest-accept
bindkey '^[[C' autosuggest-accept
'';
programs.zsh.shellAliases = {
cl = "clear";
tt = "tmux";
tta = "tmux attach";
ttls = "tmux ls";
xclipp = "xclip -selection clipboard";
palantir = "ssh palantir";
rcp = "rsync -avP --partial --append";
};
# Make theme packages available to all users
environment.sessionVariables = {
XDG_DATA_DIRS = [
"${pkgs.tela-circle-icon-theme}/share/icons"
"${pkgs.catppuccin}/share/plasma"
];
};
############################################################
## ##
## ##
## ##
############################################################
## TV Power Toggle key listener
## Caps+grave -> F14 (via keyd) -> toggle TV power
systemd.user.services.tv-power-listener = {
description = "TV Power Toggle Key Listener";
wantedBy = [ "default.target" ];
after = [ "keyd.service" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.writeShellScriptBin "tv-power-listener" ''
#!${pkgs.bash}/bin/bash
exec ${pkgs.python312}/bin/python3 ${./tv-power-listener.py}
''}/bin/tv-power-listener";
Environment = [
"PYTHONPATH=${pkgs.python312Packages.evdev}/lib/python3.12/site-packages"
"PATH=${pkgs.bash}/bin:/run/current-system/sw/bin"
];
Restart = "always";
RestartSec = 5;
};
};
############################################################
## ##
## Headless Chromium, CDP on 127.0.0.1:9222 ##
## ##
## Browser backend for the agent: browser_exec (Browser ##
## Use CLI) and the Hermes browser tools attach here via ##
## browser.cdp_url. Loopback-only by design -- nothing ##
## new is exposed to the LAN. The Camofox container on ##
## 9377 remains the stealth fallback for anti-bot pages. ##
## ##
## --remote-allow-origins=* is required because Chrome ##
## >=111 rejects CDP WebSocket upgrades that carry an ##
## Origin header; harmless while bound to loopback. ##
## ##
## Profile persists logins in ~/.local/share/chromium-cdp ##
############################################################
systemd.user.services.chromium-cdp = {
description = "Headless Chromium (CDP on 127.0.0.1:9222)";
wantedBy = [ "default.target" ];
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.chromium}/bin/chromium"
+ " --headless"
+ " --remote-debugging-address=127.0.0.1"
+ " --remote-debugging-port=9222"
+ " --remote-allow-origins=*"
+ " --user-data-dir=%h/.local/share/chromium-cdp"
+ " --no-first-run --no-default-browser-check"
+ " --disable-background-timer-throttling"
+ " --disable-renderer-backgrounding"
+ " --disable-backgrounding-occluded-windows"
+ " --window-size=1440,900";
Restart = "always";
RestartSec = 3;
};
};
## ── State version ────────────────────────────────────────
system.stateVersion = "26.11";
}