Compare commits

..
16 Commits
Author SHA1 Message Date
darmanandClaude Sonnet 5 713d91d5fc terra: finish removing Hermes Agent (module import + secrets)
Follow-up to e5ba1bf — that commit only staged the deleted module file.
Drops the flake module import, opencode_go_api_key/telegram_bot_token
secrets, and the stale hermes-agent.nix cross-reference in ollama's
context_length comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:49:50 +02:00
darmanandClaude Sonnet 5 e5ba1bfc55 terra: remove Hermes Agent
Consolidating on jupiter's isolated instance (hosts/jupiter/hermes-agent.nix)
instead of running a second one here. Drops the module import, its
opencode_go_api_key/telegram_bot_token secrets, and the now-stale
cross-reference in ollama's context_length comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:49:38 +02:00
darmanandClaude Sonnet 5 b5fa599671 jupiter: add isolated Hermes Agent instance
A separate instance from terra's, deliberately locked down harder given
jupiter's much bigger blast radius (irreplaceable immich photos on an
unredundant RAID0, gitea/CI tokens, the whole media stack): its own
dedicated "hermes" system user rather than darman (who is in jupiter's
root-equivalent docker group), container.enable = true for whole-process
containment rather than native/bare-metal, its own Telegram bot + explicit
allowlist, and no volume access to /mnt/data or this repo. stateDir/
workingDirectory live on the array (off the 29G eMMC) for future coding-task
state, guarded by RequiresMountsFor like the rest of jupiter's array-backed
services.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 22:46:32 +02:00
darmanandClaude Sonnet 5 0fa567245a jupiter: GC on every boot, silence mdadm warning, migrate sabnzbd off reused ini
nix.gc (common.nix) is weekly, too slow to catch a switch pinning the old
generation's closure on a 29G eMMC — add a full nix-collect-garbage on every
boot instead. Also set boot.swraid.mdadmConf so eval stops warning that
mdmon will crash (dormant here: the RAID0 array uses native superblocks, so
mdmon never actually runs).

sabnzbd.configFile is deprecated by the module; move to services.sabnzbd.settings
with credentials (web login, api/nzb keys, eweka.nl server) sourced from sops via
secretValues instead of living in a plaintext ini. admin_dir/log_dir are pinned
absolute at their original /mnt/data location so the existing download
queue/history isn't reset by the ini moving to /var/lib/sabnzbd.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:58:40 +02:00
darmanandClaude Sonnet 5 969bd69d8d terra: add Hermes Agent, wired to local ollama
Points Nous Research's Hermes Agent at terra's own ROCm ollama server
(gemma4:12b) as a custom OpenAI-compatible provider instead of a cloud
key. Native systemd mode via the hermes-agent flake's own NixOS module
— simpler than container mode, avoids the podman-rootful-sudo dance
its docs call out.

Also bumps OLLAMA_CONTEXT_LENGTH (and Hermes' mirrored model.context_length)
from ollama's ~4k default to 131072, load-tested with real multi-ten-
thousand-token prompts rather than just idle `ollama ps` checks — chosen
as the practical ceiling where VRAM headroom and prefill throughput both
start visibly degrading, not just the largest number that technically fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:28:34 +02:00
darmanandClaude Sonnet 5 6c8046bac8 jupiter: VAAPI hardware transcoding for jellyfin, move heavy state off the eMMC
Enables hardware.graphics + intel-media-driver for the Apollo Lake's
Gen9 iGPU (VAAPI only — QSV needs an insecure/EOL runtime on this
chip) and adds jellyfin's service user to video/render for the DRI
card node. 4K HDR still can't be tone-mapped on this hardware; those
files need to direct-play or be kept as 1080p SDR.

Also relocates podman's container storage and immich's postgres
cluster to /mnt/data/AppData, after a deploy holding two ~9G closures
at once filled the 29G eMMC and postgres died mid-write.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:22 +02:00
darmanandClaude Sonnet 5 914a7e5105 terra: drop kicad flatpak
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:15 +02:00
darmanandClaude Sonnet 5 f09ba07b63 docs: warn against printing decrypted sops secrets
Running sops --decrypt/edit_secrets --show and displaying the result
puts every plaintext secret in the file wherever that output lands,
not just the one value being checked. Point at `sops --set` instead
for adding/changing a single value non-interactively.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:12 +02:00
darmanandClaude Sonnet 5 7904433d34 terra: ollama (ROCm) + LibreChat with local persistent memory
Local LLM server on the 6800 XT (ollama-rocm, gfx1030 needs no
HSA_OVERRIDE_GFX_VERSION) fronted by a LibreChat web UI, talking to it
over the OpenAI-compatible /v1 route. Also wires up LibreChat's
persistent-memory feature, which needed its own agent+model plus a
custom extraction prompt: the default 3b model couldn't reliably tell
the user's stated facts apart from its own boilerplate, and even a
tuned prompt didn't fix that — so memory extraction now reuses
gemma4:12b, the same model as the daily-driver chat endpoint.

flake.lock bump pulls in the ollama and librechat NixOS modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:38:07 +02:00
darmanandClaude Opus 5 0ec3c6413c jupiter: keep booting when the data array is missing
After the rack move one of the RAID0 disks failed to enumerate, and jupiter
boot-looped into an emergency shell nobody could use — root is locked, so
sulogin offers a prompt with no answer, and there is no ssh from there:

  Timed out waiting for device /dev/disk/by-uuid/dadbff6f-...
  Dependency failed for /mnt/data.
  Dependency failed for /var/lib/private/prowlarr.
  Dependency failed for Local File Systems.
  local-fs.target: Job local-fs.target/start failed with result 'dependency'
  Reached target Emergency Mode.

`nofail` on /mnt/data did not help, because the prowlarr and seerr bind
mounts layered on top of it had none: without it a mount is RequiredBy
local-fs.target, so those two failed the target on the array's behalf. Give
them `nofail` too and let them fail alone. `systemd.enableEmergencyMode =
false` then keeps a bad array from costing a reachable box at all — far more
useful on a headless host than a console prompt.

Booting further is only safe if nothing quietly relocates onto the 29G eMMC,
so pin the array-backed services to the mount. systemd derives
RequiresMountsFor from a unit's own paths, which for these is somewhere under
/var/lib (eMMC) — nothing pointed immich at mediaLocation or sabnzbd at its
configFile, so with the array gone they would have started and written to the
OS disk, into directories that go invisible the moment /mnt/data mounts over
them. jellyfin, sonarr, radarr and gitea already had a real dependency and
are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:42 +02:00
darmanandClaude Opus 5 5a4588c532 gitea: provision a ci-bot account with repo + branch-protection access
Workflows push as a dedicated ci-bot account rather than a human one, so its
PAT can be scoped, rotated and revoked on its own. Adding a repo to
`ciBotRepos` and redeploying is all it takes to grant access.

Collaborator access and branch-protection push-whitelisting exist only on
gitea's HTTP API — no CLI, no config-file surface — so this one part stays
imperative: a oneshot that PUT/PATCHes the API into the desired state. It
runs on deploys where the script changed, which means it won't self-heal a
revert done through the web UI unless the unit is restarted too.

Two secrets, deliberately distinct:
- gitea_provisioning_token is darman's own token (write:repository +
  write:user). Only an owner-scoped token clears reqOwnerCheck on the
  collaborator and branch-protection endpoints, and write:user is what lets
  it write the Actions secret below. ci-bot cannot grant itself access.
- gitea_ci_bot_token is ci-bot's push token, generated once by hand (the
  command is in the comment) and pushed into gitea as a user-level Actions
  secret CI_BOT_TOKEN. Gitea has no instance-wide secret scope, and every
  repo here is owned by darman directly rather than an org, so a user-level
  secret is the closest thing — repo-level lookups fall back to it.

Branch protection is applied to the default branch plus `develop`, since
version-bump.yml pushes there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:28 +02:00
darmanandClaude Opus 5 c17524e358 terra: hyprchrome glow/shadow, borderless windows, warmer accents
Now that the plugin draws its own outline and glow, hyprland's own border is
redundant — border_size 0 and let hyprchrome own the window edge (outline_size
2 in fg_color). Fill in the rest of its knobs: glow 12/0.85, shadow 24 offset
{4,8} in bg_color.

Accents go warm: bg_accent to a muted red (963c38) and a new fg_accent_alt
(ff9d42) so the active-border gradient runs amber->orange instead of
amber->background. Colour literals lowercased for consistency.

Also re-pick the placeholder wallpaper, and drop the comment explaining
hyprland's lua gradient table format — the surviving call site is now the
only one and reads plainly enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:16 +02:00
darmanandClaude Opus 5 2abd842e97 terra: tome, rootless podman for GPU containers, direnv, kicad
- install tome from pkgs/tome.nix, built against the re-added flake input
- import services/containers.nix and put darman in `render`/`video`:
  /dev/dri/renderD128 is root:render 0660, so a rootless container can only
  reach the GPU if the host user is in the group. Needed by the Vulkan
  whisper.cpp/llama.cpp containers in content-trigger-scanner.
- point DOCKER_HOST at the podman *user* socket and add docker-compose.
  dockerCompat gives a `docker` CLI shim, but compose v2 is its own binary
  talking to a socket, and rootless podman's socket is the user one under
  /run/user/1000 — not root's /var/run/docker.sock.
- direnv + nix-direnv, so per-repo devShells load in the shell and in Rider
  via its direnv plugin, instead of hand-wiring a toolbox SDK per repo
- kicad as a flatpak, alongside the other flatpak desktop apps

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:32:06 +02:00
darmanandClaude Opus 5 d5507811ea tome: aspnetcore runtime, gapps wrapping, glib at runtime
Three separate reasons the packaged build didn't behave like the one Rider
launches:

- dotnet-runtime must be aspnetcore_10_0, not runtime_10_0. Tome.App's
  runtimeconfig.json requires Microsoft.AspNetCore.App as well as
  Microsoft.NETCore.App, because Photino hosts a local Kestrel server, and
  only the aspnetcore bundle ships it.
- wrapGAppsHook3, so gappsWrapperArgs get spliced into buildDotnetModule's
  own wrap step (it sets dontWrapGApps itself; same pattern as nixpkgs'
  libation). Without it nothing sets XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR, so
  GTK/WebKitGTK found neither the icon theme nor GTK settings — missing icons
  and a denser default font than in an already-initialized session.
- glib in runtimeDeps. It doesn't arrive via gtk3/webkitgtk's RPATH because
  the consumer is Photino.Native.so, a prebuilt binary out of the nuget
  package rather than something Nix built and patched.

