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.
2569 lines
107 KiB
Nix
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";
|
|
}
|