tome-deps.json is the regenerated nuget lock for the aspnetcore switch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:56 +02:00
darmanandClaude Opus 5 f431e81ce6 tailscale: drop the 1.98.9 vendorHash override
c5a231b pinned the hash by hand because nixpkgs bumped 1.98.8->1.98.9 without
updating vendorHash (NixOS/nixpkgs#545860). The previous commit's lock moves
nixos-26.05 past the point where that fix was promoted from release-26.05, so
the override is now dead weight — and a stale vendorHash override is worse
than none, since it silently wins over a correct upstream value on the next
version bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:46 +02:00
darmanandClaude Opus 5 a172e49c7e flake: re-add the tome input, refresh locked inputs
Tome (formerly AudibleLibrary) comes back as a git+ssh input against our own
gitea, fetched with darman's ambient key. `flake = false` — it's a plain
source tree consumed by pkgs/tome.nix, not a flake of its own.

This re-breaks `./scripts/deploy install terra localhost` exactly the way
4f79ec7 removed it for: the installer-iso has no credentials, so the git+ssh
fetch fails at nixos-install, post-disko. Taking that tradeoff knowingly
rather than losing the app from the desktop config again — the note in
flake.nix spells it out for whoever hits it next.

The lock also picks up the routine input refresh, including the nixos-26.05
rev that finally carries the tailscale vendorHash fix (next commit) and a new
client-ts-generator-src node pulled in by authentik-nix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:31:37 +02:00
25 changed files with 973 additions and 121 deletions
+6
View File
@@ -59,6 +59,12 @@ Secrets (needs the admin age key at `~/.config/sops/age/keys.txt`):
./scripts/edit_secrets secrets/<host>.yaml ./scripts/edit_secrets secrets/<host>.yaml
``` ```
**Claude: never run `sops --decrypt`/`edit_secrets --show` and print the result — that
puts every plaintext secret in the file into the conversation transcript, not just the
one you wanted.** To add or change a single value non-interactively, use
`sops --set '["key"] "value"' secrets/<host>.yaml` (quote the value as JSON), which
writes without ever displaying the file's existing contents.
Test a service config BEFORE touching hardware — always do this for nontrivial changes: Test a service config BEFORE touching hardware — always do this for nontrivial changes:
``` ```
# x86 QEMU VM of mercury's DNS/DHCP stack (fast; validates pihole/unbound at runtime) # x86 QEMU VM of mercury's DNS/DHCP stack (fast; validates pihole/unbound at runtime)
Generated
+237 -46
View File
@@ -3,6 +3,7 @@
"authentik-nix": { "authentik-nix": {
"inputs": { "inputs": {
"authentik-src": "authentik-src", "authentik-src": "authentik-src",
"client-ts-generator-src": "client-ts-generator-src",
"flake-compat": "flake-compat", "flake-compat": "flake-compat",
"flake-parts": "flake-parts", "flake-parts": "flake-parts",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
@@ -13,11 +14,11 @@
"uv2nix": "uv2nix" "uv2nix": "uv2nix"
}, },
"locked": { "locked": {
"lastModified": 1785159467, "lastModified": 1786986906,
"narHash": "sha256-1LwjkVJK5pst6Atm3uOiZW3eBMY3gs0ICO14eFGlpGw=", "narHash": "sha256-DJ1oU9szQJNdEM0dysh4NnKOB1HwOKtNukrUYKpawVs=",
"owner": "nix-community", "owner": "nix-community",
"repo": "authentik-nix", "repo": "authentik-nix",
"rev": "a492deb563cf54cb8fe134e94a7ba3b01820ca5e", "rev": "afdb2eeca1e0b38fabb93c4a8944be73d3581268",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -43,6 +44,22 @@
"type": "github" "type": "github"
} }
}, },
"client-ts-generator-src": {
"flake": false,
"locked": {
"lastModified": 1784638510,
"narHash": "sha256-NfwEWQ/SRjgeUz+F/7uoWAMwk7OqdF2+686krhvJn2M=",
"owner": "goauthentik",
"repo": "client-ts",
"rev": "5850af5867bef6fd4291731797d21b704c7f189d",
"type": "github"
},
"original": {
"owner": "goauthentik",
"repo": "client-ts",
"type": "github"
}
},
"disko": { "disko": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -83,6 +100,27 @@
"inputs": { "inputs": {
"nixpkgs-lib": "nixpkgs-lib" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": {
"lastModified": 1785627969,
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"hermes-agent",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1782949081, "lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
@@ -118,6 +156,29 @@
"type": "github" "type": "github"
} }
}, },
"hermes-agent": {
"inputs": {
"flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs_2",
"npm-lockfile-fix": "npm-lockfile-fix",
"pyproject-build-systems": "pyproject-build-systems_2",
"pyproject-nix": "pyproject-nix_2",
"uv2nix": "uv2nix_2"
},
"locked": {
"lastModified": 1787007062,
"narHash": "sha256-C1SJX0VqlxrLknGhFKj7+DKpfJ1eI4udPE67kW+dT+M=",
"owner": "NousResearch",
"repo": "hermes-agent",
"rev": "55e34fb7d0cd8fd1e16be82a1ceafee05fca63d3",
"type": "github"
},
"original": {
"owner": "NousResearch",
"repo": "hermes-agent",
"type": "github"
}
},
"home-manager": { "home-manager": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -125,11 +186,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785119570, "lastModified": 1786924861,
"narHash": "sha256-Rgs2xKnGLFWQscxUaXX07oyZeuMDOHEbqDOsgliLFGM=", "narHash": "sha256-hftabkb+73OcGzvwFAjCiQorAhprs9TnU1+FkGO5CIw=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "d4fd24667c8cbef124bb70a20380cab75ec8474d", "rev": "09ae1b85a6db412d841d60f924b23f881f0d0a38",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -146,11 +207,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785267577, "lastModified": 1785852009,
"narHash": "sha256-PoNXV5H5BbncwyO0C6Dar6C49GSf3UGDRRMpuArX8y4=", "narHash": "sha256-EgIk8Enyhiqa5J326BDNgujeU+lEbYxZzo59rXKWr4Q=",
"ref": "refs/heads/develop", "ref": "refs/heads/develop",
"rev": "73ce9ddbdf27d21c87322bcd638c1efe08be6827", "rev": "dcec9d205e4f8fbe18f71260a0614dd0187f4204",
"revCount": 4, "revCount": 33,
"type": "git", "type": "git",
"url": "https://git.mgaction.town/darman/hypr-chrome.git" "url": "https://git.mgaction.town/darman/hypr-chrome.git"
}, },
@@ -181,9 +242,9 @@
"nixpkgs": [ "nixpkgs": [
"nixpkgs" "nixpkgs"
], ],
"pyproject-build-systems": "pyproject-build-systems_2", "pyproject-build-systems": "pyproject-build-systems_3",
"pyproject-nix": "pyproject-nix_2", "pyproject-nix": "pyproject-nix_3",
"uv2nix": "uv2nix_2" "uv2nix": "uv2nix_3"
}, },
"locked": { "locked": {
"lastModified": 1768002052, "lastModified": 1768002052,
@@ -253,11 +314,11 @@
"treefmt-nix": "treefmt-nix" "treefmt-nix": "treefmt-nix"
}, },
"locked": { "locked": {
"lastModified": 1784704708, "lastModified": 1786520020,
"narHash": "sha256-xuzg2Xfalr1X5ah5IPgqB8Vyif+2je1qkCtGxCB+rm0=", "narHash": "sha256-9b0hAKM8UtNsm4lD3aySuXPjPpSibh6yfYEcNvr4fU0=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixos-anywhere", "repo": "nixos-anywhere",
"rev": "91fc9b70fc295258c366cce8627efb6f185fd9fb", "rev": "4cf3b82df8422f82657ae98c3b8374a5fd74f9a1",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -274,11 +335,11 @@
"nixos-unstable": "nixos-unstable" "nixos-unstable": "nixos-unstable"
}, },
"locked": { "locked": {
"lastModified": 1784802994, "lastModified": 1786617555,
"narHash": "sha256-4PcD0Ibzdkh85G+70w5dLlR9YgQ2bmNIjiPPMSzO57w=", "narHash": "sha256-r3brKkBWm34wltszIeoZw2VCFUEoS2nNAe3W2Zgzc9k=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixos-images", "repo": "nixos-images",
"rev": "6ece16b0c97986fe085122e796044add4cc3ff64", "rev": "f01878e82602ad3d0a2e74a14440521924e57bc5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -306,11 +367,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1785090369, "lastModified": 1786862985,
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=", "narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "624af665418d3c65d544145b4d34ad696439570e", "rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -322,11 +383,11 @@
}, },
"nixpkgs-lib": { "nixpkgs-lib": {
"locked": { "locked": {
"lastModified": 1782614948, "lastModified": 1785031560,
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
"owner": "nix-community", "owner": "nix-community",
"repo": "nixpkgs.lib", "repo": "nixpkgs.lib",
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -337,11 +398,11 @@
}, },
"nixpkgs-unstable": { "nixpkgs-unstable": {
"locked": { "locked": {
"lastModified": 1785141334, "lastModified": 1786963906,
"narHash": "sha256-kh35kIx7el4Jk8Ki3BH9/Pn1eZYSYLJ6LMALos0zOy0=", "narHash": "sha256-3tkeMWSvHPo3tYljfXbPC/TgknikU1GvdVr/DkdfvE0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "38a4887411571457d700c51c64a6e49ead2ed5ab", "rev": "f4b6996c4e8b9ee06ce147ec344c885f51071b14",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -353,11 +414,27 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1785133411, "lastModified": 1785318670,
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=", "narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3", "rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1786943417,
"narHash": "sha256-b4qgjdFtlz5TAZ1Hi7DFJeqX3sjaDUnrwr9OO+O1rM0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0dd31db7e6dbf9ce05697c4545f6fe01accec994",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -367,6 +444,27 @@
"type": "github" "type": "github"
} }
}, },
"npm-lockfile-fix": {
"inputs": {
"nixpkgs": [
"hermes-agent",
"nixpkgs"
]
},
"locked": {
"lastModified": 1775903712,
"narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=",
"owner": "jeslie0",
"repo": "npm-lockfile-fix",
"rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8",
"type": "github"
},
"original": {
"owner": "jeslie0",
"repo": "npm-lockfile-fix",
"type": "github"
}
},
"pyproject-build-systems": { "pyproject-build-systems": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -383,11 +481,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1782093830, "lastModified": 1785730568,
"narHash": "sha256-6gmEVe69+KlRkZD4PEEV5xAlB9CB0Y9TiuEgQjDrKTQ=", "narHash": "sha256-NjSPsgjJ7MSpBtTkUcmNhRe6AFZ96+zsca2M8YuQi8Y=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "build-system-pkgs", "repo": "build-system-pkgs",
"rev": "430680a19bc85a3bda55f12e4cc1a1aadcf2e478", "rev": "90fde00db3687922d39d95fc591475fd0bbbcd72",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -397,6 +495,35 @@
} }
}, },
"pyproject-build-systems_2": { "pyproject-build-systems_2": {
"inputs": {
"nixpkgs": [
"hermes-agent",
"nixpkgs"
],
"pyproject-nix": [
"hermes-agent",
"pyproject-nix"
],
"uv2nix": [
"hermes-agent",
"uv2nix"
]
},
"locked": {
"lastModified": 1785115949,
"narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "62c0d86027edb1c4f39a5facc09876348144f7c9",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"pyproject-build-systems_3": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"mediamanager-nix", "mediamanager-nix",
@@ -432,6 +559,27 @@
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": {
"lastModified": 1786031528,
"narHash": "sha256-cROiHKO3UbIKqF5FG5NikvydzlfIj4EcR1Cty9qOVt4=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "1b1485546d85f6f6c7aadb10c4923dbc09633263",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"pyproject-nix_2": {
"inputs": {
"nixpkgs": [
"hermes-agent",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1784591072, "lastModified": 1784591072,
"narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=", "narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=",
@@ -446,7 +594,7 @@
"type": "github" "type": "github"
} }
}, },
"pyproject-nix_2": { "pyproject-nix_3": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"mediamanager-nix", "mediamanager-nix",
@@ -471,15 +619,17 @@
"inputs": { "inputs": {
"authentik-nix": "authentik-nix", "authentik-nix": "authentik-nix",
"disko": "disko", "disko": "disko",
"hermes-agent": "hermes-agent",
"home-manager": "home-manager", "home-manager": "home-manager",
"hypr-chrome": "hypr-chrome", "hypr-chrome": "hypr-chrome",
"mediamanager-nix": "mediamanager-nix", "mediamanager-nix": "mediamanager-nix",
"nix-flatpak": "nix-flatpak", "nix-flatpak": "nix-flatpak",
"nixos-anywhere": "nixos-anywhere", "nixos-anywhere": "nixos-anywhere",
"nixos-images": "nixos-images", "nixos-images": "nixos-images",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_3",
"nixpkgs-unstable": "nixpkgs-unstable", "nixpkgs-unstable": "nixpkgs-unstable",
"sops-nix": "sops-nix" "sops-nix": "sops-nix",
"tome": "tome"
} }
}, },
"sops-nix": { "sops-nix": {
@@ -489,11 +639,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1783174389, "lastModified": 1786629091,
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=", "narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=",
"owner": "Mic92", "owner": "Mic92",
"repo": "sops-nix", "repo": "sops-nix",
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", "rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -517,6 +667,22 @@
"type": "github" "type": "github"
} }
}, },
"tome": {
"flake": false,
"locked": {
"lastModified": 1785431655,
"narHash": "sha256-EoM4HmJb7MZArMoP4y1b7DZcvM8iANYjMiE5gGUx070=",
"ref": "refs/heads/master",
"rev": "4f3ca447cdc967371adaf71b26cf283952e54212",
"revCount": 51,
"type": "git",
"url": "ssh://gitea@git.mgaction.town:2222/darman/TOME.git"
},
"original": {
"type": "git",
"url": "ssh://gitea@git.mgaction.town:2222/darman/TOME.git"
}
},
"treefmt-nix": { "treefmt-nix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -525,11 +691,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1784369104, "lastModified": 1785945821,
"narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", "narHash": "sha256-NLSyTCW4K4ofhNBllt3omPasm6QpralXH1DBZOc91Dw=",
"owner": "numtide", "owner": "numtide",
"repo": "treefmt-nix", "repo": "treefmt-nix",
"rev": "df3c0640565d04a0261253cdd89fce78ec50168a", "rev": "ae7910970dddc408fe6ab1c8e4b277bb21d72dc0",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -550,11 +716,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1785147819, "lastModified": 1786615403,
"narHash": "sha256-udwe836AZe8BYU+YqVc51QLuOeWKoGEmPIMF0ImUfTQ=", "narHash": "sha256-U++y7nM/6xiEcWI7q4fQoPZjPvRaTwkSqzBOVoEBjUE=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "uv2nix", "repo": "uv2nix",
"rev": "0983f54345137c2dd528ce2c28cb56c2cbdceb2a", "rev": "4b59abb2ae1896d2a0e1abfc47fbc9bf985ea730",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -564,6 +730,31 @@
} }
}, },
"uv2nix_2": { "uv2nix_2": {
"inputs": {
"nixpkgs": [
"hermes-agent",
"nixpkgs"
],
"pyproject-nix": [
"hermes-agent",
"pyproject-nix"
]
},
"locked": {
"lastModified": 1785277507,
"narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
},
"uv2nix_3": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
"mediamanager-nix", "mediamanager-nix",
+19 -4
View File
@@ -33,6 +33,11 @@
}; };
authentik-nix.url = "github:nix-community/authentik-nix"; authentik-nix.url = "github:nix-community/authentik-nix";
nix-flatpak.url = "github:gmodena/nix-flatpak"; nix-flatpak.url = "github:gmodena/nix-flatpak";
# Hermes Agent (see hosts/jupiter/hermes-agent.nix) — Tier 2 platform
# per its own docs (best-effort Nix support, can break on any upstream
# commit), so pinned like everything else via flake.lock rather than
# followed loosely.
hermes-agent.url = "github:NousResearch/hermes-agent";
# Own Hyprland plugin (border + title bar), public repo, fetched over # Own Hyprland plugin (border + title bar), public repo, fetched over
# https (no credentials needed, unlike tome below). `nixpkgs.follows` is # https (no credentials needed, unlike tome below). `nixpkgs.follows` is
# what makes its packaged build ABI-correct — Hyprland plugins are # what makes its packaged build ABI-correct — Hyprland plugins are
@@ -43,10 +48,19 @@
url = "git+https://git.mgaction.town/darman/hypr-chrome.git"; url = "git+https://git.mgaction.town/darman/hypr-chrome.git";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# NOTE: the `tome` input (private repo git+ssh://gitea@git.mgaction.town) was # Tome (formerly AudibleLibrary) — darman's own .NET/Photino desktop app.
# removed temporarily — the credential-less installer-iso can't fetch it, so # Private repo on our own gitea; fetched over ssh with darman's ambient key,
# `install terra localhost` failed at nixos-install (post-disko). Re-add it # same as any other git flake input. `flake = false`: it's a plain source
# (input + packages.tome + desktop-apps.nix) once the installer can auth. # tree, not itself a flake. See pkgs/tome.nix.
#
# NOTE: the credential-less installer-iso can't fetch this (git+ssh needs
# darman's key), so `./scripts/deploy install terra localhost` will fail
# at nixos-install (post-disko) while this input is present. Known
# tradeoff — re-removed this once before (4f79ec7) for the same reason.
tome = {
url = "git+ssh://gitea@git.mgaction.town:2222/darman/TOME.git";
flake = false;
};
}; };
outputs = { self, nixpkgs, disko, nixos-anywhere, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs: outputs = { self, nixpkgs, disko, nixos-anywhere, sops-nix, nixos-images, home-manager, mediamanager-nix, authentik-nix, ... }@inputs:
@@ -71,6 +85,7 @@
modules = [ modules = [
disko.nixosModules.disko disko.nixosModules.disko
sops-nix.nixosModules.sops sops-nix.nixosModules.sops
inputs.hermes-agent.nixosModules.default
./hosts/jupiter/configuration.nix ./hosts/jupiter/configuration.nix
]; ];
}; };
+119
View File
@@ -6,6 +6,7 @@
./hardware-configuration.nix ./hardware-configuration.nix
./disk-config.nix # disko: OS-disk partitions + filesystems ./disk-config.nix # disko: OS-disk partitions + filesystems
./secrets.nix # sops-nix: samba password, tailscale key, ... ./secrets.nix # sops-nix: samba password, tailscale key, ...
./hermes-agent.nix # Hermes Agent, isolated instance (see file for why)
../../common.nix # shared base: user / ssh / nix / firewall ../../common.nix # shared base: user / ssh / nix / firewall
../../services/network/samba.nix ../../services/network/samba.nix
../../services/network/avahi.nix ../../services/network/avahi.nix
@@ -41,6 +42,21 @@
# but at ~5G free on a 29G eMMC even 5 is too many — override down to 2. # but at ~5G free on a 29G eMMC even 5 is too many — override down to 2.
boot.loader.systemd-boot.configurationLimit = lib.mkForce 2; boot.loader.systemd-boot.configurationLimit = lib.mkForce 2;
# A `switch` pins the old generation as a GC root until the box reboots onto
# the new one (booted-system vs current-system) — common.nix's nix.gc is
# weekly, far too slow to catch that on a 29G eMMC. 2026-08-19: one switch
# alone took 14G -> 19G used; only reboot (releases the old root) + this GC
# brought it back to 14G. Run a full collect right after every boot instead
# of waiting on the weekly timer.
systemd.services.gc-on-boot = {
description = "Full nix-collect-garbage on every boot";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.nix}/bin/nix-collect-garbage -d";
};
};
# Root lives on the ZimaBlade eMMC (mmcblk0). nixos-generate-config runs in # Root lives on the ZimaBlade eMMC (mmcblk0). nixos-generate-config runs in
# the RAM installer and does NOT detect these, so pin them here (merged with # the RAM installer and does NOT detect these, so pin them here (merged with
# hardware-configuration.nix) or stage-1 can't mount root and the box panics. # hardware-configuration.nix) or stage-1 can't mount root and the box panics.
@@ -51,11 +67,48 @@
# next value: acpi -> bios -> cold -> efi. # next value: acpi -> bios -> cold -> efi.
boot.kernelParams = [ "reboot=pci" ]; boot.kernelParams = [ "reboot=pci" ];
# ---- GPU (jellyfin hardware transcoding) ----
# Apollo Lake N3450 / HD Graphics 500 (Gen9, pci 8086:5A85). The i915 KERNEL
# driver binds on its own — /dev/dri/{card1,renderD128} exist without this —
# but the libva USERSPACE driver only ships when hardware.graphics is on, and
# nothing else here pulled it in. Without it VAAPI init fails with "unknown
# libva error" and jellyfin-ffmpeg exits 251 on EVERY transcode, which the
# client shows as generic playback failure: the server log only says "FFmpeg
# exited with code 251", never that a driver is missing. Verified on the box:
# the same h264_vaapi encode goes 251 -> 0 once iHD is on LIBVA_DRIVERS_PATH.
#
# iHD (intel-media-driver) is the right one for Gen9; i965 is for Gen8 and
# older. Note the render node is 0666 but card1 is 0660 root:video, so the
# group membership in services/media/jellyfin.nix matters for the card node.
hardware.graphics = {
enable = true;
extraPackages = [ pkgs.intel-media-driver ];
};
# ⚠️ This buys VAAPI only — jellyfin must be set to VAAPI, NOT QSV, in its
# web UI (Dashboard -> Playback -> Transcoding). QSV needs an MFX runtime on
# top of the libva driver: ffmpeg's `-init_hw_device qsv=qs@va` dies with
# "Error creating a MFX session: -9" -> exit 171, the SECOND failure hiding
# behind the first (fixing the missing driver only moved 251 -> 171).
# There is no good way to provide it here: vpl-gpu-rt is Gen12+, and the
# Gen9 runtime `intel-media-sdk` is marked INSECURE in nixpkgs (EOL, 5 CVEs
# incl. local privilege escalation) — not worth it when VAAPI does the same
# job on this chip at ~3.5x realtime for 1080p->720p.
#
# Also: 4K HDR (the 2160p HEVC/DV remuxes) can NOT be tone-mapped here.
# tonemap_opencl needs OpenCL, which has no platform on this box, and
# tonemap_vaapi is Gen11+ — both fail. Only a plain scale_vaapi=format=nv12
# succeeds, which drops HDR without tone-mapping (washed-out picture).
# Those files need to direct-play, or be kept as 1080p SDR versions.
# ---- NAS data array ---- # ---- NAS data array ----
# Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T). # Existing ext4 on the mdadm RAID0 over sda+sdb (md0, 29.1T).
# Mounted, NOT formatted; kept out of disko so it is never wiped. # Mounted, NOT formatted; kept out of disko so it is never wiped.
# ⚠️ RAID0 = no redundancy: either 16TB disk failing loses ALL data. # ⚠️ RAID0 = no redundancy: either 16TB disk failing loses ALL data.
boot.swraid.enable = true; # assemble the mdadm array at boot boot.swraid.enable = true; # assemble the mdadm array at boot
# Silences "mdmon service will crash" eval warning. RAID0 here uses native
# superblocks so mdmon (external-metadata arrays only) never actually runs,
# but the module warns unconditionally without SOME MAILADDR/PROGRAM set.
boot.swraid.mdadmConf = "MAILADDR root";
fileSystems."/mnt/data" = { fileSystems."/mnt/data" = {
# fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0. # fs UUID (stable) — the array may enumerate as /dev/md127, so avoid /dev/md0.
device = "/dev/disk/by-uuid/dadbff6f-652e-49b2-bfed-eb1308ab8b78"; device = "/dev/disk/by-uuid/dadbff6f-652e-49b2-bfed-eb1308ab8b78";
@@ -63,6 +116,72 @@
options = [ "nofail" ]; # don't block boot if the array is degraded/absent options = [ "nofail" ]; # don't block boot if the array is degraded/absent
}; };
# `nofail` above is necessary but NOT sufficient — any mount layered on the
# array (prowlarr/seerr binds) is RequiredBy local-fs.target and will fail it
# regardless, and emergency mode on this box is a dead end: root is locked, so
# sulogin drops you at a prompt you cannot answer, with no ssh. 2026-08-06: a
# drive that failed to enumerate after the rack move did exactly this —
# "Timed out waiting for device /dev/disk/by-uuid/dadbff6f-…" -> Dependency
# failed for Local File Systems -> Reached target Emergency Mode, twice.
# Boot as far as possible instead and leave the failed units to be read over
# ssh. The array-backed services carry RequiresMountsFor=/mnt/data so they
# still refuse to start rather than writing to the eMMC.
systemd.enableEmergencyMode = false;
# ---- Heavy state moved off the eMMC ----
# A deploy holds TWO full closures (~9G each) on a 29G disk at once, so the
# OS disk has no room for state that grows on its own. 2026-08-09: it hit 0
# bytes free with both gen 39 and gen 40 resident, and postgres died on
# "No space left on device" — note ext4 reserves 5% for root, so non-root
# services see zero while df still shows ~300M free.
#
# Paths live under /mnt/data/AppData like every other service's state. Both
# settings below are jupiter-only on purpose: services/containers.nix stays
# engine- and host-agnostic (mercury runs pihole on podman with no array).
# podman: CI images dominate and keep growing — the gitea runner's
# act-latest is 1.7G, and the act-22.04 label in services/dev/gitea.nix
# pulls another ~1.7G the first time a job requests it.
# runroot stays on /run: it is per-boot tmpfs state, not a growing store.
virtualisation.containers.storage.settings.storage = {
driver = "overlay";
graphroot = "/mnt/data/AppData/containers/storage";
runroot = "/run/containers/storage";
};
# immich's postgres cluster. Version component mirrors the upstream default
# (`/var/lib/postgresql/${psqlSchema}`) so a major bump gets its own dir
# instead of silently reusing the old cluster's files.
# ⚠️ This puts the DB in the SAME failure domain as the photos it indexes:
# /mnt/data is RAID0, so either 16TB disk now loses both, where before an
# eMMC failure and an array failure each took only one. Chosen deliberately
# — the two are useless apart — but neither is backed up.
services.postgresql.dataDir =
"/mnt/data/AppData/postgresql/${config.services.postgresql.package.psqlSchema}";
# /mnt/data/AppData is drwx--x--- darman:users, so postgres needs group
# "users" just to TRAVERSE into its own dataDir — exactly the reason immich
# has the same line. The cluster dir itself keeps the mode it was initdb'd
# with (0750 postgres:postgres) — postgres only accepts 0700, or 0750 when
# the cluster was created with group access, and refuses to start otherwise.
users.users.postgres.extraGroups = [ "users" ];
# Neither path is under /var/lib, so no module creates it: the postgresql
# module's own tmpfiles entry only adjusts a dataDir that already exists,
# the same way immich's mediaLocation rule does.
systemd.tmpfiles.rules = [
"d /mnt/data/AppData/postgresql 0750 postgres postgres -"
"d /mnt/data/AppData/containers 0700 root root -"
];
# graphroot is not a systemd path dependency the way dataDir is, so nothing
# derives a mount ordering from it. Without these, podman would recreate an
# empty store on the eMMC under the mountpoint when the array is late or
# absent, and the runner would re-pull every image into it.
# (podman-clonarr already carries this from services/media/clonarr.nix.)
systemd.services.podman.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
systemd.services.gitea-runner-jupiter.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
# ---- Caddy vhosts (LAN) ---- # ---- Caddy vhosts (LAN) ----
# Reached via pihole local-DNS names -> jupiter IP. # Reached via pihole local-DNS names -> jupiter IP.
services.caddy.virtualHosts = { services.caddy.virtualHosts = {
+77
View File
@@ -0,0 +1,77 @@
{ config, ... }:
# Hermes Agent — a SEPARATE, isolated instance from terra's
# (services/desktop/hermes-agent.nix). Locked down harder than terra given
# jupiter's much bigger blast radius (irreplaceable immich photos on an
# unredundant RAID0, gitea/CI tokens, the whole media stack):
#
# - Own dedicated "hermes" system user (module default: user/group "hermes",
# createUser = true) — NOT darman. darman is in jupiter's "docker" group
# (services/containers.nix: rootful podman with dockerCompat), which is
# root-equivalent (`docker run -v /:/host --privileged ...`). Handing an
# LLM-driven agent that identity would mean a container escape = root on
# the whole NAS.
# - container.enable = true, backend = "podman": the ENTIRE gateway process
# runs inside a container (reusing jupiter's existing rootful podman
# instead of also standing up a second Docker daemon), not just the shell
# tool. Per upstream's own SECURITY.md this is "whole-process wrapping" —
# shell, file tools, MCP subprocesses, and the code-exec tool are all
# confined, unlike the lighter "terminal-backend"-only isolation.
# - Its own Telegram bot (own token, in secrets.nix) with an EXPLICIT
# TELEGRAM_ALLOWED_USERS rather than relying solely on the adapter's
# fail-closed default. Sharing terra's bot token would 409-conflict two
# long-pollers on the same token.
# - No container.extraVolumes into /mnt/data or the homelab repo — nothing
# valuable is in reach if a command goes wrong or gets injected via
# Telegram/tool output. stateDir/workingDirectory live on the array
# (below) purely because coding-task state (repo clones, npm/pip caches
# inside the container's writable layer) belongs off the 29G eMMC, same
# reasoning as postgres/containers.storage in configuration.nix — NOT
# because anything else on /mnt/data is exposed to the agent.
{
services.hermes-agent = {
enable = true;
addToSystemPackages = true; # `hermes` on darman's PATH for interactive
# debugging over ssh — routes through to the
# container, does not grant darman any group.
# Off the eMMC: stateDir bind-mounts into the container as /data, so this
# is where any future scoped repo clone (container.extraVolumes) and the
# container's own writable layer (npm/pip installs during coding tasks)
# actually land. RequiresMountsFor below (mirrors podman/sabnzbd/gitea-runner
# in configuration.nix) keeps the service from starting — and bind-mounting
# the wrong, empty eMMC path — before the nofail array is up.
stateDir = "/mnt/data/AppData/hermes";
workingDirectory = "/mnt/data/AppData/hermes/workspaces";
container = {
enable = true;
backend = "podman"; # jupiter already runs podman (services/containers.nix);
# default "docker" would stand up a second daemon.
};
# Same OpenCode Go provider account as terra (services/desktop/hermes-agent.nix)
# — just an API key, not a stateful identity like the Telegram bot token, so
# sharing it across hosts is fine.
settings.model = {
provider = "opencode-go";
base_url = "https://opencode.ai/zen/go/v1";
default = "gpt-5.6-luna";
api_mode = "codex_responses";
};
settings.platforms.telegram = {
enabled = true;
home_channel = {
platform = "telegram";
chat_id = "15151223";
name = "Erik Simon";
user_id = "15151223";
};
};
environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
};
systemd.services.hermes-agent.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
}
+39
View File
@@ -42,4 +42,43 @@
sops.templates."gitea-runner.env".content = sops.templates."gitea-runner.env".content =
"TOKEN=${config.sops.placeholder.gitea_runner_token}"; "TOKEN=${config.sops.placeholder.gitea_runner_token}";
# provisioning access token for gitea used to setup ci-bot account + repo access
sops.secrets.gitea_provisioning_token.owner = "gitea";
# ci-bot access token to allow the ci-bot user to push to repos
sops.secrets.gitea_ci_bot_token.owner = "gitea";
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) —
# migrated off the reused ini in services/media/sabnzbd.nix into
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this
# migration (provisioned for mediamanager's future use, services/experimental/
# mediamanager.nix — not currently imported by any host); reused here as the
# same single source of truth rather than duplicating it.
# owner = sabnzbd: the module's preStart (replace-secret) runs as the
# service's own User=/Group=, and sops secrets default to root:root 0400 —
# without this, replace-secret gets Permission denied reading /run/secrets.
sops.secrets.sabnzbd_web_username.owner = "sabnzbd";
sops.secrets.sabnzbd_web_password.owner = "sabnzbd";
sops.secrets.sabnzbd_api_key.owner = "sabnzbd";
sops.secrets.sabnzbd_nzb_key.owner = "sabnzbd";
sops.secrets.sabnzbd_eweka_username.owner = "sabnzbd";
sops.secrets.sabnzbd_eweka_password.owner = "sabnzbd";
# Hermes Agent (hosts/jupiter/hermes-agent.nix) — a separate, isolated
# instance from terra's, with its OWN Telegram bot token (sharing terra's
# would 409-conflict two long-pollers on one token). opencode_go_api_key
# is the same provider account as terra (hosts/terra/secrets.nix) — a
# stateless API key, fine to duplicate across hosts. No owner override:
# sops.templates renders via a root-run activation script, which the
# hermes module's own activation script (also root) then reads — unlike
# sabnzbd's preStart, this doesn't run as the service's own user.
sops.secrets.opencode_go_api_key = { };
sops.secrets.telegram_bot_token = { };
sops.templates."hermes-agent.env".content = ''
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
TELEGRAM_HOME_CHANNEL=15151223
TELEGRAM_ALLOWED_USERS=15151223
'';
} }
+52
View File
@@ -13,9 +13,11 @@ in
./disk-config.nix ./disk-config.nix
./secrets.nix ./secrets.nix
../../common.nix ../../common.nix
../../services/containers.nix
../../services/vpn/tailscale.nix ../../services/vpn/tailscale.nix
../../services/desktop/desktop-hyprland.nix ../../services/desktop/desktop-hyprland.nix
../../services/desktop/desktop-apps.nix ../../services/desktop/desktop-apps.nix
../../services/desktop/librechat.nix
]; ];
networking.hostName = "terra"; networking.hostName = "terra";
@@ -62,6 +64,56 @@ in
hardware.enableRedistributableFirmware = true; hardware.enableRedistributableFirmware = true;
boot.initrd.kernelModules = [ "amdgpu" ]; boot.initrd.kernelModules = [ "amdgpu" ];
# /dev/dri/renderD128 is root:render 0660, so rootless podman containers can
# only reach the GPU if the *host* user is in render. Needed by the Vulkan
# whisper.cpp/llama.cpp containers in ~/Data/Dev/repos/content-trigger-scanner.
users.users.darman.extraGroups = [ "render" "video" ];
# ---- ollama (local LLM server, ROCm on the 6800 XT) ----
# Navi 21 is gfx1030 — officially supported by ROCm, so no
# rocmOverrideGfx/HSA_OVERRIDE_GFX_VERSION needed (that's for gpus ROCm
# doesn't recognize, e.g. RDNA1/gfx101x). The upstream module runs the
# service under DynamicUser with SupplementaryGroups=["render"] and
# DeviceAllow for char-kfd/char-drm/char-fb already, so unlike jellyfin's
# static user it needs no extraGroups wiring here.
services.ollama = {
enable = true;
package = pkgs.ollama-rocm;
# keep in sync with services/desktop/librechat.nix's endpoints.custom
# default model — LibreChat's config schema needs a non-empty default
# even though fetch=true replaces it with whatever's actually pulled.
# gemma4:12b: general chat/coding daily driver, fits fully in 16G VRAM —
# also doubles as the memory-extraction agent (see librechat.nix): a
# 3b model (llama3.2:3b, dropped) couldn't reliably tell the user's
# stated facts apart from its own boilerplate, e.g. saving "I am an AI
# assistant with tool calling capabilities" as the user's personal_info
# after "Hi I'm Erik Simon". Reusing gemma4:12b for both roles also means
# no second model needs to swap into VRAM while it's already the active
# chat model.
# qwen3.6:35b-a3b: MoE (3B active/36B total), ~24GB Q4_K_M — doesn't fit
# in VRAM alone, so ollama offloads the inactive experts to CPU RAM.
# Sparse activation makes that far less painful than it'd be for a dense
# model this size, but still expect it to run slower than the two above.
loadModels = [ "gemma4:12b" "qwen3.6:35b-a3b" ];
# Ollama truncates context far below the model's real window unless
# told otherwise (the OpenAI-compat /v1 route it's reached through has
# no way to set this per-request). 131072 chosen as the practical
# ceiling after load-testing with real prompts, not just idle
# `ollama ps` checks:
# 32768 (31.6k-token prompt) and 65536 (40.8k-token prompt) both stayed
# 100% GPU with VRAM barely moving (~10.1G / ~10.67G of 16G) — KV cache
# cost barely grows with context, likely sliding-window/local attention
# on most of gemma4:12b's layers. At 131072 that stopped being true: a
# ~108k-token prompt pushed VRAM to ~11.4G/16G (still 100% GPU, no CPU
# spillover, negligible GTT) but with visibly shrinking headroom, and
# prefill throughput measurably dropped (~490 -> ~460 tok/s) over just
# the last 13k tokens — filling the full window would take minutes of
# pure prompt processing. Stopped here rather than push further: next
# doubling would risk CPU spillover under any concurrent GPU load
# (desktop compositor, jellyfin transcode) for diminishing benefit.
environmentVariables.OLLAMA_CONTEXT_LENGTH = "131072";
};
# ---- Dev-data disks — NOT in disko, mounted read-write, never wiped ---- # ---- Dev-data disks — NOT in disko, mounted read-write, never wiped ----
fileSystems."/mnt/hdd_01" = { fileSystems."/mnt/hdd_01" = {
device = "/dev/disk/by-uuid/b8445126-ec6d-4f88-818a-d9e13031d9a4"; device = "/dev/disk/by-uuid/b8445126-ec6d-4f88-818a-d9e13031d9a4";
+22 -2
View File
@@ -1,5 +1,7 @@
{ pkgs, unstable, ... }: { pkgs, unstable, inputs, ... }:
let
tome = pkgs.callPackage ../../pkgs/tome.nix { src = inputs.tome; };
in
{ {
imports = [ ./home/hyprland.nix ./home/theme.nix ]; imports = [ ./home/hyprland.nix ./home/theme.nix ];
@@ -17,6 +19,22 @@
programs.zsh.enable = true; programs.zsh.enable = true;
# direnv + nix-direnv: lets per-repo devShells (e.g. ~/Data/Dev/repos/Tome's
# flake.nix) auto-load in the shell AND in Rider via its "direnv
# integration" plugin, instead of every dev repo needing its own
# jetbrains-toolbox SDK wiring by hand.
programs.direnv = {
enable = true;
nix-direnv.enable = true;
};
# Rootless podman: containers run as darman, not root. services/containers.nix
# gives us the `docker` CLI shim (dockerCompat), but compose v2 is a separate
# binary and talks to a socket rather than the CLI — the NixOS podman module
# enables the *user* socket (systemd.user.sockets.podman), so point compose at
# it instead of the root /var/run/docker.sock.
home.sessionVariables.DOCKER_HOST = "unix:///run/user/1000/podman/podman.sock";
xdg.userDirs = { xdg.userDirs = {
enable = true; enable = true;
}; };
@@ -33,9 +51,11 @@
pkgs.quickshell pkgs.quickshell
pkgs.github-cli pkgs.github-cli
pkgs.tea pkgs.tea
pkgs.docker-compose
pkgs.hyprcursor pkgs.hyprcursor
pkgs.bibata-cursors pkgs.bibata-cursors
pkgs.papirus-icon-theme pkgs.papirus-icon-theme
tome
]; ];
xdg.desktopEntries.btop = { xdg.desktopEntries.btop = {
+17 -12
View File
@@ -36,7 +36,7 @@ let
# here rather than at runtime, since hyprpaper has no built-in "random" # here rather than at runtime, since hyprpaper has no built-in "random"
# mode; re-pick and rebuild (or swap in real per-monitor selection) when # mode; re-pick and rebuild (or swap in real per-monitor selection) when
# this stops being a placeholder. # this stops being a placeholder.
wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-g7jg63.png"; wallpaper = "/mnt/hdd_01/data/Pictures/Wallhaven/wallhaven-4yjyd4.png";
# Dispatchers → the new hl.dsp.* API (signatures verified against hyprland # Dispatchers → the new hl.dsp.* API (signatures verified against hyprland
# 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp). # 0.55's src/config/lua/bindings/LuaBindingsDispatchers.cpp).
@@ -93,9 +93,10 @@ in
settings = { settings = {
# ---- colours (from colors.conf) ---- # ---- colours (from colors.conf) ----
fg_color = { _var = "rgba(eeeeeeff)"; }; fg_color = { _var = "rgba(eeeeeeff)"; };
fg_accent = { _var = "rgba(FFD063ff)"; }; fg_accent = { _var = "rgba(ffd063ff)"; };
bg_color = { _var = "rgba(0F1012ff)"; }; fg_accent_alt = { _var = "rgba(ff9d42ff)"; };
bg_accent = { _var = "rgba(40382fff)"; }; bg_color = { _var = "rgba(0f1012ff)"; };
bg_accent = { _var = "rgba(963c38ff)"; };
# ---- monitors ---- # ---- monitors ----
monitor = [ monitor = [
@@ -115,16 +116,11 @@ in
debug.disable_logs = false; debug.disable_logs = false;
general = { general = {
border_size = 1; border_size = 0;
col = { col = {
inactive_border = lua "bg_accent"; inactive_border = lua "bg_accent";
# Gradient: hyprland's lua gradient type (CLuaConfigGradient::parse)
# takes either a plain colour string or a table with a `colors`
# array (1+ entries) and an optional `angle` in DEGREES. The list
# entries may be literal "rgba(...)" strings or, as here, the
# `_var` locals declared above rendered raw via mkLuaInline.
active_border = { active_border = {
colors = [ (lua "fg_accent") (lua "bg_accent") ]; colors = [ (lua "fg_accent") (lua "fg_accent_alt") ];
angle = 45; angle = 45;
}; };
}; };
@@ -162,7 +158,16 @@ in
allow_workspace_cycles = true; allow_workspace_cycles = true;
}; };
plugin.hyprchrome.enabled = true; plugin.hyprchrome = {
enabled = true;
glow_size = 12;
glow_strength = 0.85;
shadow_size = 24;
shadow_color = lua "bg_color";
shadow_offset = lua "{ 4, 8 }";
outline_size = lua "2";
outline_color = lua "fg_color";
};
}; };
# ---- animations ---- # ---- animations ----
+10
View File
@@ -21,4 +21,14 @@
username=darman username=darman
password=${config.sops.placeholder.samba_password} password=${config.sops.placeholder.samba_password}
''; '';
# LibreChat's CREDS_KEY/IV encrypt stored user credentials (linked 3rd-party
# API keys etc) at rest in mongo; JWT_SECRET/JWT_REFRESH_SECRET sign session
# tokens. All four are random, generated once with `sops --set` (see
# CLAUDE.md) — losing/rotating them just invalidates existing sessions and
# any saved per-user API keys, nothing else depends on their value.
sops.secrets.librechat_creds_key = { };
sops.secrets.librechat_creds_iv = { };
sops.secrets.librechat_jwt_secret = { };
sops.secrets.librechat_jwt_refresh_secret = { };
} }
+20 -20
View File
@@ -41,23 +41,23 @@
}, },
{ {
"pname": "Microsoft.AspNetCore.App.Ref", "pname": "Microsoft.AspNetCore.App.Ref",
"version": "8.0.27", "version": "8.0.29",
"hash": "sha256-nwBrMFATFwpJS1iq9Bf+vvWQ1dDGergMuY809tUqo60=" "hash": "sha256-0wK5Lsa4a1ani/gvSzsqyuY3R4MBuH/9XOyZeWCUWoU="
}, },
{ {
"pname": "Microsoft.AspNetCore.App.Ref", "pname": "Microsoft.AspNetCore.App.Ref",
"version": "9.0.16", "version": "9.0.18",
"hash": "sha256-lBbgyPyZOrPsRMtd0UOHJuB5dbMQFysVIk4RAFx2Rk0=" "hash": "sha256-0qkb9Gbxlyyw8rKxDTm9OqdW89DIi1qolGK85HLwc2k="
}, },
{ {
"pname": "Microsoft.AspNetCore.App.Runtime.linux-x64", "pname": "Microsoft.AspNetCore.App.Runtime.linux-x64",
"version": "8.0.27", "version": "8.0.29",
"hash": "sha256-7DX4XBTx8a6sFRnTrJ3zJhJzQVC81OwzOZnYOo+nO20=" "hash": "sha256-iiDWZa7MkybKwozVKIV4Eq0PGzirxeyjFLnoxPyAHiQ="
}, },
{ {
"pname": "Microsoft.AspNetCore.App.Runtime.linux-x64", "pname": "Microsoft.AspNetCore.App.Runtime.linux-x64",
"version": "9.0.16", "version": "9.0.18",
"hash": "sha256-JDdPuh01rffoWnKekJU34/QKWFFnyTljmqq6DM5vYs8=" "hash": "sha256-ETNi+pMo8nNSgHMLKgi/VFMla/duHI2CQlJlm1FdyKc="
}, },
{ {
"pname": "Microsoft.Bcl.TimeProvider", "pname": "Microsoft.Bcl.TimeProvider",
@@ -216,33 +216,33 @@
}, },
{ {
"pname": "Microsoft.NETCore.App.Host.linux-x64", "pname": "Microsoft.NETCore.App.Host.linux-x64",
"version": "8.0.27", "version": "8.0.29",
"hash": "sha256-ZI5ByoSqJIcQAnH1dyGcK8uyvPB7yUNznD6BLY2V8Hs=" "hash": "sha256-T/eXkzT3V3T1Yasc1cUZ/jLOWJY3zp/GxJTCT24gnAw="
}, },
{ {
"pname": "Microsoft.NETCore.App.Host.linux-x64", "pname": "Microsoft.NETCore.App.Host.linux-x64",
"version": "9.0.16", "version": "9.0.18",
"hash": "sha256-HaIx6pwpKUwdseu3tOhuVtqnpGCubhWbi9BEvijHd+M=" "hash": "sha256-h36uAGfY36RKon6QU3lP3tsVlMVZ842lvdGiLcHO7Ko="
}, },
{ {
"pname": "Microsoft.NETCore.App.Ref", "pname": "Microsoft.NETCore.App.Ref",
"version": "8.0.27", "version": "8.0.29",
"hash": "sha256-F/FL0ptluwCfxN4S93/UAKs4fRtyL+D4NoSPc5CGyJo=" "hash": "sha256-dxAuEUU1VOElQ4CpL9HLhV4KVFtRDAcZS8W0GT2Di6g="
}, },
{ {
"pname": "Microsoft.NETCore.App.Ref", "pname": "Microsoft.NETCore.App.Ref",
"version": "9.0.16", "version": "9.0.18",
"hash": "sha256-VLwChaPID3roiQw6qU8IuPaUOPAHl3wzCIjVtG4D6ZM=" "hash": "sha256-ZFU4lXz/BjJiqU4sykZEYR5j5e745KMf4ZPo0GHnicU="
}, },
{ {
"pname": "Microsoft.NETCore.App.Runtime.linux-x64", "pname": "Microsoft.NETCore.App.Runtime.linux-x64",
"version": "8.0.27", "version": "8.0.29",
"hash": "sha256-GejthwcyJAmNTFvWEZXis6lRM2sJyNrKHYsq79Fn/WI=" "hash": "sha256-eA5x9NMfCg6JhRmOKKqfBL8+TwtQW/1ONvJeANPJOFA="
}, },
{ {
"pname": "Microsoft.NETCore.App.Runtime.linux-x64", "pname": "Microsoft.NETCore.App.Runtime.linux-x64",
"version": "9.0.16", "version": "9.0.18",
"hash": "sha256-3nDEdBN1jHIy2PiLffnE4+Snt7MiGP2hEDEi924DXww=" "hash": "sha256-DV5iYmqH8j7JIcem8Qu/HLbZuHPVmaTTQuZc8OoDZBk="
}, },
{ {
"pname": "Microsoft.SourceLink.Common", "pname": "Microsoft.SourceLink.Common",
+18 -2
View File
@@ -3,11 +3,13 @@
, buildNpmPackage , buildNpmPackage
, importNpmLock , importNpmLock
, dotnetCorePackages , dotnetCorePackages
, glib
, gtk3 , gtk3
, webkitgtk_4_1 , webkitgtk_4_1
, libnotify , libnotify
, makeDesktopItem , makeDesktopItem
, copyDesktopItems , copyDesktopItems
, wrapGAppsHook3
, src , src
}: }:
@@ -45,15 +47,29 @@ buildDotnetModule (finalAttrs: {
nugetDeps = ./tome-deps.json; nugetDeps = ./tome-deps.json;
dotnet-sdk = dotnetCorePackages.sdk_10_0; dotnet-sdk = dotnetCorePackages.sdk_10_0;
dotnet-runtime = dotnetCorePackages.runtime_10_0; # aspnetcore_10_0, not runtime_10_0: Tome.App's runtimeconfig.json requires
# both Microsoft.NETCore.App AND Microsoft.AspNetCore.App (Photino hosts a
# local Kestrel server), and only the aspnetcore bundle ships the latter.
dotnet-runtime = dotnetCorePackages.aspnetcore_10_0;
dotnetFlags = [ "-p:SkipNpmBuild=true" ]; dotnetFlags = [ "-p:SkipNpmBuild=true" ];
executables = [ "Tome.App" ]; executables = [ "Tome.App" ];
nativeBuildInputs = [ copyDesktopItems ]; # wrapGAppsHook3: buildDotnetModule sets dontWrapGApps = true by default (to
# avoid double-wrapping) but its own wrap step still splices gappsWrapperArgs
# in when the hook is present (see nixpkgs' libation package, same pattern).
# Without it the binary never gets XDG_DATA_DIRS/GSETTINGS_SCHEMA_DIR set, so
# GTK/WebKitGTK can't find the icon theme or GTK settings from the desktop
# session — symptoms: missing icons and a denser default UI font/size than
# when launched from an already-fully-initialized session (e.g. via Rider).
nativeBuildInputs = [ copyDesktopItems wrapGAppsHook3 ];
runtimeDeps = [ runtimeDeps = [
# glib: not pulled in via gtk3/webkitgtk's own RPATH here, because the
# thing that needs it — Photino.Native.so — is a prebuilt binary shipped
# in the Photino.Native nuget package, not something Nix built/patched.
glib
gtk3 gtk3
webkitgtk_4_1 webkitgtk_4_1
libnotify libnotify
+12 -3
View File
@@ -7,6 +7,15 @@ prowlarr_api_key: ENC[AES256_GCM,data:ab1QACaagI8ACJXFUy8r9X9lgYwRo1byUmhBPSRWwK
cinephage_better_auth_secret: ENC[AES256_GCM,data:S1ilcQeC2HmXe/4xdLi6wm5RNz954SL3qVur6JCn5ekBVCbXMd1DGCafjhU=,iv:9rS5gDuazMOAq/hWp0onvHZPzKJgQM3oWIrtplJN/9I=,tag:xNqf/unY2v/98p4v52vUqw==,type:str] cinephage_better_auth_secret: ENC[AES256_GCM,data:S1ilcQeC2HmXe/4xdLi6wm5RNz954SL3qVur6JCn5ekBVCbXMd1DGCafjhU=,iv:9rS5gDuazMOAq/hWp0onvHZPzKJgQM3oWIrtplJN/9I=,tag:xNqf/unY2v/98p4v52vUqw==,type:str]
immich_oauth_client_secret: ENC[AES256_GCM,data:+NbUnwImwFTYNjz3luzczpCf7oMetzYBkj5ZnuG2QQf0Wpm6OtYS3amTC8dwoh9F/DAos5224etncfEgEu2k2iMUACLADnlCGppIx0F7Gl1Ve7UF2VzKJ3xQpgCDrXklU+o5NxfU/YBn1Vfa3580wT3tr2++SCSrcKq1XGtfhv4=,iv:tjaPDQbrA6TxsDebgNOtO/ITfXzU5wTKU9SkfC0TQcY=,tag:AnlJR0tLExkB7Aeo/ZVTng==,type:str] immich_oauth_client_secret: ENC[AES256_GCM,data:+NbUnwImwFTYNjz3luzczpCf7oMetzYBkj5ZnuG2QQf0Wpm6OtYS3amTC8dwoh9F/DAos5224etncfEgEu2k2iMUACLADnlCGppIx0F7Gl1Ve7UF2VzKJ3xQpgCDrXklU+o5NxfU/YBn1Vfa3580wT3tr2++SCSrcKq1XGtfhv4=,iv:tjaPDQbrA6TxsDebgNOtO/ITfXzU5wTKU9SkfC0TQcY=,tag:AnlJR0tLExkB7Aeo/ZVTng==,type:str]
gitea_runner_token: ENC[AES256_GCM,data:8ji4Nia7GMCBBsemUeGZRqzhlk1RnzzOLLdo7+to85KIC5Kz4AtDsQ==,iv:2wotlB1B/Co/NrZVcIVB4AlwL7DF9KnEVKe32FJNErU=,tag:zLzKPgGmXCkGfK7P/74pyw==,type:str] gitea_runner_token: ENC[AES256_GCM,data:8ji4Nia7GMCBBsemUeGZRqzhlk1RnzzOLLdo7+to85KIC5Kz4AtDsQ==,iv:2wotlB1B/Co/NrZVcIVB4AlwL7DF9KnEVKe32FJNErU=,tag:zLzKPgGmXCkGfK7P/74pyw==,type:str]
gitea_provisioning_token: ENC[AES256_GCM,data:aVzD+3qb0eAuGCNIXgzR338jMz9MqXun3nbgfZAirewDwT3D7T5T0Q==,iv:OOeDRk+4CHQyRh09qgUp7I4vcrvuqaAPAh5HgJ10Uvo=,tag:2Yey9e9WZteERDoqkIppWQ==,type:str]
gitea_ci_bot_token: ENC[AES256_GCM,data:isgOYuA8S6w7WCUr2i2tW4F+b8mCRh8e+rjFJtM1fXEkkUIRNaiADA==,iv:W5IoxhuPCoTP2wLhedu4RYKrC8tBFFJ4B+QvNW3jjTc=,tag:ztxEwDdGJLc/JEkls1jrOA==,type:str]
sabnzbd_web_username: ENC[AES256_GCM,data:yNU=,iv:t6Ev0bTLovn3gYtOltS14Y/ElUVCYGxxz8wGsgl9R44=,tag:k2yXYQ2yot1HaohCNDgycg==,type:str]
sabnzbd_web_password: ENC[AES256_GCM,data:9Lo=,iv:H0Kz8A534RxX+7/Aue8Q87gCzSY5e/TrdDjeVYgC+Tg=,tag:tvVN6g5DheTN67oWsSBLHQ==,type:str]
sabnzbd_nzb_key: ENC[AES256_GCM,data:DNVenqhJ7wf5Ng0XRA1gJN95e+90e6D9NImOSHJv/Us=,iv:eqFn0stB5pqh0ls4/impD8gc/lOkORwEJzRP6m7u1XU=,tag:Zs8ogLBZEZLyMvFBqhfpIA==,type:str]
sabnzbd_eweka_username: ENC[AES256_GCM,data:eLsTZoM8T8fAlGaXWlDaoQ==,iv:eawyGhN7+d6UfBIbI3y1qgq+MYBGrXP6VfAkSOK6llA=,tag:ELOfQGHU5NOxZFhKOKf8LA==,type:str]
sabnzbd_eweka_password: ENC[AES256_GCM,data:Mt3ZHAe2wzacCQq3x9Uy8WxjrVNad1SmU6sl8ZgrkMLymfq2eP4JzO/uPdD33A==,iv:PnFT95Zxqz4QBpPF5PRloKpoa15AU7Ef/Owwy+iDotw=,tag:/uRX00RzHLJN3gws5Qz8SA==,type:str]
opencode_go_api_key: ENC[AES256_GCM,data:7kgWiye0wHCxzKFsrzX2WQNDkSVpuvJN6w5Zw9tuyYj5ysDRnWDjCvQtWEJlBalq+Fz7HfT28uFLFtrjFornGEPPdQ==,iv:9Ue/nMpJozVy7oHvhvHwKNuMlsb3tXjwnpC3jok5IWs=,tag:K2UqXBnMH3lpnATa2A/Agg==,type:str]
telegram_bot_token: ENC[AES256_GCM,data:wU3CgKqbO1twJMIAlVi6rzVP5IUu34l1JOBVnlvTzhGL+Teq/sodQ9nlZOkzfg==,iv:8WxWDkGitljLa8aiwiT8td/3WeEnZAvz38oVPF5TQ4I=,tag:x9ldeAJOsTUJqAE4YZMPNg==,type:str]
sops: sops:
age: age:
- enc: | - enc: |
@@ -27,7 +36,7 @@ sops:
CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ== CzjSDQZTcseEXZNwuzZcfB5Mvq0BQvjOj7lGuxzuE4qwWkdJWGfVLQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt recipient: age1zak7glavmg4026p2389fyqe769vqm4jrryknuqckgqq4merz5f7q44rkkt
lastmodified: "2026-07-29T19:00:26Z" lastmodified: "2026-08-19T19:44:22Z"
mac: ENC[AES256_GCM,data:D5FLrembFUqs5yC2bHuhw2w8XUhF46LWpN5LF/VJKtLOo3A3KUdGV0HhA9k6gaTWyn5Zd7O9YSuJLknI62fbOtJkqOhMVUgj/VZXgrcQg4hlU53gDppryYNGi/7Vc9lxLfsJU4Yvk6yQfh7Gt19U+Mj6s7ETn6lJxC5FBnIkS3I=,iv:EJAM1cCD4rTq0XjIbo66di73hnOoklx5UoTrpdyKHq0=,tag:wEhFnwlXiYKyu/k/ED75TA==,type:str] mac: ENC[AES256_GCM,data:ZgHrvBYUeUDo7ZydN3K5CbIUXDvbvj1whnSWuzc+x5TlejqQH89zXRMKBuDD2DgzS+ET6PFHgN+0KoQchOI9SVGmdzz0b3mKpAMZc1BXYWOy5OcxF2xToA2Gub+QV3KQ1VsLSuR6bHcPKR6h4vAs1iwRKYAguQqo7LJMKAduuWs=,iv:EhUYS1iZOT9AbTk2A0EfLFjqIjLU7KYd1xHWwbwzUpU=,tag:Lh2yKA2AELEZk8axXdJJiA==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.2 version: 3.13.3
+6 -2
View File
@@ -1,6 +1,10 @@
tailscale_authkey: ENC[AES256_GCM,data:DgxbMrKTxC//cWd+ZckCbwZ9j/FgjYwNwadU5cWnnPz+nPEFhXLla2TuUE0pJKpcx9pKZCQmxcGtzQvIB+mbgxjgB0X2CRTp4Yy3WR+PBYMghKx36F8KBg==,iv:hJr6O41OTdMa4wkBXEsRUXgV4zWt21h1kx/CWArLU7o=,tag:IEpMIwKBHakvVYEFdEUe7A==,type:str] tailscale_authkey: ENC[AES256_GCM,data:DgxbMrKTxC//cWd+ZckCbwZ9j/FgjYwNwadU5cWnnPz+nPEFhXLla2TuUE0pJKpcx9pKZCQmxcGtzQvIB+mbgxjgB0X2CRTp4Yy3WR+PBYMghKx36F8KBg==,iv:hJr6O41OTdMa4wkBXEsRUXgV4zWt21h1kx/CWArLU7o=,tag:IEpMIwKBHakvVYEFdEUe7A==,type:str]
darman_password: ENC[AES256_GCM,data:G3ZM+NMxvKq5twblcBvyC+MiUX+X7nz+s1GqBHBkJQy1YgW4KN6rpbdj10F9jWxHph5dJ9j9fG6L7UlEg5W5zUYeLFdEx2xJGE1fxksch+6BB9FT6LKkhJ7MmbJmhNHYwJOZO3LM1f/oRw==,iv:abQTRe9kRyYj+TL0rtoxM6JFBaIBmxu7y77qt1h4eME=,tag:OgKrB3SUgSNJjmtJyrmh/Q==,type:str] darman_password: ENC[AES256_GCM,data:G3ZM+NMxvKq5twblcBvyC+MiUX+X7nz+s1GqBHBkJQy1YgW4KN6rpbdj10F9jWxHph5dJ9j9fG6L7UlEg5W5zUYeLFdEx2xJGE1fxksch+6BB9FT6LKkhJ7MmbJmhNHYwJOZO3LM1f/oRw==,iv:abQTRe9kRyYj+TL0rtoxM6JFBaIBmxu7y77qt1h4eME=,tag:OgKrB3SUgSNJjmtJyrmh/Q==,type:str]
samba_password: ENC[AES256_GCM,data:UkJLUa2hW1iZ++sfJAcg6G1RJMM=,iv:/HbZ9F+GxCydUP50PNBtJknPlmDWh1DAE26N9FJUyb0=,tag:z1cbwUaVdyfwEIU8LINEcw==,type:str] samba_password: ENC[AES256_GCM,data:UkJLUa2hW1iZ++sfJAcg6G1RJMM=,iv:/HbZ9F+GxCydUP50PNBtJknPlmDWh1DAE26N9FJUyb0=,tag:z1cbwUaVdyfwEIU8LINEcw==,type:str]
librechat_creds_key: ENC[AES256_GCM,data:e2Ptf41yHu0KxfzjW4DP04CSJfbtdsJ8bwrgJyv9up4/JSCbBzjPFmOi7jsHUL4jT0AGVuHG5w3y+YOil9EeNA==,iv:zneozNSkXsb4Vy/sq21b8HWCKpDkXVTxyLY2Zh0bwP0=,tag:6CxR5sIspbfzHlfdx+45Iw==,type:str]
librechat_creds_iv: ENC[AES256_GCM,data:iuW1Rxfu7Ei8zHVG7wsBTYYznbtErj7DC9B71OiiWGA=,iv:Ngs88C44gcSzxQZU1lMiy5kn9mM/ckuWKRRGOFJeeoQ=,tag:Q9B8vM4YMkIP3RyYVWSQqg==,type:str]
librechat_jwt_secret: ENC[AES256_GCM,data:f/gljrQPZIXeLHXtqKCCYGEu2pXgbZd69CjyEhSVJ+AMuaVj7DWVclD5IMsEVERBHw5a7OndBSfLvgXRaKN6Gg==,iv:DG5CwCbQLCTv1++APCdFzAiWGKiEJl+9MhKIM2ykJQM=,tag:wdpY6l+5h4UtsluIncsyhA==,type:str]
librechat_jwt_refresh_secret: ENC[AES256_GCM,data:N/yAPtaodJX4t2C2A0bZ/LQaVc8SBMgg3KYJrWRyXCM6HAmgHtSQ9nWJO2fl+7A966mMtYvpATYcBIPj/K5nig==,iv:k5ZYi2OKjL9Z+lhVgdNsmGVQm+iCqmF15H3TUTbofWk=,tag:NVTdvlL1lpwVhjo2c2phpg==,type:str]
sops: sops:
age: age:
- enc: | - enc: |
@@ -21,7 +25,7 @@ sops:
sHjKfw8VrrmAR4pQf1dsY+wcyh4FsZxhP3Q+QIVq3eCIXS9PeJkGAg== sHjKfw8VrrmAR4pQf1dsY+wcyh4FsZxhP3Q+QIVq3eCIXS9PeJkGAg==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
recipient: age1rfcmu6zh40v4260l9hnf8ajs9vly0s06rx3ey76eu78dp9t7getqyhmkut recipient: age1rfcmu6zh40v4260l9hnf8ajs9vly0s06rx3ey76eu78dp9t7getqyhmkut
lastmodified: "2026-07-28T20:18:44Z" lastmodified: "2026-08-19T20:48:49Z"
mac: ENC[AES256_GCM,data:TRh/Idd8MEq/5QRb7r+g6msdaObtVzsX5hK5gacJI6seZ5BLW5tcBO/E2m8ya4t50lTofxkwObWskMbCOucLDYt+lnYwHSqDvAeQRvswxaCZgiFOn0upqCi8H4OomvIK475SIH6bniyN9bCQ2gaSS0D9aecyCSmdUARkmcZEYLc=,iv:PBoEUZG0QNyj4B0c6zuWr6Y39EdL1CoAsVExU86P6Tw=,tag:NPxsWkxdojXNsoxVVMb5TQ==,type:str] mac: ENC[AES256_GCM,data:YbsOhLmzynV7S86A9w1XqDEe1/soZPewSrsc+5eR49naQNHUrv2j40J271hMh7r2c+gBZkkHAvq/tQy4USaWPdVn44h6+fPYc11lSlfuIGeIDX6KuXcIw4PdrJr5NgHpQ6+l5XASO0vKOifdqUxENEL8Uu5QJflgg6dA+y6RU94=,iv:ZALmRHzSFx9wzgapZVd27/JRCdTYzVdQsqs0eO9+6ko=,tag:9Un6iiPTxy61yNlms+K8Uw==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.13.2 version: 3.13.2
+1
View File
@@ -18,6 +18,7 @@ in
"claude-code" "claude-code"
"proton-pass-cli" "proton-pass-cli"
"vivaldi" "vivaldi"
"mongodb" # librechat's local db (services/desktop/librechat.nix) — SSPL
]; ];
programs.steam = { programs.steam = {
+96
View File
@@ -0,0 +1,96 @@
{ config, ... }:
# LibreChat — web chat UI, talking to the local ollama server (see
# hosts/terra/configuration.nix) over its OpenAI-compatible /v1 route.
# Only reachable over the tailnet (networking.firewall.trustedInterfaces =
# [ "tailscale0" ] in services/vpn/tailscale.nix) — openFirewall stays off.
{
services.librechat = {
enable = true;
enableLocalDB = true; # spins up a local, unauthenticated-on-localhost mongodb
# LibreChat's isEnabled() treats an UNSET var as false, not true — so
# registration is closed unless this is explicit, despite .env.example
# suggesting true is the default. Only reachable over the tailnet
# (trusted interface, see module comment below), so leaving it open is
# fine; flip to false once your account exists if you want it locked down.
env.ALLOW_REGISTRATION = true;
credentials = {
CREDS_KEY = config.sops.secrets.librechat_creds_key.path;
CREDS_IV = config.sops.secrets.librechat_creds_iv.path;
JWT_SECRET = config.sops.secrets.librechat_jwt_secret.path;
JWT_REFRESH_SECRET = config.sops.secrets.librechat_jwt_refresh_secret.path;
};
settings = {
version = "1.2.1";
endpoints.custom = [
{
name = "Ollama";
# required field but unchecked by ollama's OpenAI-compat shim
apiKey = "ollama";
baseURL = "http://127.0.0.1:11434/v1";
models = {
# schema requires >=1 entry even though fetch=true overwrites it
# at runtime with whatever's pulled (see loadModels in
# hosts/terra/configuration.nix) — kept roughly in sync anyway
# so the UI has sane names before the first fetch completes.
default = [ "gemma4:12b" "qwen3.6:35b-a3b" ];
fetch = true; # pull the model list from ollama at startup
};
titleConvo = true;
}
];
# Persistent memory is opt-in at the CONFIG level — omitting this block
# (as before) leaves the feature entirely off, no matter what a user
# toggles in Settings > Personalization. `agent.provider` must match
# endpoints.custom[].name above exactly ("Ollama"), which is how the
# memory-extraction agent picks a backend/model.
memory = {
personalize = true; # still needs a per-user opt-in toggle in the UI
# instructions REPLACES the default extraction prompt entirely (not
# appended to it) — the 3b model (llama3.2:3b, dropped) was
# defaulting to saving things like its own "I am a helpful
# assistant..." boilerplate under an invented "user_conversation"
# key, and even after adding this prompt, still saved "I am an AI
# assistant with tool calling capabilities" as personal_info after
# the user introduced THEMSELVES — a capability ceiling, not a
# prompting problem. validKeys constrains it to a fixed whitelist
# and instructions spells out the bar for each one.
validKeys = [ "user_preferences" "personal_info" "ongoing_projects" "technical_context" ];
agent = {
enabled = true;
provider = "Ollama";
# same model as the chat endpoint's primary driver — when that's
# the active chat model, extraction needs no second model swapped
# into VRAM alongside it.
model = "gemma4:12b";
instructions = ''
Save memory ONLY using the keys below, and only when the user's
message states something durable and genuinely useful to recall
in a LATER, unrelated conversation. Small talk, greetings, and
anything about what the assistant said or is capable of are NOT
memories if nothing meets the bar, save nothing.
set_memory REPLACES the entire value stored at a key it does
NOT append to it. Before calling set_memory for a key, check the
"Existing memory" section below. If that key already has a
value, your new value MUST merge the old and new information
into one complete sentence or short paragraph calling
set_memory with only the newest fact silently ERASES everything
already stored under that key. Only drop prior details if the
user is explicitly correcting or replacing them.
- user_preferences: explicitly stated preferences (tools, formats, style).
- personal_info: durable facts about the user (name, role, timezone).
- ongoing_projects: projects or tasks the user is actively working on.
- technical_context: durable facts about the user's setup/stack
relevant to future answers (e.g. "runs NixOS", "GPU is AMD ROCm").
'';
};
};
};
};
}
+101 -1
View File
@@ -1,4 +1,4 @@
{ config, ... }: { config, lib, pkgs, ... }:
# Gitea — self-hosted git. stateDir/repositories were migrated from the old # Gitea — self-hosted git. stateDir/repositories were migrated from the old
# ZimaOS docker instance straight into stateDir's default layout, so no # ZimaOS docker instance straight into stateDir's default layout, so no
@@ -9,6 +9,12 @@
# HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix). # HTTP is reverse-proxied through Caddy (hosts/jupiter/configuration.nix).
# SSH uses gitea's own built-in server on :2222 (not the host's :22, and not # SSH uses gitea's own built-in server on :2222 (not the host's :22, and not
# :222 — the unpriv gitea user can't bind <1024). # :222 — the unpriv gitea user can't bind <1024).
let
# Repos where the ci-bot account (see below) should be a Write collaborator
# and whitelisted to push past branch protection. Add a repo here and
# redeploy — no manual UI clicking needed.
ciBotRepos = [ "darman/hypr-chrome" ];
in
{ {
services.gitea = { services.gitea = {
enable = true; enable = true;
@@ -66,4 +72,98 @@
"ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04" "ubuntu-22.04:docker://ghcr.io/catthehacker/ubuntu:act-22.04"
]; ];
}; };
# ci-bot: dedicated account CI workflows push as (kept separate from any
# human account so its own PAT can be scoped/rotated/revoked independently).
# Collaborator access + branch-protection push-whitelisting have no CLI or
# config-file surface in gitea — only the HTTP API — so this is the one
# part of the setup that stays imperative even though it's nix-triggered:
# a oneshot that PUTs/PATCHes the API into the desired state on every
# deploy where its script changed (adding a repo to `ciBotRepos` and
# redeploying is enough to pick it up; it won't self-heal a manual revert
# done via the web UI unless the unit is also restarted).
#
# Auth for those API calls is darman's OWN token (named
# "jupiter-ci-bot-provisioning" in gitea, scopes write:repository +
# write:user — see hosts/jupiter/secrets.nix), since darman owns the repos
# in ciBotRepos and only an owner-scoped token clears the reqOwnerCheck on
# the collaborator/branch-protection endpoints; write:user is additionally
# needed to push ci-bot's token below as a secret on darman's own account.
# It is NOT ci-bot's own push token — ci-bot can't grant itself access.
#
# ci-bot's own push token (separate secret, ci_bot_token) is generated
# once via:
# su gitea -s /bin/sh -c \
# 'GITEA_WORK_DIR=/mnt/data/AppData/gitea gitea admin user generate-access-token \
# --username ci-bot --scopes write:repository'
# and this service pushes it into gitea itself as a user-level Actions
# secret (CI_BOT_TOKEN, on darman's account — see the PUT below) so
# workflows in ciBotRepos can push as ci-bot without a per-repo secret.
systemd.services.gitea-ci-bot-provision = {
description = "Provision ci-bot gitea account + repo access";
after = [ "gitea.service" ];
requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.curl pkgs.jq config.services.gitea.package ];
environment = {
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
CI_BOT_TOKEN_FILE = config.sops.secrets.gitea_ci_bot_token.path;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.gitea.user;
};
script = ''
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
admin_token="$(cat "$TOKEN_FILE")"
auth=(-H "Authorization: token $admin_token")
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
done
if ! curl -fs "''${auth[@]}" "$api/users/ci-bot" >/dev/null 2>&1; then
GITEA_WORK_DIR=${config.services.gitea.stateDir} gitea admin user create \
--username ci-bot \
--email ci-bot@${config.services.gitea.settings.server.DOMAIN} \
--random-password --must-change-password=false
fi
# No instance-wide secret scope exists in Gitea (it's an open feature
# request) - a user-level secret on darman's own account is the closest
# equivalent, since every repo below is owned directly by darman, not
# an org, and repo-level secrets fall back to user-level when unset.
ci_bot_token="$(cat "$CI_BOT_TOKEN_FILE")"
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PUT "$api/user/actions/secrets/CI_BOT_TOKEN" \
-d "$(jq -n --arg data "$ci_bot_token" '{data: $data}')"
${lib.concatMapStringsSep "\n" (repo: ''
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PUT "$api/repos/${repo}/collaborators/ci-bot" \
-d '{"permission":"write"}'
default_branch="$(curl -fs "''${auth[@]}" "$api/repos/${repo}" | jq -r .default_branch)"
# ci-bot needs push access on every branch a workflow might commit
# back to (currently just `develop`, where version-bump.yml pushes),
# in addition to whatever the repo's actual default branch is.
branches="$(printf '%s\n' "$default_branch" develop | sort -u)"
for branch in $branches; do
if curl -fs "''${auth[@]}" "$api/repos/${repo}/branch_protections/$branch" >/dev/null 2>&1; then
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PATCH "$api/repos/${repo}/branch_protections/$branch" \
-d '{"enable_push":true,"enable_push_whitelist":true,"push_whitelist_usernames":["ci-bot"]}'
else
curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X POST "$api/repos/${repo}/branch_protections" \
-d "{\"branch_name\":\"$branch\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"ci-bot\"]}"
fi
done
'') ciBotRepos}
'';
};
} }
+5
View File
@@ -13,4 +13,9 @@
port = 8000; port = 8000;
}; };
users.users.audiobookshelf.extraGroups = [ "users" ]; users.users.audiobookshelf.extraGroups = [ "users" ];
# Its state dir is on the eMMC, so systemd sees no reason to wait for the
# array — but every library path points into /mnt/data. Starting without it
# means an empty library and rescans against nothing.
systemd.services.audiobookshelf.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
} }
+6
View File
@@ -25,4 +25,10 @@
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt/data/AppData/clonarr 0755 darman users -" "d /mnt/data/AppData/clonarr 0755 darman users -"
]; ];
# podman bind-mounts /mnt/data/AppData/clonarr into the container, but the
# generated unit only knows about /run/clonarr — with the array absent podman
# would create the source path on the eMMC and the container would run
# against an empty config.
systemd.services.podman-clonarr.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
} }
+6
View File
@@ -119,4 +119,10 @@ in
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt/data/AppData/immich 0700 immich immich -" "d /mnt/data/AppData/immich 0700 immich immich -"
]; ];
# The unit's automatic RequiresMountsFor covers /run/immich and /var/lib/immich
# only — nothing points it at mediaLocation. Without this immich starts with
# the array missing and writes uploaded photos onto the 29G eMMC, into a
# directory that becomes invisible the moment /mnt/data mounts over it.
systemd.services.immich-server.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
} }
+8 -1
View File
@@ -6,7 +6,14 @@
dataDir = "/mnt/data/AppData/jellyfin"; dataDir = "/mnt/data/AppData/jellyfin";
cacheDir = "/mnt/data/AppData/jellyfin/cache"; cacheDir = "/mnt/data/AppData/jellyfin/cache";
}; };
users.users.jellyfin.extraGroups = [ "users" ]; # "users" so the shared library stays readable (see the UMask note below);
# "video"/"render" for the DRI nodes used by hardware transcoding. renderD128
# happens to be 0666 so VAAPI alone would work without this, but card1 is
# 0660 root:video — and neither mode is guaranteed, so don't rely on it. The
# groups are harmless on a host with no GPU: they exist regardless, and this
# module stays host-agnostic (the DRIVER is enabled per-host, e.g. jupiter's
# hardware.graphics + intel-media-driver).
users.users.jellyfin.extraGroups = [ "users" "video" "render" ];
# The upstream module hardcodes UMask=0077 — root cause of jellyfin writing # The upstream module hardcodes UMask=0077 — root cause of jellyfin writing
# trickplay thumbnails into stray new show folders it invented itself, # trickplay thumbnails into stray new show folders it invented itself,
+13 -1
View File
@@ -15,9 +15,21 @@
{ {
services.prowlarr.enable = true; services.prowlarr.enable = true;
# `nofail` is NOT optional here: without it this bind is RequiredBy
# local-fs.target, so an unassembled RAID array fails that target and drops
# jupiter into emergency mode — which is a dead end, since root is locked and
# sulogin has nothing to offer on a headless box. It defeats the `nofail` on
# /mnt/data itself (a mount layered on the array is what actually took the
# target down). Let this bind fail alone instead.
fileSystems."/var/lib/private/prowlarr" = { fileSystems."/var/lib/private/prowlarr" = {
device = "/mnt/data/AppData/prowlarr/config"; device = "/mnt/data/AppData/prowlarr/config";
fsType = "none"; fsType = "none";
options = [ "bind" ]; options = [ "bind" "nofail" ];
}; };
# systemd derives RequiresMountsFor from the unit's own paths, which here is
# only /var/lib/prowlarr on the eMMC — so without this prowlarr starts happily
# with the array absent and writes its state onto the 29G OS disk. Pin it to
# the array so it fails loudly instead.
systemd.services.prowlarr.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
} }
+72 -9
View File
@@ -1,21 +1,84 @@
{ ... }: { config, ... }:
# SABnzbd — usenet downloader. Reuses the config migrated from the old # SABnzbd — usenet downloader. Migrated off a reused hand-authored ini
# ZimaOS docker stack (servers/API key/history already set up) by pointing # (servers/API key/history originally imported from the old ZimaOS docker
# straight at the real ini instead of generating a fresh NixOS-managed one. # stack) onto NixOS-managed `settings`, per the module's own deprecation
# Runs as the module's default dedicated `sabnzbd` system user — after first # notice for `configFile`. Only the values that differ from SABnzbd's own
# deploy, chown the migrated config dir to it (see README/CLAUDE notes): # built-in defaults are declared here — everything else falls back to the
# chown -R sabnzbd:sabnzbd /mnt/data/AppData/sabnzbd/config # same defaults SABnzbd was already using.
#
# `admin_dir`/`log_dir` MUST stay absolute: the module writes the merged ini
# to /var/lib/sabnzbd/sabnzbd.ini (eMMC), and both dirs are otherwise
# relative to wherever the ini lives. Pointing them back at the ORIGINAL
# /mnt/data location keeps the existing download queue/history database
# (admin_dir) intact — a relative default here would silently "reset"
# SABnzbd to an empty queue on first switch, even though nothing was deleted.
{ {
services.sabnzbd = { services.sabnzbd = {
enable = true; enable = true;
configFile = "/mnt/data/AppData/sabnzbd/config/sabnzbd.ini"; allowConfigWrite = true; # let sabnzbd keep saving state (queue, wizard flags, ...)
allowConfigWrite = true; # real pre-existing state — let sabnzbd keep saving it settings = {
misc = {
host = "::";
port = 8085;
web_color = "Night";
enable_https = false;
url_base = "/sabnzbd";
cache_limit = "1G";
download_dir = "/mnt/data/HighSeas/Downloads/Incomplete";
complete_dir = "/mnt/data/HighSeas/Downloads";
admin_dir = "/mnt/data/AppData/sabnzbd/config/admin";
log_dir = "/mnt/data/AppData/sabnzbd/config/logs";
# Verbatim from the migrated ini — includes a pre-existing "izma ace"
# (missing comma) left as-is rather than silently "fixed" here.
unwanted_extensions = "exe, com, bat, ink, js, vbs, ps1, sh, py, php, pl, rb, jar, class, swf, scr, hta, msi, msp, msu, pif, ink, chm, vb, vba, ws, wsf, wsh, xll, docm, dotm, xlsm, xltm, pptm, potm, ppsm, sldm, thmx, xlam, ppam, docb, dotb, xltb, mht, mhtml, url, iqylink, deamon, elf, dmg, iso, cue, nrg, img, udf, wim, vhd, vhdx, vmdk, ova, tf, pb, savedmodel, h5, ckpt, meta, index, data-00000-of-00001, vocab, config, model, pt, tgz, tar.gz, bz2, xz, izma ace, arc, cab, jar, izh, pea, sit, sitx, sqx, zoo, pak, upk, bsa, dat, nzb, nzbs, nzb.gz, nzb.bz2";
host_whitelist = "cd1a98d07ece, helium, sabnzbd.jupiter.sol, localhost, jupiter, jupiter.sol";
username = "@sabnzbd_web_username@";
password = "@sabnzbd_web_password@";
api_key = "@sabnzbd_api_key@";
nzb_key = "@sabnzbd_nzb_key@";
};
servers."news.eweka.nl" = {
name = "news.eweka.nl";
displayname = "news.eweka.nl";
host = "news.eweka.nl";
port = 563;
connections = 8;
ssl = true;
ssl_verify = "strict";
username = "@sabnzbd_eweka_username@";
password = "@sabnzbd_eweka_password@";
};
categories = {
"*" = { name = "*"; order = 0; pp = 3; };
movies = { name = "movies"; order = 1; script = "Default"; priority = -100; };
tv = { name = "tv"; order = 2; script = "Default"; priority = -100; };
audio = { name = "audio"; order = 3; script = "Default"; priority = -100; };
software = { name = "software"; order = 4; script = "Default"; priority = -100; };
prowlarr = { name = "prowlarr"; order = 5; script = "Default"; priority = -100; };
xxx = { name = "xxx"; order = 6; script = "Default"; priority = -100; };
readarr = { name = "readarr"; order = 7; script = "Default"; priority = -100; };
};
};
secretValues = {
"@sabnzbd_web_username@" = config.sops.secrets.sabnzbd_web_username.path;
"@sabnzbd_web_password@" = config.sops.secrets.sabnzbd_web_password.path;
"@sabnzbd_api_key@" = config.sops.secrets.sabnzbd_api_key.path;
"@sabnzbd_nzb_key@" = config.sops.secrets.sabnzbd_nzb_key.path;
"@sabnzbd_eweka_username@" = config.sops.secrets.sabnzbd_eweka_username.path;
"@sabnzbd_eweka_password@" = config.sops.secrets.sabnzbd_eweka_password.path;
};
}; };
# Write access to the shared downloads dir (owned darman:users on disk). # Write access to the shared downloads dir (owned darman:users on disk).
users.users.sabnzbd.extraGroups = [ "users" ]; users.users.sabnzbd.extraGroups = [ "users" ];
# download/complete/admin dirs all live on the array, but systemd only
# derives RequiresMountsFor from /var/lib/sabnzbd (eMMC) — so with the array
# absent sabnzbd would start and download onto the 29G OS disk.
systemd.services.sabnzbd.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
systemd.services.fix-downloads-perms.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
# SABnzbd hardcodes completed job folders to 0700 on every job, ignoring # SABnzbd hardcodes completed job folders to 0700 on every job, ignoring
# the ini's `umask` (that only covers files during unpack, not the job # the ini's `umask` (that only covers files during unpack, not the job
# dir itself). setgid on Downloads keeps the group as "users" but perm # dir itself). setgid on Downloads keeps the group as "users" but perm
+7 -1
View File
@@ -11,12 +11,18 @@
{ {
services.seerr.enable = true; services.seerr.enable = true;
# `nofail` for the same reason as prowlarr.nix — see the comment there: an
# array-backed bind without it fails local-fs.target and boots to an
# unusable emergency shell.
fileSystems."/var/lib/private/seerr" = { fileSystems."/var/lib/private/seerr" = {
device = "/mnt/data/AppData/seerr"; device = "/mnt/data/AppData/seerr";
fsType = "none"; fsType = "none";
options = [ "bind" ]; options = [ "bind" "nofail" ];
}; };
# Only /var/lib/seerr (eMMC) is picked up automatically; pin to the array.
systemd.services.seerr.unitConfig.RequiresMountsFor = [ "/mnt/data" ];
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d /mnt/data/AppData/seerr 0755 darman users -" "d /mnt/data/AppData/seerr 0755 darman users -"
]; ];
-13
View File
@@ -5,19 +5,6 @@
# to declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix). # to declare `sops.secrets.tailscale_authkey` (see each host's secrets.nix).
# Not for the VM (no sops). # Not for the VM (no sops).
{ {
# TEMPORARY: nixpkgs bumped tailscale 1.98.8->1.98.9 without updating
# vendorHash (NixOS/nixpkgs#545860, fixed on the `release-26.05` branch
# 2026-07-26 but not yet promoted to the `nixos-26.05` channel branch this
# flake tracks). Remove this override once `nix flake lock --update-input
# nixpkgs` picks up a nixos-26.05 rev at/after that fix.
nixpkgs.overlays = [
(final: prev: {
tailscale = prev.tailscale.overrideAttrs (old: {
vendorHash = "sha256-Sd2iLJ7eDfDYdIRuW4xuiKgzhQWJWGAnz97FJWrVRlE=";
});
})
];
services.tailscale = { services.tailscale = {
enable = true; enable = true;
openFirewall = true; # UDP 41641 for direct connections openFirewall = true; # UDP 41641 for direct connections