Compare commits
52 Commits
0e75c0036f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d839afb70b | ||
|
|
96a0162b4e | ||
| 4bc5d57fa6 | |||
| 1403c9d3bc | |||
| 48ac68c297 | |||
| fc548a137f | |||
| 9ea45d4558 | |||
| cebdd3ea96 | |||
| df57d636f5 | |||
|
2f09c800e0
|
|||
| 2c67b9729b | |||
|
7d77926f8a
|
|||
|
2aa401a9ef
|
|||
|
92f44d6c71
|
|||
|
daae941d36
|
|||
|
5990319445
|
|||
|
55fda4b5ee
|
|||
|
20ca945436
|
|||
|
aecd9002b0
|
|||
|
48efd7fcf7
|
|||
|
0289ce0856
|
|||
|
5b98e6197e
|
|||
|
a0085187a9
|
|||
|
0c70c2b2b4
|
|||
|
f28dd190bf
|
|||
|
a01452bd59
|
|||
|
140330e98d
|
|||
|
28df0a7f06
|
|||
| 4aa7c2a44b | |||
|
e0c86a956e
|
|||
|
e904e249ed
|
|||
|
55001bbe75
|
|||
| 053160fb36 | |||
| 19ea2dc02b | |||
|
dbf6d2f832
|
|||
|
acfa08fc2e
|
|||
|
1f2886d35c
|
|||
|
674d3cf539
|
|||
|
bef4ac7ddc
|
|||
|
12469de580
|
|||
| dad3867144 | |||
|
7ee55eca6b
|
|||
|
100999734b
|
|||
|
ce1c335230
|
|||
|
e9ce1ce0a2
|
|||
|
a3a6700106
|
|||
|
75319256f3
|
|||
|
c74d356595
|
|||
|
ae03c2f288
|
|||
|
0d87f90657
|
|||
|
d1e9c92423
|
|||
|
4f33b16411
|
@@ -112,6 +112,7 @@ Each service file in `services/` follows this structure:
|
|||||||
- **Hugepages**: Services needing large pages declare their budget in `service-configs.nix` under `hugepages_2m.services`. The kernel sysctl is set automatically from the total.
|
- **Hugepages**: Services needing large pages declare their budget in `service-configs.nix` under `hugepages_2m.services`. The kernel sysctl is set automatically from the total.
|
||||||
- **Domain**: Primary domain is `sigkill.computer`. Old domain `gardling.com` redirects automatically.
|
- **Domain**: Primary domain is `sigkill.computer`. Old domain `gardling.com` redirects automatically.
|
||||||
- **Hardened kernel**: Uses `_hardened` kernel. Security-sensitive defaults apply.
|
- **Hardened kernel**: Uses `_hardened` kernel. Security-sensitive defaults apply.
|
||||||
|
- **PostgreSQL as central database**: All services that support PostgreSQL MUST use it instead of embedded databases (H2, SQLite, etc.). Connect via Unix socket with peer auth when possible (JDBC services can use junixsocket). The PostgreSQL instance is declared in `services/postgresql.nix` with ZFS-backed storage. Use `ensureDatabases`/`ensureUsers` to auto-create databases and roles.
|
||||||
|
|
||||||
### Test Pattern
|
### Test Pattern
|
||||||
Tests use `pkgs.testers.runNixOSTest` (NixOS VM tests):
|
Tests use `pkgs.testers.runNixOSTest` (NixOS VM tests):
|
||||||
|
|||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# server-config (archived)
|
||||||
|
|
||||||
|
This repository has been unified with its sibling `dotfiles` into
|
||||||
|
[**titaniumtown/nixos**](https://git.sigkill.computer/titaniumtown/nixos).
|
||||||
|
|
||||||
|
The final pre-unification commit is tagged `final-before-unify`.
|
||||||
|
|
||||||
|
See the new repo's `README.md` and `AGENTS.md` for:
|
||||||
|
|
||||||
|
- current flake layout (hosts: mreow, yarn, muffin)
|
||||||
|
- deploy workflow
|
||||||
|
- git-crypt / agenix setup
|
||||||
|
|
||||||
|
Do **not** push new commits here — CI has been disabled, and muffin's harmonia
|
||||||
|
binary-cache no longer serves paths from `/var/lib/dotfiles-deploy/`.
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
./services/soulseek.nix
|
./services/soulseek.nix
|
||||||
|
|
||||||
./services/llama-cpp.nix
|
# ./services/llama-cpp.nix
|
||||||
./services/trilium.nix
|
./services/trilium.nix
|
||||||
|
|
||||||
./services/ups.nix
|
./services/ups.nix
|
||||||
@@ -71,6 +71,8 @@
|
|||||||
./services/mollysocket.nix
|
./services/mollysocket.nix
|
||||||
|
|
||||||
./services/harmonia.nix
|
./services/harmonia.nix
|
||||||
|
|
||||||
|
./services/ddns-updater.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
# Hosts entries for CI/CD deploy targets
|
# Hosts entries for CI/CD deploy targets
|
||||||
@@ -131,8 +133,10 @@
|
|||||||
boot.kernel.sysctl."vm.nr_hugepages" = service_configs.hugepages_2m.total_pages;
|
boot.kernel.sysctl."vm.nr_hugepages" = service_configs.hugepages_2m.total_pages;
|
||||||
|
|
||||||
boot = {
|
boot = {
|
||||||
# 6.12 LTS until 2026
|
# 6.12 LTS until 2027-03. Kernel 6.18 causes a reproducible ZFS deadlock
|
||||||
kernelPackages = pkgs.linuxPackages_6_12_hardened;
|
# in dbuf_evict due to page allocator changes (__free_frozen_pages).
|
||||||
|
# https://github.com/openzfs/zfs/issues/18426
|
||||||
|
kernelPackages = pkgs.linuxPackages_6_12;
|
||||||
|
|
||||||
loader = {
|
loader = {
|
||||||
# Use the systemd-boot EFI boot loader.
|
# Use the systemd-boot EFI boot loader.
|
||||||
|
|||||||
88
flake.lock
generated
88
flake.lock
generated
@@ -27,16 +27,17 @@
|
|||||||
},
|
},
|
||||||
"arr-init": {
|
"arr-init": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1774681523,
|
"lastModified": 1776401121,
|
||||||
"narHash": "sha256-K49RohIwbgzVeOdStfVDO83qy5K5ZLKWk4EsHJKj/k4=",
|
"narHash": "sha256-BELV1YMBuLL0aQNQ3SLvSLq8YN5h2o1jcrwz1+Zt32Q=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "f8475f6cb4d4d4df99002d07cf9583fb33b87876",
|
"rev": "6dde2a3e0d087208b8084b61113707c5533c4c2d",
|
||||||
"revCount": 11,
|
"revCount": 19,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "ssh://gitea@git.gardling.com/titaniumtown/arr-init"
|
"url": "ssh://gitea@git.gardling.com/titaniumtown/arr-init"
|
||||||
},
|
},
|
||||||
@@ -193,7 +194,25 @@
|
|||||||
},
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems_5"
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_6"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1731533236,
|
||||||
@@ -304,11 +323,11 @@
|
|||||||
"rust-overlay": "rust-overlay"
|
"rust-overlay": "rust-overlay"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775510693,
|
"lastModified": 1776248416,
|
||||||
"narHash": "sha256-gZfJ07j/oOciDi8mF/V8QTm7YCeDcusNSMZzBFi8OUM=",
|
"narHash": "sha256-TC6yzbCAex1pDfqUZv9u8fVm8e17ft5fNrcZ0JRDOIQ=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "lanzaboote",
|
"repo": "lanzaboote",
|
||||||
"rev": "3fe0ae8cb285e0ad101a9675f4190d455fb05e85",
|
"rev": "18e9e64bae15b828c092658335599122a6db939b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -325,11 +344,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775614184,
|
"lastModified": 1776301820,
|
||||||
"narHash": "sha256-OYwr36LLVIeEqccN1mJ2k6vCsFocboCQJnbtne415Ig=",
|
"narHash": "sha256-Yr3JRZ05PNmX4sR2Ak7e0jT+oCQgTAAML7FUoyTmitk=",
|
||||||
"owner": "TheTom",
|
"owner": "TheTom",
|
||||||
"repo": "llama-cpp-turboquant",
|
"repo": "llama-cpp-turboquant",
|
||||||
"rev": "eea498c42716519e58baf2d9600d2e2b41839255",
|
"rev": "1073622985bb68075472474b4b0fdfcdabcfc9d0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -365,14 +384,14 @@
|
|||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
],
|
],
|
||||||
"systems": "systems_3"
|
"systems": "systems_4"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775531897,
|
"lastModified": 1776310483,
|
||||||
"narHash": "sha256-3NIpnV1HxBCwi00iMvj9KcqXkM0VNA72KABj8g0cFFs=",
|
"narHash": "sha256-xMFl+umxGmo5VEgcZcXT5Dk9sXU5WyTRz1Olpywr/60=",
|
||||||
"owner": "Infinidoge",
|
"owner": "Infinidoge",
|
||||||
"repo": "nix-minecraft",
|
"repo": "nix-minecraft",
|
||||||
"rev": "8c7693880cb861e60adeab5480f02dc3e7a390f6",
|
"rev": "74abd91054e2655d6c392428a27e5d27edd5e6bf",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -399,11 +418,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775305101,
|
"lastModified": 1776221942,
|
||||||
"narHash": "sha256-/74n1oQPtKG52Yw41cbToxspxHbYz6O3vi+XEw16Qe8=",
|
"narHash": "sha256-FbQAeVNi7G4v3QCSThrSAAvzQTmrmyDLiHNPvTF2qFM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "36a601196c4ebf49e035270e10b2d103fe39076b",
|
"rev": "1766437c5509f444c1b15331e82b8b6a9b967000",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -503,7 +522,7 @@
|
|||||||
"nixpkgs": [
|
"nixpkgs": [
|
||||||
"nixpkgs"
|
"nixpkgs"
|
||||||
],
|
],
|
||||||
"systems": "systems_4"
|
"systems": "systems_5"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1771989937,
|
"lastModified": 1771989937,
|
||||||
@@ -624,11 +643,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775444042,
|
"lastModified": 1776306894,
|
||||||
"narHash": "sha256-cg19ipIlZaLYgs/5ZPFcDDuOcZlGzfprB5xS4x7bVM4=",
|
"narHash": "sha256-l4N3O1cfXiQCHJGspAkg6WlZyOFBTbLXhi8Anf8jB0g=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "srvos",
|
"repo": "srvos",
|
||||||
"rev": "64c9cc6a274dac7d08c4d53494ffa4acf906e287",
|
"rev": "01d98209264c78cb323b636d7ab3fe8e7a8b60c7",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -712,14 +731,29 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"systems_6": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"trackerlist": {
|
"trackerlist": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775599784,
|
"lastModified": 1776290985,
|
||||||
"narHash": "sha256-ZapxbiFEYjJV2nhdowHQ/8+c8Jd5fpBIEKDiPEmyNgI=",
|
"narHash": "sha256-eNWDOLBA0vk1TiKqse71siIAgLycjvBFDw35eAtnUPs=",
|
||||||
"owner": "ngosang",
|
"owner": "ngosang",
|
||||||
"repo": "trackerslist",
|
"repo": "trackerslist",
|
||||||
"rev": "6cc71b5b65349081bb713719f5142c200438a327",
|
"rev": "9bb380b3c2a641a3289f92dedef97016f2e47f36",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -730,7 +764,7 @@
|
|||||||
},
|
},
|
||||||
"utils": {
|
"utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems_2"
|
"systems": "systems_3"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1731533236,
|
"lastModified": 1731533236,
|
||||||
@@ -779,7 +813,7 @@
|
|||||||
},
|
},
|
||||||
"ytbn-graphing-software": {
|
"ytbn-graphing-software": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils_2",
|
||||||
"nixpkgs": "nixpkgs_3",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"rust-overlay": "rust-overlay_2"
|
"rust-overlay": "rust-overlay_2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,22 @@
|
|||||||
group = "caddy";
|
group = "caddy";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Njalla API token (NJALLA_API_TOKEN=...) for Caddy DNS-01 challenge
|
||||||
|
njalla-api-token-env = {
|
||||||
|
file = ../secrets/njalla-api-token-env.age;
|
||||||
|
mode = "0400";
|
||||||
|
owner = "caddy";
|
||||||
|
group = "caddy";
|
||||||
|
};
|
||||||
|
|
||||||
|
# ddns-updater config.json with Njalla provider credentials
|
||||||
|
ddns-updater-config = {
|
||||||
|
file = ../secrets/ddns-updater-config.age;
|
||||||
|
mode = "0400";
|
||||||
|
owner = "ddns-updater";
|
||||||
|
group = "ddns-updater";
|
||||||
|
};
|
||||||
|
|
||||||
jellyfin-api-key = {
|
jellyfin-api-key = {
|
||||||
file = ../secrets/jellyfin-api-key.age;
|
file = ../secrets/jellyfin-api-key.age;
|
||||||
mode = "0400";
|
mode = "0400";
|
||||||
@@ -152,6 +168,15 @@
|
|||||||
group = "gitea-runner";
|
group = "gitea-runner";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Git-crypt symmetric key for the new unified nixos repo (Phase 5 of the unify migration).
|
||||||
|
# Added additively here so muffin can decrypt nixos's secrets once Phase 6 cuts CI over.
|
||||||
|
git-crypt-key-nixos = {
|
||||||
|
file = ../secrets/git-crypt-key-nixos.age;
|
||||||
|
mode = "0400";
|
||||||
|
owner = "gitea-runner";
|
||||||
|
group = "gitea-runner";
|
||||||
|
};
|
||||||
|
|
||||||
# Gitea Actions runner registration token
|
# Gitea Actions runner registration token
|
||||||
gitea-runner-token = {
|
gitea-runner-token = {
|
||||||
file = ../secrets/gitea-runner-token.age;
|
file = ../secrets/gitea-runner-token.age;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let
|
|||||||
parent=''${1%%[0-9]*}
|
parent=''${1%%[0-9]*}
|
||||||
dev="/sys/block/$parent"
|
dev="/sys/block/$parent"
|
||||||
[ -d "$dev/queue/iosched" ] || exit 0
|
[ -d "$dev/queue/iosched" ] || exit 0
|
||||||
echo 15000 > "$dev/queue/iosched/read_expire"
|
echo 500 > "$dev/queue/iosched/read_expire"
|
||||||
echo 15000 > "$dev/queue/iosched/write_expire"
|
echo 15000 > "$dev/queue/iosched/write_expire"
|
||||||
echo 128 > "$dev/queue/iosched/fifo_batch"
|
echo 128 > "$dev/queue/iosched/fifo_batch"
|
||||||
echo 16 > "$dev/queue/iosched/writes_starved"
|
echo 16 > "$dev/queue/iosched/writes_starved"
|
||||||
@@ -36,11 +36,17 @@ in
|
|||||||
hardware.cpu.amd.updateMicrocode = true;
|
hardware.cpu.amd.updateMicrocode = true;
|
||||||
hardware.enableRedistributableFirmware = true;
|
hardware.enableRedistributableFirmware = true;
|
||||||
|
|
||||||
# HDD I/O tuning for torrent seeding workload (high-concurrency random reads).
|
# HDD I/O tuning for torrent seeding workload (high-concurrency random reads)
|
||||||
|
# sharing the pool with latency-sensitive sequential reads (Jellyfin playback).
|
||||||
#
|
#
|
||||||
# mq-deadline sorts requests into elevator sweeps, reducing seek distance.
|
# mq-deadline sorts requests into elevator sweeps, reducing seek distance.
|
||||||
# Aggressive deadlines (15s) let the scheduler accumulate more ops before dispatching,
|
# read_expire=500ms keeps reads bounded so a Jellyfin segment can't queue for
|
||||||
# maximizing coalescence — latency is irrelevant since torrent peers tolerate 30-60s.
|
# seconds behind a torrent burst; write_expire=15s lets the scheduler batch
|
||||||
|
# writes for coalescence (torrent writes are async and tolerate delay).
|
||||||
|
# The bulk of read coalescence already happens above the scheduler via ZFS
|
||||||
|
# aggregation (zfs_vdev_aggregation_limit=4M, read_gap_limit=128K,
|
||||||
|
# async_read_max=32), so the scheduler deadline only needs to be large enough
|
||||||
|
# to keep the elevator sweep coherent -- 500ms is plenty on rotational disks.
|
||||||
# fifo_batch=128 keeps sweeps long; writes_starved=16 heavily favors reads.
|
# fifo_batch=128 keeps sweeps long; writes_starved=16 heavily favors reads.
|
||||||
# 4 MiB readahead matches libtorrent piece extent affinity for sequential prefetch.
|
# 4 MiB readahead matches libtorrent piece extent affinity for sequential prefetch.
|
||||||
#
|
#
|
||||||
|
|||||||
111
modules/lib.nix
111
modules/lib.nix
@@ -59,8 +59,12 @@ inputs.nixpkgs.lib.extend (
|
|||||||
{ pkgs, config, ... }:
|
{ pkgs, config, ... }:
|
||||||
{
|
{
|
||||||
systemd.services."${serviceName}-mounts" = {
|
systemd.services."${serviceName}-mounts" = {
|
||||||
wants = [ "zfs.target" ] ++ lib.optionals (zpool != "") [ "zfs-import-${zpool}.service" ];
|
wants = [
|
||||||
after = lib.optionals (zpool != "") [ "zfs-import-${zpool}.service" ];
|
"zfs.target"
|
||||||
|
"zfs-mount.service"
|
||||||
|
]
|
||||||
|
++ lib.optionals (zpool != "") [ "zfs-import-${zpool}.service" ];
|
||||||
|
after = [ "zfs-mount.service" ] ++ lib.optionals (zpool != "") [ "zfs-import-${zpool}.service" ];
|
||||||
before = [ "${serviceName}.service" ];
|
before = [ "${serviceName}.service" ];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -176,5 +180,108 @@ inputs.nixpkgs.lib.extend (
|
|||||||
after = [ "${serviceName}-file-perms.service" ];
|
after = [ "${serviceName}-file-perms.service" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
# Creates a Caddy virtualHost with reverse_proxy to a local or VPN-namespaced port.
|
||||||
|
# Use `subdomain` for "<name>.${domain}" or `domain` for a full custom domain.
|
||||||
|
# Exactly one of `subdomain` or `domain` must be provided.
|
||||||
|
mkCaddyReverseProxy =
|
||||||
|
{
|
||||||
|
subdomain ? null,
|
||||||
|
domain ? null,
|
||||||
|
port,
|
||||||
|
auth ? false,
|
||||||
|
vpn ? false,
|
||||||
|
}:
|
||||||
|
assert (subdomain != null) != (domain != null);
|
||||||
|
{ config, ... }:
|
||||||
|
let
|
||||||
|
vhostDomain = if domain != null then domain else "${subdomain}.${service_configs.https.domain}";
|
||||||
|
upstream =
|
||||||
|
if vpn then
|
||||||
|
"${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString port}"
|
||||||
|
else
|
||||||
|
":${builtins.toString port}";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
services.caddy.virtualHosts."${vhostDomain}".extraConfig = lib.concatStringsSep "\n" (
|
||||||
|
lib.optional auth "import ${config.age.secrets.caddy_auth.path}" ++ [ "reverse_proxy ${upstream}" ]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
# Creates a fail2ban jail with systemd journal backend.
|
||||||
|
# Covers the common pattern: journal-based detection, http/https ports, default thresholds.
|
||||||
|
mkFail2banJail =
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
unitName ? "${name}.service",
|
||||||
|
failregex,
|
||||||
|
}:
|
||||||
|
{ ... }:
|
||||||
|
{
|
||||||
|
services.fail2ban.jails.${name} = {
|
||||||
|
enabled = true;
|
||||||
|
settings = {
|
||||||
|
backend = "systemd";
|
||||||
|
port = "http,https";
|
||||||
|
# defaults: maxretry=5, findtime=10m, bantime=10m
|
||||||
|
};
|
||||||
|
filter.Definition = {
|
||||||
|
inherit failregex;
|
||||||
|
ignoreregex = "";
|
||||||
|
journalmatch = "_SYSTEMD_UNIT=${unitName}";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Creates a hardened Grafana annotation daemon service.
|
||||||
|
# Provides DynamicUser, sandboxing, state directory, and GRAFANA_URL/STATE_FILE automatically.
|
||||||
|
mkGrafanaAnnotationService =
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
script,
|
||||||
|
after ? [ ],
|
||||||
|
environment ? { },
|
||||||
|
loadCredential ? null,
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
systemd.services."${name}-annotations" = {
|
||||||
|
inherit description;
|
||||||
|
after = [
|
||||||
|
"network.target"
|
||||||
|
"grafana.service"
|
||||||
|
]
|
||||||
|
++ after;
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
ExecStart = "${pkgs.python3}/bin/python3 ${script}";
|
||||||
|
Restart = "always";
|
||||||
|
RestartSec = "10s";
|
||||||
|
DynamicUser = true;
|
||||||
|
StateDirectory = "${name}-annotations";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
RestrictAddressFamilies = [
|
||||||
|
"AF_INET"
|
||||||
|
"AF_INET6"
|
||||||
|
];
|
||||||
|
MemoryDenyWriteExecute = true;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (loadCredential != null) {
|
||||||
|
LoadCredential = loadCredential;
|
||||||
|
};
|
||||||
|
environment = {
|
||||||
|
GRAFANA_URL = "http://127.0.0.1:${toString service_configs.ports.private.grafana.port}";
|
||||||
|
STATE_FILE = "/var/lib/${name}-annotations/state.json";
|
||||||
|
}
|
||||||
|
// environment;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Shell command to extract an API key from an *arr config.xml file.
|
||||||
|
# Returns a string suitable for $() command substitution in shell scripts.
|
||||||
|
extractArrApiKey =
|
||||||
|
configXmlPath: "${lib.getExe pkgs.gnugrep} -oP '(?<=<ApiKey>)[^<]+' ${configXmlPath}";
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,12 +13,89 @@
|
|||||||
# disable coredumps
|
# disable coredumps
|
||||||
systemd.coredump.enable = false;
|
systemd.coredump.enable = false;
|
||||||
|
|
||||||
# The hardened kernel defaults kernel.unprivileged_userns_clone to 0, which
|
# Needed for Nix sandbox UID/GID mapping inside derivation builds.
|
||||||
# prevents the Nix sandbox from mapping UIDs/GIDs. Without this, any derivation
|
# See https://github.com/NixOS/nixpkgs/issues/287194
|
||||||
# that calls `id` in its build phase (e.g. logrotate checkPhase) fails when not
|
|
||||||
# served from the binary cache. See https://github.com/NixOS/nixpkgs/issues/287194
|
|
||||||
security.unprivilegedUsernsClone = true;
|
security.unprivilegedUsernsClone = true;
|
||||||
|
|
||||||
|
# Disable kexec to prevent replacing the running kernel at runtime.
|
||||||
|
security.protectKernelImage = true;
|
||||||
|
|
||||||
|
# Kernel hardening boot parameters. These recover most of the runtime-
|
||||||
|
# configurable protections that the linux-hardened patchset provided.
|
||||||
|
boot.kernelParams = [
|
||||||
|
# Zero all page allocator pages on free / alloc. Prevents info leaks
|
||||||
|
# and use-after-free from seeing stale data. Modest CPU overhead.
|
||||||
|
"init_on_alloc=1"
|
||||||
|
"init_on_free=1"
|
||||||
|
|
||||||
|
# Prevent SLUB allocator from merging caches with similar size/flags.
|
||||||
|
# Keeps different kernel object types in separate slabs, making heap
|
||||||
|
# exploitation (type confusion, spray, use-after-free) significantly harder.
|
||||||
|
"slab_nomerge"
|
||||||
|
|
||||||
|
# Randomize order of pages returned by the buddy allocator.
|
||||||
|
"page_alloc.shuffle=1"
|
||||||
|
|
||||||
|
# Disable debugfs entirely (exposes kernel internals).
|
||||||
|
"debugfs=off"
|
||||||
|
|
||||||
|
# Disable legacy vsyscall emulation (unused by any modern glibc).
|
||||||
|
"vsyscall=none"
|
||||||
|
|
||||||
|
# Strict IOMMU TLB invalidation (no batching). Prevents DMA-capable
|
||||||
|
# devices from accessing stale mappings after unmap.
|
||||||
|
"iommu.strict=1"
|
||||||
|
];
|
||||||
|
|
||||||
|
boot.kernel.sysctl = {
|
||||||
|
# Immediately reboot on kernel oops (don't leave a compromised
|
||||||
|
# kernel running). Negative value = reboot without delay.
|
||||||
|
"kernel.panic" = -1;
|
||||||
|
|
||||||
|
# Hide kernel pointers from all processes, including CAP_SYSLOG.
|
||||||
|
# Prevents info leaks used to defeat KASLR.
|
||||||
|
"kernel.kptr_restrict" = 2;
|
||||||
|
|
||||||
|
# Disable bpf() JIT compiler (eliminates JIT spray attack vector).
|
||||||
|
"net.core.bpf_jit_enable" = false;
|
||||||
|
|
||||||
|
# Disable ftrace (kernel function tracer) at runtime.
|
||||||
|
"kernel.ftrace_enabled" = false;
|
||||||
|
|
||||||
|
# Strict reverse-path filtering: drop packets arriving on an interface
|
||||||
|
# where the source address isn't routable back via that interface.
|
||||||
|
"net.ipv4.conf.all.rp_filter" = 1;
|
||||||
|
"net.ipv4.conf.default.rp_filter" = 1;
|
||||||
|
"net.ipv4.conf.all.log_martians" = true;
|
||||||
|
"net.ipv4.conf.default.log_martians" = true;
|
||||||
|
|
||||||
|
# Ignore ICMP redirects (prevents route table poisoning).
|
||||||
|
"net.ipv4.conf.all.accept_redirects" = false;
|
||||||
|
"net.ipv4.conf.all.secure_redirects" = false;
|
||||||
|
"net.ipv4.conf.default.accept_redirects" = false;
|
||||||
|
"net.ipv4.conf.default.secure_redirects" = false;
|
||||||
|
"net.ipv6.conf.all.accept_redirects" = false;
|
||||||
|
"net.ipv6.conf.default.accept_redirects" = false;
|
||||||
|
|
||||||
|
# Don't send ICMP redirects (we are not a router).
|
||||||
|
"net.ipv4.conf.all.send_redirects" = false;
|
||||||
|
"net.ipv4.conf.default.send_redirects" = false;
|
||||||
|
|
||||||
|
# Ignore broadcast ICMP (SMURF amplification mitigation).
|
||||||
|
"net.ipv4.icmp_echo_ignore_broadcasts" = true;
|
||||||
|
|
||||||
|
# Filesystem hardening: prevent hardlink/symlink-based attacks.
|
||||||
|
# protected_hardlinks/symlinks: block unprivileged creation of hard/symlinks
|
||||||
|
# to files the user doesn't own (prevents TOCTOU privilege escalation).
|
||||||
|
# protected_fifos/regular (level 2): restrict opening FIFOs and regular files
|
||||||
|
# in world-writable sticky directories to owner/group match only.
|
||||||
|
# Also required for systemd-tmpfiles to chmod hardlinked files.
|
||||||
|
"fs.protected_hardlinks" = true;
|
||||||
|
"fs.protected_symlinks" = true;
|
||||||
|
"fs.protected_fifos" = 2;
|
||||||
|
"fs.protected_regular" = 2;
|
||||||
|
};
|
||||||
|
|
||||||
services = {
|
services = {
|
||||||
dbus.implementation = "broker";
|
dbus.implementation = "broker";
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,15 +1,39 @@
|
|||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
|
lib,
|
||||||
service_configs,
|
service_configs,
|
||||||
pkgs,
|
pkgs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
let
|
||||||
|
# Total RAM in bytes (from /proc/meminfo: 65775836 KiB).
|
||||||
|
totalRamBytes = 65775836 * 1024;
|
||||||
|
|
||||||
|
# Hugepage reservations that the kernel carves out before ZFS can use them.
|
||||||
|
hugepages2mBytes = service_configs.hugepages_2m.total_pages * 2 * 1024 * 1024;
|
||||||
|
hugepages1gBytes = 3 * 1024 * 1024 * 1024; # 3x 1G pages for RandomX (xmrig.nix)
|
||||||
|
totalHugepageBytes = hugepages2mBytes + hugepages1gBytes;
|
||||||
|
|
||||||
|
# ARC max: 60% of RAM remaining after hugepages. Leaves headroom for
|
||||||
|
# application RSS (PostgreSQL, qBittorrent, Jellyfin, Grafana, etc.),
|
||||||
|
# kernel slabs, and page cache.
|
||||||
|
arcMaxBytes = (totalRamBytes - totalHugepageBytes) * 60 / 100;
|
||||||
|
in
|
||||||
{
|
{
|
||||||
boot.zfs.package = pkgs.zfs;
|
boot.zfs.package = pkgs.zfs_2_4;
|
||||||
boot.initrd.kernelModules = [ "zfs" ];
|
boot.initrd.kernelModules = [ "zfs" ];
|
||||||
|
|
||||||
boot.kernelParams = [
|
boot.kernelParams = [
|
||||||
"zfs.zfs_txg_timeout=120" # longer TXG open time = larger sequential writes
|
# 120s TXG timeout: batch more dirty data per transaction group so the
|
||||||
|
# HDD pool (hdds) writes larger, sequential I/Os instead of many small syncs.
|
||||||
|
# This is a global setting (no per-pool control); the SSD pool (tank) syncs
|
||||||
|
# infrequently but handles it fine since SSDs don't suffer from seek overhead.
|
||||||
|
"zfs.zfs_txg_timeout=120"
|
||||||
|
|
||||||
|
# Cap ARC to prevent it from claiming memory reserved for hugepages.
|
||||||
|
# Without this, ZFS auto-sizes c_max to ~62 GiB on a 64 GiB system,
|
||||||
|
# ignoring the 11.5 GiB of hugepage reservations.
|
||||||
|
"zfs.zfs_arc_max=${toString arcMaxBytes}"
|
||||||
|
|
||||||
# vdev I/O scheduler: feed more concurrent reads to the block scheduler so
|
# vdev I/O scheduler: feed more concurrent reads to the block scheduler so
|
||||||
# mq-deadline has a larger pool of requests to sort and merge into elevator sweeps.
|
# mq-deadline has a larger pool of requests to sort and merge into elevator sweeps.
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
From 320c29c2dbe3c8df56374a9ec19a7fe5c124d4f8 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Piotr Wilkin <piotr.wilkin@syndatis.com>
|
|
||||||
Date: Tue, 7 Apr 2026 00:54:00 +0200
|
|
||||||
Subject: [PATCH 1/2] YATF (Yet Another Tokenizer Fix) for Gemma 4. With tests!
|
|
||||||
|
|
||||||
---
|
|
||||||
convert_hf_to_gguf_update.py | 1 +
|
|
||||||
models/ggml-vocab-gemma-4.gguf | Bin 0 -> 15776467 bytes
|
|
||||||
models/ggml-vocab-gemma-4.gguf.inp | 111 +++++++++++++++++++++++++++++
|
|
||||||
models/ggml-vocab-gemma-4.gguf.out | 46 ++++++++++++
|
|
||||||
src/llama-vocab.cpp | 13 +++-
|
|
||||||
tests/CMakeLists.txt | 1 +
|
|
||||||
6 files changed, 170 insertions(+), 2 deletions(-)
|
|
||||||
create mode 100644 models/ggml-vocab-gemma-4.gguf
|
|
||||||
create mode 100644 models/ggml-vocab-gemma-4.gguf.inp
|
|
||||||
create mode 100644 models/ggml-vocab-gemma-4.gguf.out
|
|
||||||
|
|
||||||
diff --git a/convert_hf_to_gguf_update.py b/convert_hf_to_gguf_update.py
|
|
||||||
index 086f1c22863..f1d70d62e73 100755
|
|
||||||
--- a/convert_hf_to_gguf_update.py
|
|
||||||
+++ b/convert_hf_to_gguf_update.py
|
|
||||||
@@ -114,6 +114,7 @@ class TOKENIZER_TYPE(IntEnum):
|
|
||||||
{"name": "viking", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/LumiOpen/Viking-7B", }, # Also used for Viking 13B and 33B
|
|
||||||
{"name": "gemma", "tokt": TOKENIZER_TYPE.SPM, "repo": "https://huggingface.co/google/gemma-2b", },
|
|
||||||
{"name": "gemma-2", "tokt": TOKENIZER_TYPE.SPM, "repo": "https://huggingface.co/google/gemma-2-9b", },
|
|
||||||
+ {"name": "gemma-4", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/google/gemma-4-E2B-it", },
|
|
||||||
{"name": "jais", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/core42/jais-13b", },
|
|
||||||
{"name": "jais-2", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/inceptionai/Jais-2-8B-Chat", },
|
|
||||||
{"name": "t5", "tokt": TOKENIZER_TYPE.UGM, "repo": "https://huggingface.co/google-t5/t5-small", },
|
|
||||||
diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp
|
|
||||||
index de9a9466bc7..e9e276ab999 100644
|
|
||||||
--- a/src/llama-vocab.cpp
|
|
||||||
+++ b/src/llama-vocab.cpp
|
|
||||||
@@ -658,9 +658,18 @@ struct llm_tokenizer_bpe_session {
|
|
||||||
const auto token = vocab.text_to_token(str);
|
|
||||||
|
|
||||||
if (token == LLAMA_TOKEN_NULL) {
|
|
||||||
+ static const char * hex = "0123456789ABCDEF";
|
|
||||||
for (auto j = str.begin(); j != str.end(); ++j) {
|
|
||||||
- std::string byte_str(1, *j);
|
|
||||||
- auto token_multibyte = vocab.text_to_token(byte_str);
|
|
||||||
+ llama_token token_multibyte = LLAMA_TOKEN_NULL;
|
|
||||||
+ if (tokenizer.byte_encode) {
|
|
||||||
+ std::string byte_str(1, *j);
|
|
||||||
+ token_multibyte = vocab.text_to_token(byte_str);
|
|
||||||
+ } else {
|
|
||||||
+ // For non-byte-encoded BPE (e.g. gemma-4), byte tokens use <0xXX> format
|
|
||||||
+ const uint8_t ch = (uint8_t)*j;
|
|
||||||
+ const char buf[7] = { '<', '0', 'x', hex[ch >> 4], hex[ch & 15], '>', 0 };
|
|
||||||
+ token_multibyte = vocab.text_to_token(buf);
|
|
||||||
+ }
|
|
||||||
if (token_multibyte != LLAMA_TOKEN_NULL) {
|
|
||||||
output.push_back(token_multibyte);
|
|
||||||
}
|
|
||||||
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
|
|
||||||
index 5e87c8b34e1..cd4bc5ef1d3 100644
|
|
||||||
--- a/tests/CMakeLists.txt
|
|
||||||
+++ b/tests/CMakeLists.txt
|
|
||||||
@@ -124,6 +124,7 @@ llama_test(test-tokenizer-0 NAME test-tokenizer-0-command-r ARGS ${PROJE
|
|
||||||
llama_test(test-tokenizer-0 NAME test-tokenizer-0-deepseek-coder ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-deepseek-coder.gguf)
|
|
||||||
llama_test(test-tokenizer-0 NAME test-tokenizer-0-deepseek-llm ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-deepseek-llm.gguf)
|
|
||||||
llama_test(test-tokenizer-0 NAME test-tokenizer-0-falcon ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-falcon.gguf)
|
|
||||||
+llama_test(test-tokenizer-0 NAME test-tokenizer-0-gemma-4 ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-gemma-4.gguf)
|
|
||||||
llama_test(test-tokenizer-0 NAME test-tokenizer-0-gpt-2 ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-gpt-2.gguf)
|
|
||||||
llama_test(test-tokenizer-0 NAME test-tokenizer-0-llama-bpe ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-llama-bpe.gguf)
|
|
||||||
llama_test(test-tokenizer-0 NAME test-tokenizer-0-llama-spm ARGS ${PROJECT_SOURCE_DIR}/models/ggml-vocab-llama-spm.gguf)
|
|
||||||
|
|
||||||
From 0e98596dec124c6968132ef042c21ccdb20d1304 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Piotr Wilkin <piotr.wilkin@syndatis.com>
|
|
||||||
Date: Tue, 7 Apr 2026 00:58:08 +0200
|
|
||||||
Subject: [PATCH 2/2] Remove unnecessary hash from update script.
|
|
||||||
|
|
||||||
---
|
|
||||||
convert_hf_to_gguf_update.py | 1 -
|
|
||||||
1 file changed, 1 deletion(-)
|
|
||||||
|
|
||||||
diff --git a/convert_hf_to_gguf_update.py b/convert_hf_to_gguf_update.py
|
|
||||||
index f1d70d62e73..086f1c22863 100755
|
|
||||||
--- a/convert_hf_to_gguf_update.py
|
|
||||||
+++ b/convert_hf_to_gguf_update.py
|
|
||||||
@@ -114,7 +114,6 @@ class TOKENIZER_TYPE(IntEnum):
|
|
||||||
{"name": "viking", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/LumiOpen/Viking-7B", }, # Also used for Viking 13B and 33B
|
|
||||||
{"name": "gemma", "tokt": TOKENIZER_TYPE.SPM, "repo": "https://huggingface.co/google/gemma-2b", },
|
|
||||||
{"name": "gemma-2", "tokt": TOKENIZER_TYPE.SPM, "repo": "https://huggingface.co/google/gemma-2-9b", },
|
|
||||||
- {"name": "gemma-4", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/google/gemma-4-E2B-it", },
|
|
||||||
{"name": "jais", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/core42/jais-13b", },
|
|
||||||
{"name": "jais-2", "tokt": TOKENIZER_TYPE.BPE, "repo": "https://huggingface.co/inceptionai/Jais-2-8B-Chat", },
|
|
||||||
{"name": "t5", "tokt": TOKENIZER_TYPE.UGM, "repo": "https://huggingface.co/google-t5/t5-small", },
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
From b934a8ca49f9e764fa21d45ff2ce1168a3a7c914 Mon Sep 17 00:00:00 2001
|
|
||||||
From: Georgi Gerganov <ggerganov@gmail.com>
|
|
||||||
Date: Mon, 6 Apr 2026 11:50:22 +0300
|
|
||||||
Subject: [PATCH] models : set gemma 4 FFN MoE prec to F32
|
|
||||||
|
|
||||||
---
|
|
||||||
src/llama-graph.cpp | 4 ++--
|
|
||||||
1 file changed, 2 insertions(+), 2 deletions(-)
|
|
||||||
|
|
||||||
diff --git a/src/llama-graph.cpp b/src/llama-graph.cpp
|
|
||||||
index 0e7d96ca10d..aa8a35721fa 100644
|
|
||||||
--- a/src/llama-graph.cpp
|
|
||||||
+++ b/src/llama-graph.cpp
|
|
||||||
@@ -1185,8 +1185,8 @@ ggml_tensor * llm_graph_context::build_ffn(
|
|
||||||
|
|
||||||
if (down) {
|
|
||||||
cur = build_lora_mm(down, cur);
|
|
||||||
- if (arch == LLM_ARCH_GLM4 || arch == LLM_ARCH_GLM4_MOE || arch == LLM_ARCH_JAIS2) {
|
|
||||||
- // GLM4, GLM4_MOE, and JAIS2 seem to have numerical issues with half-precision accumulators
|
|
||||||
+ if (arch == LLM_ARCH_GLM4 || arch == LLM_ARCH_GLM4_MOE || arch == LLM_ARCH_JAIS2 || arch == LLM_ARCH_GEMMA4) {
|
|
||||||
+ // certain models seem to have numerical issues with half-precision accumulators
|
|
||||||
ggml_mul_mat_set_prec(cur, GGML_PREC_F32);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
From f0582558f0a8b0ef543b3251c4a07afab89fde63 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Simon Gardling <titaniumtown@proton.me>
|
||||||
|
Date: Fri, 17 Apr 2026 19:37:11 -0400
|
||||||
|
Subject: [PATCH] nixos/jellyfin: add declarative network.xml options
|
||||||
|
|
||||||
|
Adds services.jellyfin.network.* (baseUrl, ports, IPv4/6, LAN subnets,
|
||||||
|
known proxies, remote IP filter, etc.) and services.jellyfin.forceNetworkConfig,
|
||||||
|
mirroring the existing hardwareAcceleration / forceEncodingConfig pattern.
|
||||||
|
|
||||||
|
Motivation: running Jellyfin behind a reverse proxy requires configuring
|
||||||
|
KnownProxies (so the real client IP is extracted from X-Forwarded-For)
|
||||||
|
and LocalNetworkSubnets (so LAN clients are correctly classified and not
|
||||||
|
subject to RemoteClientBitrateLimit). These settings previously had no
|
||||||
|
declarative option -- they could only be set via the web dashboard or
|
||||||
|
by hand-editing network.xml, with no guarantee they would survive a
|
||||||
|
reinstall or be consistent across deployments.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Adds a networkXmlText template alongside the existing encodingXmlText.
|
||||||
|
- Factors the force-vs-soft install logic out of preStart into a
|
||||||
|
small 'manage_config_xml' shell helper; encoding.xml and network.xml
|
||||||
|
now share the same install/backup semantics.
|
||||||
|
- Extends the VM test with a machineWithNetworkConfig node and a
|
||||||
|
subtest that verifies the declared values land in network.xml,
|
||||||
|
Jellyfin parses them at startup, and the backup-on-overwrite path
|
||||||
|
works (same shape as the existing 'Force encoding config' subtest).
|
||||||
|
---
|
||||||
|
nixos/modules/services/misc/jellyfin.nix | 303 ++++++++++++++++++++---
|
||||||
|
nixos/tests/jellyfin.nix | 50 ++++
|
||||||
|
2 files changed, 317 insertions(+), 36 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/nixos/modules/services/misc/jellyfin.nix b/nixos/modules/services/misc/jellyfin.nix
|
||||||
|
index 5c08fc478e45..387da907c652 100644
|
||||||
|
--- a/nixos/modules/services/misc/jellyfin.nix
|
||||||
|
+++ b/nixos/modules/services/misc/jellyfin.nix
|
||||||
|
@@ -26,8 +26,10 @@ let
|
||||||
|
bool
|
||||||
|
enum
|
||||||
|
ints
|
||||||
|
+ listOf
|
||||||
|
nullOr
|
||||||
|
path
|
||||||
|
+ port
|
||||||
|
str
|
||||||
|
submodule
|
||||||
|
;
|
||||||
|
@@ -68,6 +70,41 @@ let
|
||||||
|
</EncodingOptions>
|
||||||
|
'';
|
||||||
|
encodingXmlFile = pkgs.writeText "encoding.xml" encodingXmlText;
|
||||||
|
+ stringListToXml =
|
||||||
|
+ tag: items:
|
||||||
|
+ if items == [ ] then
|
||||||
|
+ "<${tag} />"
|
||||||
|
+ else
|
||||||
|
+ "<${tag}>\n ${
|
||||||
|
+ concatMapStringsSep "\n " (item: "<string>${escapeXML item}</string>") items
|
||||||
|
+ }\n </${tag}>";
|
||||||
|
+ networkXmlText = ''
|
||||||
|
+ <?xml version="1.0" encoding="utf-8"?>
|
||||||
|
+ <NetworkConfiguration xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||||
|
+ <BaseUrl>${escapeXML cfg.network.baseUrl}</BaseUrl>
|
||||||
|
+ <EnableHttps>${boolToString cfg.network.enableHttps}</EnableHttps>
|
||||||
|
+ <RequireHttps>${boolToString cfg.network.requireHttps}</RequireHttps>
|
||||||
|
+ <InternalHttpPort>${toString cfg.network.internalHttpPort}</InternalHttpPort>
|
||||||
|
+ <InternalHttpsPort>${toString cfg.network.internalHttpsPort}</InternalHttpsPort>
|
||||||
|
+ <PublicHttpPort>${toString cfg.network.publicHttpPort}</PublicHttpPort>
|
||||||
|
+ <PublicHttpsPort>${toString cfg.network.publicHttpsPort}</PublicHttpsPort>
|
||||||
|
+ <AutoDiscovery>${boolToString cfg.network.autoDiscovery}</AutoDiscovery>
|
||||||
|
+ <EnableUPnP>${boolToString cfg.network.enableUPnP}</EnableUPnP>
|
||||||
|
+ <EnableIPv4>${boolToString cfg.network.enableIPv4}</EnableIPv4>
|
||||||
|
+ <EnableIPv6>${boolToString cfg.network.enableIPv6}</EnableIPv6>
|
||||||
|
+ <EnableRemoteAccess>${boolToString cfg.network.enableRemoteAccess}</EnableRemoteAccess>
|
||||||
|
+ ${stringListToXml "LocalNetworkSubnets" cfg.network.localNetworkSubnets}
|
||||||
|
+ ${stringListToXml "LocalNetworkAddresses" cfg.network.localNetworkAddresses}
|
||||||
|
+ ${stringListToXml "KnownProxies" cfg.network.knownProxies}
|
||||||
|
+ <IgnoreVirtualInterfaces>${boolToString cfg.network.ignoreVirtualInterfaces}</IgnoreVirtualInterfaces>
|
||||||
|
+ ${stringListToXml "VirtualInterfaceNames" cfg.network.virtualInterfaceNames}
|
||||||
|
+ <EnablePublishedServerUriByRequest>${boolToString cfg.network.enablePublishedServerUriByRequest}</EnablePublishedServerUriByRequest>
|
||||||
|
+ ${stringListToXml "PublishedServerUriBySubnet" cfg.network.publishedServerUriBySubnet}
|
||||||
|
+ ${stringListToXml "RemoteIPFilter" cfg.network.remoteIPFilter}
|
||||||
|
+ <IsRemoteIPFilterBlacklist>${boolToString cfg.network.isRemoteIPFilterBlacklist}</IsRemoteIPFilterBlacklist>
|
||||||
|
+ </NetworkConfiguration>
|
||||||
|
+ '';
|
||||||
|
+ networkXmlFile = pkgs.writeText "network.xml" networkXmlText;
|
||||||
|
codecListToType =
|
||||||
|
desc: list:
|
||||||
|
submodule {
|
||||||
|
@@ -205,6 +242,196 @@ in
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
+ network = {
|
||||||
|
+ baseUrl = mkOption {
|
||||||
|
+ type = str;
|
||||||
|
+ default = "";
|
||||||
|
+ example = "/jellyfin";
|
||||||
|
+ description = ''
|
||||||
|
+ Prefix added to Jellyfin's internal URLs when it sits behind a reverse proxy at a sub-path.
|
||||||
|
+ Leave empty when Jellyfin is served at the root of its host.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ enableHttps = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = false;
|
||||||
|
+ description = ''
|
||||||
|
+ Serve HTTPS directly from Jellyfin. Usually unnecessary when terminating TLS in a reverse proxy.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ requireHttps = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = false;
|
||||||
|
+ description = ''
|
||||||
|
+ Redirect plaintext HTTP requests to HTTPS. Only meaningful when {option}`enableHttps` is true.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ internalHttpPort = mkOption {
|
||||||
|
+ type = port;
|
||||||
|
+ default = 8096;
|
||||||
|
+ description = "TCP port Jellyfin binds for HTTP.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ internalHttpsPort = mkOption {
|
||||||
|
+ type = port;
|
||||||
|
+ default = 8920;
|
||||||
|
+ description = "TCP port Jellyfin binds for HTTPS. Only used when {option}`enableHttps` is true.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ publicHttpPort = mkOption {
|
||||||
|
+ type = port;
|
||||||
|
+ default = 8096;
|
||||||
|
+ description = "HTTP port Jellyfin advertises in server discovery responses and published URIs.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ publicHttpsPort = mkOption {
|
||||||
|
+ type = port;
|
||||||
|
+ default = 8920;
|
||||||
|
+ description = "HTTPS port Jellyfin advertises in server discovery responses and published URIs.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ autoDiscovery = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = true;
|
||||||
|
+ description = "Respond to LAN client auto-discovery broadcasts (UDP 7359).";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ enableUPnP = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = false;
|
||||||
|
+ description = "Attempt to open the public ports on the router via UPnP.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ enableIPv4 = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = true;
|
||||||
|
+ description = "Listen on IPv4.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ enableIPv6 = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = true;
|
||||||
|
+ description = "Listen on IPv6.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ enableRemoteAccess = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = true;
|
||||||
|
+ description = ''
|
||||||
|
+ Allow connections from clients outside the subnets listed in {option}`localNetworkSubnets`.
|
||||||
|
+ When false, Jellyfin rejects non-local requests regardless of reverse proxy configuration.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ localNetworkSubnets = mkOption {
|
||||||
|
+ type = listOf str;
|
||||||
|
+ default = [ ];
|
||||||
|
+ example = [
|
||||||
|
+ "192.168.1.0/24"
|
||||||
|
+ "10.0.0.0/8"
|
||||||
|
+ ];
|
||||||
|
+ description = ''
|
||||||
|
+ CIDR ranges (or bare IPs) that Jellyfin classifies as the local network.
|
||||||
|
+ Clients originating from these ranges -- as seen after {option}`knownProxies` X-Forwarded-For
|
||||||
|
+ unwrapping -- are not subject to {option}`services.jellyfin` remote-client bitrate limits.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ localNetworkAddresses = mkOption {
|
||||||
|
+ type = listOf str;
|
||||||
|
+ default = [ ];
|
||||||
|
+ example = [ "192.168.1.50" ];
|
||||||
|
+ description = ''
|
||||||
|
+ Specific interface addresses Jellyfin binds to. Leave empty to bind all interfaces.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ knownProxies = mkOption {
|
||||||
|
+ type = listOf str;
|
||||||
|
+ default = [ ];
|
||||||
|
+ example = [ "127.0.0.1" ];
|
||||||
|
+ description = ''
|
||||||
|
+ Addresses of reverse proxies trusted to forward the real client IP via `X-Forwarded-For`.
|
||||||
|
+ Without this, Jellyfin sees the proxy's address for every request and cannot apply
|
||||||
|
+ {option}`localNetworkSubnets` classification to the true client.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ ignoreVirtualInterfaces = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = true;
|
||||||
|
+ description = "Skip virtual network interfaces (matching {option}`virtualInterfaceNames`) during auto-bind.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ virtualInterfaceNames = mkOption {
|
||||||
|
+ type = listOf str;
|
||||||
|
+ default = [ "veth" ];
|
||||||
|
+ description = "Interface name prefixes treated as virtual when {option}`ignoreVirtualInterfaces` is true.";
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ enablePublishedServerUriByRequest = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = false;
|
||||||
|
+ description = ''
|
||||||
|
+ Derive the server's public URI from the incoming request's Host header instead of any
|
||||||
|
+ configured {option}`publishedServerUriBySubnet` entry.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ publishedServerUriBySubnet = mkOption {
|
||||||
|
+ type = listOf str;
|
||||||
|
+ default = [ ];
|
||||||
|
+ example = [ "192.168.1.0/24=http://jellyfin.lan:8096" ];
|
||||||
|
+ description = ''
|
||||||
|
+ Per-subnet overrides for the URI Jellyfin advertises to clients, in `subnet=uri` form.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ remoteIPFilter = mkOption {
|
||||||
|
+ type = listOf str;
|
||||||
|
+ default = [ ];
|
||||||
|
+ example = [ "203.0.113.0/24" ];
|
||||||
|
+ description = ''
|
||||||
|
+ IPs or CIDRs used as the allow- or denylist for remote access.
|
||||||
|
+ Behaviour is controlled by {option}`isRemoteIPFilterBlacklist`.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ isRemoteIPFilterBlacklist = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = false;
|
||||||
|
+ description = ''
|
||||||
|
+ When true, {option}`remoteIPFilter` is a denylist; when false, it is an allowlist
|
||||||
|
+ (and an empty list allows all remote addresses).
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
+ forceNetworkConfig = mkOption {
|
||||||
|
+ type = bool;
|
||||||
|
+ default = false;
|
||||||
|
+ description = ''
|
||||||
|
+ Whether to overwrite Jellyfin's `network.xml` configuration file on each service start.
|
||||||
|
+
|
||||||
|
+ When enabled, the network configuration specified in {option}`services.jellyfin.network`
|
||||||
|
+ is applied on every service restart. A backup of the existing `network.xml` will be
|
||||||
|
+ created at `network.xml.backup-$timestamp`.
|
||||||
|
+
|
||||||
|
+ ::: {.warning}
|
||||||
|
+ Enabling this option means that any changes made to networking settings through
|
||||||
|
+ Jellyfin's web dashboard will be lost on the next service restart. The NixOS configuration
|
||||||
|
+ becomes the single source of truth for network settings.
|
||||||
|
+ :::
|
||||||
|
+
|
||||||
|
+ When disabled (the default), the network configuration is only written if no `network.xml`
|
||||||
|
+ exists yet. This allows settings to be changed through Jellyfin's web dashboard and persist
|
||||||
|
+ across restarts, but means the NixOS configuration options will be ignored after the initial setup.
|
||||||
|
+ '';
|
||||||
|
+ };
|
||||||
|
+
|
||||||
|
transcoding = {
|
||||||
|
maxConcurrentStreams = mkOption {
|
||||||
|
type = nullOr ints.positive;
|
||||||
|
@@ -384,46 +611,50 @@ in
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
|
- preStart = mkIf cfg.hardwareAcceleration.enable (
|
||||||
|
- ''
|
||||||
|
- configDir=${escapeShellArg cfg.configDir}
|
||||||
|
- encodingXml="$configDir/encoding.xml"
|
||||||
|
- ''
|
||||||
|
- + (
|
||||||
|
- if cfg.forceEncodingConfig then
|
||||||
|
- ''
|
||||||
|
- if [[ -e $encodingXml ]]; then
|
||||||
|
+ preStart =
|
||||||
|
+ let
|
||||||
|
+ # manage_config_xml <source> <destination> <force> <description>
|
||||||
|
+ #
|
||||||
|
+ # Installs a NixOS-declared XML config at <destination>, preserving
|
||||||
|
+ # any existing file as a timestamped backup when <force> is true.
|
||||||
|
+ # With <force>=false, leaves existing files untouched and warns if
|
||||||
|
+ # the on-disk content differs from the declared content.
|
||||||
|
+ helper = ''
|
||||||
|
+ manage_config_xml() {
|
||||||
|
+ local src="$1" dest="$2" force="$3" desc="$4"
|
||||||
|
+ if [[ -e "$dest" ]]; then
|
||||||
|
# this intentionally removes trailing newlines
|
||||||
|
- currentText="$(<"$encodingXml")"
|
||||||
|
- configuredText="$(<${encodingXmlFile})"
|
||||||
|
- if [[ $currentText == "$configuredText" ]]; then
|
||||||
|
- # don't need to do anything
|
||||||
|
- exit 0
|
||||||
|
- else
|
||||||
|
- encodingXmlBackup="$configDir/encoding.xml.backup-$(date -u +"%FT%H_%M_%SZ")"
|
||||||
|
- mv --update=none-fail -T "$encodingXml" "$encodingXmlBackup"
|
||||||
|
+ local currentText configuredText
|
||||||
|
+ currentText="$(<"$dest")"
|
||||||
|
+ configuredText="$(<"$src")"
|
||||||
|
+ if [[ "$currentText" == "$configuredText" ]]; then
|
||||||
|
+ return 0
|
||||||
|
fi
|
||||||
|
- fi
|
||||||
|
- cp --update=none-fail -T ${encodingXmlFile} "$encodingXml"
|
||||||
|
- chmod u+w "$encodingXml"
|
||||||
|
- ''
|
||||||
|
- else
|
||||||
|
- ''
|
||||||
|
- if [[ -e $encodingXml ]]; then
|
||||||
|
- # this intentionally removes trailing newlines
|
||||||
|
- currentText="$(<"$encodingXml")"
|
||||||
|
- configuredText="$(<${encodingXmlFile})"
|
||||||
|
- if [[ $currentText != "$configuredText" ]]; then
|
||||||
|
- echo "WARN: $encodingXml already exists and is different from the configured settings. transcoding options NOT applied." >&2
|
||||||
|
- echo "WARN: Set config.services.jellyfin.forceEncodingConfig = true to override." >&2
|
||||||
|
+ if [[ "$force" == true ]]; then
|
||||||
|
+ local backup
|
||||||
|
+ backup="$dest.backup-$(date -u +"%FT%H_%M_%SZ")"
|
||||||
|
+ mv --update=none-fail -T "$dest" "$backup"
|
||||||
|
+ else
|
||||||
|
+ echo "WARN: $dest already exists and is different from the configured settings. $desc options NOT applied." >&2
|
||||||
|
+ echo "WARN: Set the corresponding force*Config option to override." >&2
|
||||||
|
+ return 0
|
||||||
|
fi
|
||||||
|
- else
|
||||||
|
- cp --update=none-fail -T ${encodingXmlFile} "$encodingXml"
|
||||||
|
- chmod u+w "$encodingXml"
|
||||||
|
fi
|
||||||
|
- ''
|
||||||
|
- )
|
||||||
|
- );
|
||||||
|
+ cp --update=none-fail -T "$src" "$dest"
|
||||||
|
+ chmod u+w "$dest"
|
||||||
|
+ }
|
||||||
|
+ configDir=${escapeShellArg cfg.configDir}
|
||||||
|
+ '';
|
||||||
|
+ in
|
||||||
|
+ (
|
||||||
|
+ helper
|
||||||
|
+ + optionalString cfg.hardwareAcceleration.enable ''
|
||||||
|
+ manage_config_xml ${encodingXmlFile} "$configDir/encoding.xml" ${boolToString cfg.forceEncodingConfig} transcoding
|
||||||
|
+ ''
|
||||||
|
+ + ''
|
||||||
|
+ manage_config_xml ${networkXmlFile} "$configDir/network.xml" ${boolToString cfg.forceNetworkConfig} network
|
||||||
|
+ ''
|
||||||
|
+ );
|
||||||
|
|
||||||
|
# This is mostly follows: https://github.com/jellyfin/jellyfin/blob/master/fedora/jellyfin.service
|
||||||
|
# Upstream also disable some hardenings when running in LXC, we do the same with the isContainer option
|
||||||
|
diff --git a/nixos/tests/jellyfin.nix b/nixos/tests/jellyfin.nix
|
||||||
|
index 4896c13d4eca..0c9191960f78 100644
|
||||||
|
--- a/nixos/tests/jellyfin.nix
|
||||||
|
+++ b/nixos/tests/jellyfin.nix
|
||||||
|
@@ -63,6 +63,26 @@
|
||||||
|
environment.systemPackages = with pkgs; [ ffmpeg ];
|
||||||
|
virtualisation.diskSize = 3 * 1024;
|
||||||
|
};
|
||||||
|
+
|
||||||
|
+ machineWithNetworkConfig = {
|
||||||
|
+ services.jellyfin = {
|
||||||
|
+ enable = true;
|
||||||
|
+ forceNetworkConfig = true;
|
||||||
|
+ network = {
|
||||||
|
+ localNetworkSubnets = [
|
||||||
|
+ "192.168.1.0/24"
|
||||||
|
+ "10.0.0.0/8"
|
||||||
|
+ ];
|
||||||
|
+ knownProxies = [ "127.0.0.1" ];
|
||||||
|
+ enableUPnP = false;
|
||||||
|
+ enableIPv6 = false;
|
||||||
|
+ remoteIPFilter = [ "203.0.113.5" ];
|
||||||
|
+ isRemoteIPFilterBlacklist = true;
|
||||||
|
+ };
|
||||||
|
+ };
|
||||||
|
+ environment.systemPackages = with pkgs; [ ffmpeg ];
|
||||||
|
+ virtualisation.diskSize = 3 * 1024;
|
||||||
|
+ };
|
||||||
|
};
|
||||||
|
|
||||||
|
# Documentation of the Jellyfin API: https://api.jellyfin.org/
|
||||||
|
@@ -122,6 +142,36 @@
|
||||||
|
# Verify the new encoding.xml does not have the marker (was overwritten)
|
||||||
|
machineWithForceConfig.fail("grep -q 'MARKER' /var/lib/jellyfin/config/encoding.xml")
|
||||||
|
|
||||||
|
+ # Test forceNetworkConfig and network.xml generation
|
||||||
|
+ with subtest("Force network config writes declared values and backs up on overwrite"):
|
||||||
|
+ wait_for_jellyfin(machineWithNetworkConfig)
|
||||||
|
+
|
||||||
|
+ # Verify network.xml exists and contains the declared values
|
||||||
|
+ machineWithNetworkConfig.succeed("test -f /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<string>192.168.1.0/24</string>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<string>10.0.0.0/8</string>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<string>127.0.0.1</string>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<string>203.0.113.5</string>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<IsRemoteIPFilterBlacklist>true</IsRemoteIPFilterBlacklist>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<EnableIPv6>false</EnableIPv6>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -F '<EnableUPnP>false</EnableUPnP>' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+
|
||||||
|
+ # Stop service before modifying config
|
||||||
|
+ machineWithNetworkConfig.succeed("systemctl stop jellyfin.service")
|
||||||
|
+
|
||||||
|
+ # Plant a marker so we can prove the backup-and-overwrite path runs
|
||||||
|
+ machineWithNetworkConfig.succeed("echo '<!-- NETMARKER -->' > /var/lib/jellyfin/config/network.xml")
|
||||||
|
+
|
||||||
|
+ # Restart the service to trigger the backup
|
||||||
|
+ machineWithNetworkConfig.succeed("systemctl restart jellyfin.service")
|
||||||
|
+ wait_for_jellyfin(machineWithNetworkConfig)
|
||||||
|
+
|
||||||
|
+ # Verify the marked content was preserved as a timestamped backup
|
||||||
|
+ machineWithNetworkConfig.succeed("grep -q 'NETMARKER' /var/lib/jellyfin/config/network.xml.backup-*")
|
||||||
|
+
|
||||||
|
+ # Verify the new network.xml does not have the marker (was overwritten)
|
||||||
|
+ machineWithNetworkConfig.fail("grep -q 'NETMARKER' /var/lib/jellyfin/config/network.xml")
|
||||||
|
+
|
||||||
|
auth_header = 'MediaBrowser Client="NixOS Integration Tests", DeviceId="1337", Device="Apple II", Version="20.09"'
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
2.53.0
|
||||||
|
|
||||||
BIN
secrets/ddns-updater-config.age
Normal file
BIN
secrets/ddns-updater-config.age
Normal file
Binary file not shown.
BIN
secrets/git-crypt-key-nixos.age
Normal file
BIN
secrets/git-crypt-key-nixos.age
Normal file
Binary file not shown.
BIN
secrets/njalla-api-token-env.age
Normal file
BIN
secrets/njalla-api-token-env.age
Normal file
Binary file not shown.
@@ -81,6 +81,12 @@ rec {
|
|||||||
port = 6011;
|
port = 6011;
|
||||||
proto = "tcp";
|
proto = "tcp";
|
||||||
};
|
};
|
||||||
|
# Webhook receiver for the Jellyfin-qBittorrent monitor — Jellyfin pushes
|
||||||
|
# playback events here so throttling reacts without waiting for the poll.
|
||||||
|
jellyfin_qbittorrent_monitor_webhook = {
|
||||||
|
port = 9898;
|
||||||
|
proto = "tcp";
|
||||||
|
};
|
||||||
bitmagnet = {
|
bitmagnet = {
|
||||||
port = 3333;
|
port = 3333;
|
||||||
proto = "tcp";
|
proto = "tcp";
|
||||||
@@ -189,6 +195,10 @@ rec {
|
|||||||
port = 9563;
|
port = 9563;
|
||||||
proto = "tcp";
|
proto = "tcp";
|
||||||
};
|
};
|
||||||
|
prometheus_zfs = {
|
||||||
|
port = 9134;
|
||||||
|
proto = "tcp";
|
||||||
|
};
|
||||||
harmonia = {
|
harmonia = {
|
||||||
port = 5500;
|
port = 5500;
|
||||||
proto = "tcp";
|
proto = "tcp";
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
pkgs,
|
pkgs,
|
||||||
|
lib,
|
||||||
service_configs,
|
service_configs,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
@@ -12,7 +13,6 @@ let
|
|||||||
|
|
||||||
curl = "${pkgs.curl}/bin/curl";
|
curl = "${pkgs.curl}/bin/curl";
|
||||||
jq = "${pkgs.jq}/bin/jq";
|
jq = "${pkgs.jq}/bin/jq";
|
||||||
grep = "${pkgs.gnugrep}/bin/grep";
|
|
||||||
|
|
||||||
# Max items to search per cycle per category (missing + cutoff) per app
|
# Max items to search per cycle per category (missing + cutoff) per app
|
||||||
maxPerCycle = 5;
|
maxPerCycle = 5;
|
||||||
@@ -20,8 +20,8 @@ let
|
|||||||
searchScript = pkgs.writeShellScript "arr-search" ''
|
searchScript = pkgs.writeShellScript "arr-search" ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
RADARR_KEY=$(${grep} -oP '(?<=<ApiKey>)[^<]+' ${radarrConfig})
|
RADARR_KEY=$(${lib.extractArrApiKey radarrConfig})
|
||||||
SONARR_KEY=$(${grep} -oP '(?<=<ApiKey>)[^<]+' ${sonarrConfig})
|
SONARR_KEY=$(${lib.extractArrApiKey sonarrConfig})
|
||||||
|
|
||||||
search_radarr() {
|
search_radarr() {
|
||||||
local endpoint="$1"
|
local endpoint="$1"
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
(lib.serviceFilePerms "bazarr" [
|
(lib.serviceFilePerms "bazarr" [
|
||||||
"Z ${service_configs.bazarr.dataDir} 0700 ${config.services.bazarr.user} ${config.services.bazarr.group}"
|
"Z ${service_configs.bazarr.dataDir} 0700 ${config.services.bazarr.user} ${config.services.bazarr.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "bazarr";
|
||||||
|
port = service_configs.ports.private.bazarr.port;
|
||||||
|
auth = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.bazarr = {
|
services.bazarr = {
|
||||||
@@ -23,11 +28,6 @@
|
|||||||
listenPort = service_configs.ports.private.bazarr.port;
|
listenPort = service_configs.ports.private.bazarr.port;
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."bazarr.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.bazarr.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
users.users.${config.services.bazarr.user}.extraGroups = [
|
users.users.${config.services.bazarr.user}.extraGroups = [
|
||||||
service_configs.media_group
|
service_configs.media_group
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,13 +8,26 @@
|
|||||||
dataDir = service_configs.prowlarr.dataDir;
|
dataDir = service_configs.prowlarr.dataDir;
|
||||||
apiVersion = "v1";
|
apiVersion = "v1";
|
||||||
networkNamespacePath = "/run/netns/wg";
|
networkNamespacePath = "/run/netns/wg";
|
||||||
|
networkNamespaceService = "wg";
|
||||||
|
# Guarantee critical config.xml elements before startup. Prowlarr has a
|
||||||
|
# history of losing <Port> from config.xml, causing the service to run
|
||||||
|
# without binding any socket. See arr-init's configXml for details.
|
||||||
|
configXml = {
|
||||||
|
Port = service_configs.ports.private.prowlarr.port;
|
||||||
|
BindAddress = "*";
|
||||||
|
EnableSsl = false;
|
||||||
|
};
|
||||||
|
# Prowlarr runs in the wg netns; Sonarr/Radarr in the host netns.
|
||||||
|
# From host netns, Prowlarr is reachable at the wg namespace address,
|
||||||
|
# not at localhost (which resolves to the host's own netns).
|
||||||
|
# Health checks can now run — the reverse-connect is reachable.
|
||||||
healthChecks = true;
|
healthChecks = true;
|
||||||
syncedApps = [
|
syncedApps = [
|
||||||
{
|
{
|
||||||
name = "Sonarr";
|
name = "Sonarr";
|
||||||
implementation = "Sonarr";
|
implementation = "Sonarr";
|
||||||
configContract = "SonarrSettings";
|
configContract = "SonarrSettings";
|
||||||
prowlarrUrl = "http://localhost:${builtins.toString service_configs.ports.private.prowlarr.port}";
|
prowlarrUrl = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.prowlarr.port}";
|
||||||
baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.private.sonarr.port}";
|
baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.private.sonarr.port}";
|
||||||
apiKeyFrom = "${service_configs.sonarr.dataDir}/config.xml";
|
apiKeyFrom = "${service_configs.sonarr.dataDir}/config.xml";
|
||||||
serviceName = "sonarr";
|
serviceName = "sonarr";
|
||||||
@@ -23,7 +36,7 @@
|
|||||||
name = "Radarr";
|
name = "Radarr";
|
||||||
implementation = "Radarr";
|
implementation = "Radarr";
|
||||||
configContract = "RadarrSettings";
|
configContract = "RadarrSettings";
|
||||||
prowlarrUrl = "http://localhost:${builtins.toString service_configs.ports.private.prowlarr.port}";
|
prowlarrUrl = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.prowlarr.port}";
|
||||||
baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.private.radarr.port}";
|
baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.private.radarr.port}";
|
||||||
apiKeyFrom = "${service_configs.radarr.dataDir}/config.xml";
|
apiKeyFrom = "${service_configs.radarr.dataDir}/config.xml";
|
||||||
serviceName = "radarr";
|
serviceName = "radarr";
|
||||||
@@ -37,6 +50,11 @@
|
|||||||
port = service_configs.ports.private.sonarr.port;
|
port = service_configs.ports.private.sonarr.port;
|
||||||
dataDir = service_configs.sonarr.dataDir;
|
dataDir = service_configs.sonarr.dataDir;
|
||||||
healthChecks = true;
|
healthChecks = true;
|
||||||
|
configXml = {
|
||||||
|
Port = service_configs.ports.private.sonarr.port;
|
||||||
|
BindAddress = "*";
|
||||||
|
EnableSsl = false;
|
||||||
|
};
|
||||||
rootFolders = [ service_configs.media.tvDir ];
|
rootFolders = [ service_configs.media.tvDir ];
|
||||||
naming = {
|
naming = {
|
||||||
renameEpisodes = true;
|
renameEpisodes = true;
|
||||||
@@ -69,6 +87,11 @@
|
|||||||
port = service_configs.ports.private.radarr.port;
|
port = service_configs.ports.private.radarr.port;
|
||||||
dataDir = service_configs.radarr.dataDir;
|
dataDir = service_configs.radarr.dataDir;
|
||||||
healthChecks = true;
|
healthChecks = true;
|
||||||
|
configXml = {
|
||||||
|
Port = service_configs.ports.private.radarr.port;
|
||||||
|
BindAddress = "*";
|
||||||
|
EnableSsl = false;
|
||||||
|
};
|
||||||
rootFolders = [ service_configs.media.moviesDir ];
|
rootFolders = [ service_configs.media.moviesDir ];
|
||||||
naming = {
|
naming = {
|
||||||
renameMovies = true;
|
renameMovies = true;
|
||||||
@@ -110,4 +133,21 @@
|
|||||||
serviceName = "radarr";
|
serviceName = "radarr";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
services.jellyseerrInit = {
|
||||||
|
enable = true;
|
||||||
|
configDir = service_configs.jellyseerr.configDir;
|
||||||
|
radarr = {
|
||||||
|
profileName = "Remux + WEB 2160p";
|
||||||
|
dataDir = service_configs.radarr.dataDir;
|
||||||
|
port = service_configs.ports.private.radarr.port;
|
||||||
|
serviceName = "radarr";
|
||||||
|
};
|
||||||
|
sonarr = {
|
||||||
|
profileName = "WEB-2160p";
|
||||||
|
dataDir = service_configs.sonarr.dataDir;
|
||||||
|
port = service_configs.ports.private.sonarr.port;
|
||||||
|
serviceName = "sonarr";
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
(lib.serviceFilePerms "jellyseerr" [
|
(lib.serviceFilePerms "jellyseerr" [
|
||||||
"Z ${service_configs.jellyseerr.configDir} 0700 jellyseerr jellyseerr"
|
"Z ${service_configs.jellyseerr.configDir} 0700 jellyseerr jellyseerr"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "jellyseerr";
|
||||||
|
port = service_configs.ports.private.jellyseerr.port;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.jellyseerr = {
|
services.jellyseerr = {
|
||||||
@@ -36,8 +40,4 @@
|
|||||||
|
|
||||||
users.groups.jellyseerr = { };
|
users.groups.jellyseerr = { };
|
||||||
|
|
||||||
services.caddy.virtualHosts."jellyseerr.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
# import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.jellyseerr.port}
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
(lib.serviceFilePerms "prowlarr" [
|
(lib.serviceFilePerms "prowlarr" [
|
||||||
"Z ${service_configs.prowlarr.dataDir} 0700 prowlarr prowlarr"
|
"Z ${service_configs.prowlarr.dataDir} 0700 prowlarr prowlarr"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "prowlarr";
|
||||||
|
port = service_configs.ports.private.prowlarr.port;
|
||||||
|
auth = true;
|
||||||
|
vpn = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.prowlarr = {
|
services.prowlarr = {
|
||||||
@@ -51,8 +57,4 @@
|
|||||||
ExecStart = lib.mkForce "${lib.getExe pkgs.prowlarr} -nobrowser -data=${service_configs.prowlarr.dataDir}";
|
ExecStart = lib.mkForce "${lib.getExe pkgs.prowlarr} -nobrowser -data=${service_configs.prowlarr.dataDir}";
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."prowlarr.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy ${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.prowlarr.port}
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
(lib.serviceFilePerms "radarr" [
|
(lib.serviceFilePerms "radarr" [
|
||||||
"Z ${service_configs.radarr.dataDir} 0700 ${config.services.radarr.user} ${config.services.radarr.group}"
|
"Z ${service_configs.radarr.dataDir} 0700 ${config.services.radarr.user} ${config.services.radarr.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "radarr";
|
||||||
|
port = service_configs.ports.private.radarr.port;
|
||||||
|
auth = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.radarr = {
|
services.radarr = {
|
||||||
@@ -25,11 +30,6 @@
|
|||||||
settings.update.mechanism = "external";
|
settings.update.mechanism = "external";
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."radarr.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.radarr.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
users.users.${config.services.radarr.user}.extraGroups = [
|
users.users.${config.services.radarr.user}.extraGroups = [
|
||||||
service_configs.media_group
|
service_configs.media_group
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ let
|
|||||||
# Runs as root (via + prefix) after the NixOS module writes config.json.
|
# Runs as root (via + prefix) after the NixOS module writes config.json.
|
||||||
# Extracts API keys from radarr/sonarr config.xml and injects them via jq.
|
# Extracts API keys from radarr/sonarr config.xml and injects them via jq.
|
||||||
injectApiKeys = pkgs.writeShellScript "recyclarr-inject-api-keys" ''
|
injectApiKeys = pkgs.writeShellScript "recyclarr-inject-api-keys" ''
|
||||||
RADARR_KEY=$(${lib.getExe pkgs.gnugrep} -oP '(?<=<ApiKey>)[^<]+' ${radarrConfig})
|
RADARR_KEY=$(${lib.extractArrApiKey radarrConfig})
|
||||||
SONARR_KEY=$(${lib.getExe pkgs.gnugrep} -oP '(?<=<ApiKey>)[^<]+' ${sonarrConfig})
|
SONARR_KEY=$(${lib.extractArrApiKey sonarrConfig})
|
||||||
${pkgs.jq}/bin/jq \
|
${pkgs.jq}/bin/jq \
|
||||||
--arg rk "$RADARR_KEY" \
|
--arg rk "$RADARR_KEY" \
|
||||||
--arg sk "$SONARR_KEY" \
|
--arg sk "$SONARR_KEY" \
|
||||||
@@ -46,30 +46,42 @@ in
|
|||||||
radarr.movies = {
|
radarr.movies = {
|
||||||
base_url = "http://localhost:${builtins.toString service_configs.ports.private.radarr.port}";
|
base_url = "http://localhost:${builtins.toString service_configs.ports.private.radarr.port}";
|
||||||
|
|
||||||
|
# Recyclarr is the sole authority for custom formats and scores.
|
||||||
|
# Overwrite any manually-created CFs and delete stale ones.
|
||||||
|
replace_existing_custom_formats = true;
|
||||||
|
delete_old_custom_formats = true;
|
||||||
|
|
||||||
include = [
|
include = [
|
||||||
{ template = "radarr-quality-definition-movie"; }
|
{ template = "radarr-quality-definition-movie"; }
|
||||||
{ template = "radarr-quality-profile-remux-web-2160p"; }
|
{ template = "radarr-quality-profile-remux-web-2160p"; }
|
||||||
{ template = "radarr-custom-formats-remux-web-2160p"; }
|
{ template = "radarr-custom-formats-remux-web-2160p"; }
|
||||||
];
|
];
|
||||||
|
|
||||||
# Extend the template's quality profile with lower-resolution fallbacks
|
# Group WEB 2160p with 1080p in the same quality tier so custom
|
||||||
|
# format scores -- not quality ranking -- decide the winner.
|
||||||
|
# Native 4K with HDR/DV from good release groups scores high and
|
||||||
|
# wins; AI upscales get -10000 from the Upscaled CF and are
|
||||||
|
# blocked by min_format_score. Untagged upscales from unknown
|
||||||
|
# groups (score ~0) lose to well-scored 1080p (Tier 01 = +1750).
|
||||||
quality_profiles = [
|
quality_profiles = [
|
||||||
{
|
{
|
||||||
name = "Remux + WEB 2160p";
|
name = "Remux + WEB 2160p";
|
||||||
|
min_format_score = 0;
|
||||||
|
reset_unmatched_scores.enabled = true;
|
||||||
|
upgrade = {
|
||||||
|
allowed = true;
|
||||||
|
until_quality = "Remux-2160p";
|
||||||
|
until_score = 10000;
|
||||||
|
};
|
||||||
qualities = [
|
qualities = [
|
||||||
{ name = "Remux-2160p"; }
|
{ name = "Remux-2160p"; }
|
||||||
{
|
{
|
||||||
name = "WEB 2160p";
|
name = "WEB/Bluray";
|
||||||
qualities = [
|
qualities = [
|
||||||
"WEBDL-2160p"
|
"WEBDL-2160p"
|
||||||
"WEBRip-2160p"
|
"WEBRip-2160p"
|
||||||
];
|
"Remux-1080p"
|
||||||
}
|
"Bluray-1080p"
|
||||||
{ name = "Remux-1080p"; }
|
|
||||||
{ name = "Bluray-1080p"; }
|
|
||||||
{
|
|
||||||
name = "WEB 1080p";
|
|
||||||
qualities = [
|
|
||||||
"WEBDL-1080p"
|
"WEBDL-1080p"
|
||||||
"WEBRip-1080p"
|
"WEBRip-1080p"
|
||||||
];
|
];
|
||||||
@@ -96,35 +108,57 @@ in
|
|||||||
{ name = "Remux + WEB 2160p"; }
|
{ name = "Remux + WEB 2160p"; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
# Upscaled - block AI upscales and other upscaled-to-2160p releases
|
||||||
|
{
|
||||||
|
trash_ids = [ "bfd8eb01832d646a0a89c4deb46f8564" ];
|
||||||
|
assign_scores_to = [
|
||||||
|
{
|
||||||
|
name = "Remux + WEB 2160p";
|
||||||
|
score = -10000;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
sonarr.series = {
|
sonarr.series = {
|
||||||
base_url = "http://localhost:${builtins.toString service_configs.ports.private.sonarr.port}";
|
base_url = "http://localhost:${builtins.toString service_configs.ports.private.sonarr.port}";
|
||||||
|
|
||||||
|
# Recyclarr is the sole authority for custom formats and scores.
|
||||||
|
# Overwrite any manually-created CFs and delete stale ones.
|
||||||
|
replace_existing_custom_formats = true;
|
||||||
|
delete_old_custom_formats = true;
|
||||||
|
|
||||||
include = [
|
include = [
|
||||||
{ template = "sonarr-quality-definition-series"; }
|
{ template = "sonarr-quality-definition-series"; }
|
||||||
{ template = "sonarr-v4-quality-profile-web-2160p"; }
|
{ template = "sonarr-v4-quality-profile-web-2160p"; }
|
||||||
{ template = "sonarr-v4-custom-formats-web-2160p"; }
|
{ template = "sonarr-v4-custom-formats-web-2160p"; }
|
||||||
];
|
];
|
||||||
|
|
||||||
# Extend the template's quality profile with lower-resolution fallbacks
|
# Group WEB 2160p with 1080p in the same quality tier so custom
|
||||||
|
# format scores -- not quality ranking -- decide the winner.
|
||||||
|
# Native 4K with HDR/DV from good release groups scores high and
|
||||||
|
# wins; AI upscales get -10000 from the Upscaled CF and are
|
||||||
|
# blocked by min_format_score. Untagged upscales from unknown
|
||||||
|
# groups (score ~0) lose to well-scored 1080p (Tier 01 = +1750).
|
||||||
quality_profiles = [
|
quality_profiles = [
|
||||||
{
|
{
|
||||||
name = "WEB-2160p";
|
name = "WEB-2160p";
|
||||||
|
min_format_score = 0;
|
||||||
|
reset_unmatched_scores.enabled = true;
|
||||||
|
upgrade = {
|
||||||
|
allowed = true;
|
||||||
|
until_quality = "WEB/Bluray";
|
||||||
|
until_score = 10000;
|
||||||
|
};
|
||||||
qualities = [
|
qualities = [
|
||||||
{
|
{
|
||||||
name = "WEB 2160p";
|
name = "WEB/Bluray";
|
||||||
qualities = [
|
qualities = [
|
||||||
"WEBDL-2160p"
|
"WEBDL-2160p"
|
||||||
"WEBRip-2160p"
|
"WEBRip-2160p"
|
||||||
];
|
"Bluray-1080p Remux"
|
||||||
}
|
"Bluray-1080p"
|
||||||
{ name = "Bluray-1080p Remux"; }
|
|
||||||
{ name = "Bluray-1080p"; }
|
|
||||||
{
|
|
||||||
name = "WEB 1080p";
|
|
||||||
qualities = [
|
|
||||||
"WEBDL-1080p"
|
"WEBDL-1080p"
|
||||||
"WEBRip-1080p"
|
"WEBRip-1080p"
|
||||||
];
|
];
|
||||||
@@ -151,14 +185,34 @@ in
|
|||||||
{ name = "WEB-2160p"; }
|
{ name = "WEB-2160p"; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
# Upscaled - block AI upscales and other upscaled-to-2160p releases
|
||||||
|
{
|
||||||
|
trash_ids = [ "23297a736ca77c0fc8e70f8edd7ee56c" ];
|
||||||
|
assign_scores_to = [
|
||||||
|
{
|
||||||
|
name = "WEB-2160p";
|
||||||
|
score = -10000;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Re-sync immediately on deploy when the recyclarr config changes
|
# Trigger immediate sync on deploy when recyclarr config changes.
|
||||||
|
# restartTriggers on the oneshot service are unreliable (systemd may
|
||||||
|
# no-op a restart of an inactive oneshot). Instead, embed a config
|
||||||
|
# hash in the timer unit -- NixOS restarts changed timers reliably,
|
||||||
|
# and OnActiveSec fires the sync within seconds.
|
||||||
|
systemd.timers.recyclarr = {
|
||||||
|
timerConfig.OnActiveSec = "5s";
|
||||||
|
unitConfig.X-ConfigHash = builtins.hashString "sha256" (
|
||||||
|
builtins.toJSON config.services.recyclarr.configuration
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
systemd.services.recyclarr = {
|
systemd.services.recyclarr = {
|
||||||
restartTriggers = [ (builtins.toJSON config.services.recyclarr.configuration) ];
|
|
||||||
after = [
|
after = [
|
||||||
"network-online.target"
|
"network-online.target"
|
||||||
"radarr.service"
|
"radarr.service"
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
(lib.serviceFilePerms "sonarr" [
|
(lib.serviceFilePerms "sonarr" [
|
||||||
"Z ${service_configs.sonarr.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.group}"
|
"Z ${service_configs.sonarr.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "sonarr";
|
||||||
|
port = service_configs.ports.private.sonarr.port;
|
||||||
|
auth = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
@@ -31,11 +36,6 @@
|
|||||||
settings.update.mechanism = "external";
|
settings.update.mechanism = "external";
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."sonarr.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.sonarr.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
users.users.${config.services.sonarr.user}.extraGroups = [
|
users.users.${config.services.sonarr.user}.extraGroups = [
|
||||||
service_configs.media_group
|
service_configs.media_group
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,9 +5,66 @@
|
|||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
let
|
||||||
|
prowlarrPort = toString service_configs.ports.private.prowlarr.port;
|
||||||
|
sonarrPort = toString service_configs.ports.private.sonarr.port;
|
||||||
|
radarrPort = toString service_configs.ports.private.radarr.port;
|
||||||
|
bitmagnetPort = toString service_configs.ports.private.bitmagnet.port;
|
||||||
|
bridgeAddr = config.vpnNamespaces.wg.bridgeAddress;
|
||||||
|
|
||||||
|
prowlarrConfigXml = "${service_configs.prowlarr.dataDir}/config.xml";
|
||||||
|
sonarrConfigXml = "${service_configs.sonarr.dataDir}/config.xml";
|
||||||
|
radarrConfigXml = "${service_configs.radarr.dataDir}/config.xml";
|
||||||
|
|
||||||
|
curl = "${pkgs.curl}/bin/curl";
|
||||||
|
jq = "${pkgs.jq}/bin/jq";
|
||||||
|
|
||||||
|
# Clears the escalating failure backoff for the Bitmagnet indexer across
|
||||||
|
# Prowlarr, Sonarr, and Radarr so searches resume immediately after
|
||||||
|
# Bitmagnet restarts instead of waiting hours for disable timers to expire.
|
||||||
|
recoveryScript = pkgs.writeShellScript "prowlarr-bitmagnet-recovery" ''
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
wait_for() {
|
||||||
|
for _ in $(seq 1 "$2"); do
|
||||||
|
${curl} -sf --max-time 5 "$1" > /dev/null && return 0
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "$1 not reachable, aborting" >&2; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test a Bitmagnet-named indexer to clear its failure status.
|
||||||
|
# A successful test triggers RecordSuccess() which resets the backoff.
|
||||||
|
clear_status() {
|
||||||
|
local key indexer
|
||||||
|
key=$(${lib.extractArrApiKey ''"$3"''}) || return 0
|
||||||
|
indexer=$(${curl} -sf --max-time 10 \
|
||||||
|
-H "X-Api-Key: $key" "$2/api/$1/indexer" | \
|
||||||
|
${jq} 'first(.[] | select(.name | test("Bitmagnet"; "i")))') || return 0
|
||||||
|
[ -n "$indexer" ] && [ "$indexer" != "null" ] || return 0
|
||||||
|
${curl} -sf --max-time 30 \
|
||||||
|
-H "X-Api-Key: $key" -H "Content-Type: application/json" \
|
||||||
|
-X POST "$2/api/$1/indexer/test" -d "$indexer" > /dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for "http://localhost:${bitmagnetPort}" 12
|
||||||
|
wait_for "http://localhost:${prowlarrPort}/ping" 6
|
||||||
|
|
||||||
|
# Prowlarr first — downstream apps route searches through it.
|
||||||
|
clear_status v1 "http://localhost:${prowlarrPort}" "${prowlarrConfigXml}" || true
|
||||||
|
clear_status v3 "http://${bridgeAddr}:${sonarrPort}" "${sonarrConfigXml}" || true
|
||||||
|
clear_status v3 "http://${bridgeAddr}:${radarrPort}" "${radarrConfigXml}" || true
|
||||||
|
'';
|
||||||
|
in
|
||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
(lib.vpnNamespaceOpenPort service_configs.ports.private.bitmagnet.port "bitmagnet")
|
(lib.vpnNamespaceOpenPort service_configs.ports.private.bitmagnet.port "bitmagnet")
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "bitmagnet";
|
||||||
|
port = service_configs.ports.private.bitmagnet.port;
|
||||||
|
auth = true;
|
||||||
|
vpn = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.bitmagnet = {
|
services.bitmagnet = {
|
||||||
@@ -19,13 +76,38 @@
|
|||||||
};
|
};
|
||||||
http_server = {
|
http_server = {
|
||||||
# TODO! make issue about this being a string and not a `port` type
|
# TODO! make issue about this being a string and not a `port` type
|
||||||
port = ":" + (builtins.toString service_configs.ports.private.bitmagnet.port);
|
port = ":" + (toString service_configs.ports.private.bitmagnet.port);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."bitmagnet.${service_configs.https.domain}".extraConfig = ''
|
# The upstream default (Restart=on-failure) leaves Bitmagnet dead after
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
# clean exits (e.g. systemd stop during deploy). Always restart it.
|
||||||
reverse_proxy ${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.bitmagnet.port}
|
systemd.services.bitmagnet.serviceConfig = {
|
||||||
'';
|
Restart = lib.mkForce "always";
|
||||||
|
RestartSec = 10;
|
||||||
|
};
|
||||||
|
|
||||||
|
# After Bitmagnet restarts, clear the escalating failure backoff across
|
||||||
|
# Prowlarr, Sonarr, and Radarr so searches resume immediately instead of
|
||||||
|
# waiting hours for the disable timers to expire.
|
||||||
|
systemd.services.prowlarr-bitmagnet-recovery = {
|
||||||
|
description = "Clear Prowlarr/Sonarr/Radarr failure status for Bitmagnet indexer";
|
||||||
|
after = [
|
||||||
|
"bitmagnet.service"
|
||||||
|
"prowlarr.service"
|
||||||
|
"sonarr.service"
|
||||||
|
"radarr.service"
|
||||||
|
];
|
||||||
|
bindsTo = [ "bitmagnet.service" ];
|
||||||
|
wantedBy = [ "bitmagnet.service" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
ExecStart = recoveryScript;
|
||||||
|
# Same VPN namespace as Bitmagnet and Prowlarr.
|
||||||
|
NetworkNamespacePath = "/run/netns/wg";
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
(lib.serviceFilePerms "vaultwarden" [
|
(lib.serviceFilePerms "vaultwarden" [
|
||||||
"Z ${service_configs.vaultwarden.path} 0700 vaultwarden vaultwarden"
|
"Z ${service_configs.vaultwarden.path} 0700 vaultwarden vaultwarden"
|
||||||
])
|
])
|
||||||
|
(lib.mkFail2banJail {
|
||||||
|
name = "vaultwarden";
|
||||||
|
failregex = ''^.*Username or password is incorrect\. Try again\. IP: <HOST>\..*$'';
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.vaultwarden = {
|
services.vaultwarden = {
|
||||||
@@ -38,18 +42,4 @@
|
|||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Protect Vaultwarden login from brute force attacks
|
|
||||||
services.fail2ban.jails.vaultwarden = {
|
|
||||||
enabled = true;
|
|
||||||
settings = {
|
|
||||||
backend = "systemd";
|
|
||||||
port = "http,https";
|
|
||||||
# defaults: maxretry=5, findtime=10m, bantime=10m
|
|
||||||
};
|
|
||||||
filter.Definition = {
|
|
||||||
failregex = ''^.*Username or password is incorrect\. Try again\. IP: <HOST>\..*$'';
|
|
||||||
ignoreregex = "";
|
|
||||||
journalmatch = "_SYSTEMD_UNIT=vaultwarden.service";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,19 @@ in
|
|||||||
enable = true;
|
enable = true;
|
||||||
email = "titaniumtown@proton.me";
|
email = "titaniumtown@proton.me";
|
||||||
|
|
||||||
# Enable on-demand TLS for old domain redirects
|
# Build with Njalla DNS provider for DNS-01 ACME challenges (wildcard certs)
|
||||||
# Certs are issued dynamically when subdomains are accessed
|
package = pkgs.caddy.withPlugins {
|
||||||
|
plugins = [ "github.com/caddy-dns/njalla@v0.0.0-20250823094507-f709141f1fe6" ];
|
||||||
|
hash = "sha256-rrOAR6noTDpV/I/hZXxhz0OXVJKu0mFQRq87RUrpmzw=";
|
||||||
|
};
|
||||||
|
|
||||||
globalConfig = ''
|
globalConfig = ''
|
||||||
|
# Wildcard cert for *.${newDomain} via DNS-01 challenge
|
||||||
|
acme_dns njalla {
|
||||||
|
api_token {env.NJALLA_API_TOKEN}
|
||||||
|
}
|
||||||
|
|
||||||
|
# On-demand TLS for old domain redirects
|
||||||
on_demand_tls {
|
on_demand_tls {
|
||||||
ask http://localhost:9123/check
|
ask http://localhost:9123/check
|
||||||
}
|
}
|
||||||
@@ -106,6 +116,9 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# Inject Njalla API token for DNS-01 challenge
|
||||||
|
systemd.services.caddy.serviceConfig.EnvironmentFile = config.age.secrets.njalla-api-token-env.path;
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d ${config.services.caddy.dataDir} 700 ${config.services.caddy.user} ${config.services.caddy.group}"
|
"d ${config.services.caddy.dataDir} 700 ${config.services.caddy.user} ${config.services.caddy.group}"
|
||||||
];
|
];
|
||||||
|
|||||||
27
services/ddns-updater.nix
Normal file
27
services/ddns-updater.nix
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
{
|
||||||
|
services.ddns-updater = {
|
||||||
|
enable = true;
|
||||||
|
environment = {
|
||||||
|
PERIOD = "5m";
|
||||||
|
# ddns-updater reads config from this path at runtime
|
||||||
|
CONFIG_FILEPATH = config.age.secrets.ddns-updater-config.path;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
users.users.ddns-updater = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "ddns-updater";
|
||||||
|
};
|
||||||
|
users.groups.ddns-updater = { };
|
||||||
|
|
||||||
|
systemd.services.ddns-updater.serviceConfig = {
|
||||||
|
DynamicUser = lib.mkForce false;
|
||||||
|
User = "ddns-updater";
|
||||||
|
Group = "ddns-updater";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,13 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
|
imports = [
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
domain = service_configs.firefox_syncserver.domain;
|
||||||
|
port = service_configs.ports.private.firefox_syncserver.port;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
services.firefox-syncserver = {
|
services.firefox-syncserver = {
|
||||||
enable = true;
|
enable = true;
|
||||||
database = {
|
database = {
|
||||||
@@ -33,7 +40,4 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."${service_configs.firefox_syncserver.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.firefox_syncserver.port}
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,13 +29,17 @@
|
|||||||
settings = {
|
settings = {
|
||||||
runner = {
|
runner = {
|
||||||
capacity = 1;
|
capacity = 1;
|
||||||
timeout = "3h";
|
timeout = "6h";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Override DynamicUser to use our static gitea-runner user
|
# Override DynamicUser to use our static gitea-runner user, and ensure
|
||||||
|
# the runner doesn't start before the co-located gitea instance is ready
|
||||||
|
# (upstream can't assume locality, so this dependency is ours to add).
|
||||||
systemd.services."gitea-runner-muffin" = {
|
systemd.services."gitea-runner-muffin" = {
|
||||||
|
requires = [ "gitea.service" ];
|
||||||
|
after = [ "gitea.service" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
DynamicUser = lib.mkForce false;
|
DynamicUser = lib.mkForce false;
|
||||||
User = "gitea-runner";
|
User = "gitea-runner";
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
(lib.serviceFilePerms "gitea" [
|
(lib.serviceFilePerms "gitea" [
|
||||||
"Z ${config.services.gitea.stateDir} 0700 ${config.services.gitea.user} ${config.services.gitea.group}"
|
"Z ${config.services.gitea.stateDir} 0700 ${config.services.gitea.user} ${config.services.gitea.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
domain = service_configs.gitea.domain;
|
||||||
|
port = service_configs.ports.private.gitea.port;
|
||||||
|
})
|
||||||
|
(lib.mkFail2banJail {
|
||||||
|
name = "gitea";
|
||||||
|
failregex = "^.*Failed authentication attempt for .* from <HOST>:.*$";
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.gitea = {
|
services.gitea = {
|
||||||
@@ -41,10 +49,6 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."${service_configs.gitea.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${builtins.toString config.services.gitea.settings.server.HTTP_PORT}
|
|
||||||
'';
|
|
||||||
|
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
ensureDatabases = [ config.services.gitea.user ];
|
ensureDatabases = [ config.services.gitea.user ];
|
||||||
ensureUsers = [
|
ensureUsers = [
|
||||||
@@ -58,18 +62,4 @@
|
|||||||
|
|
||||||
services.openssh.settings.AllowUsers = [ config.services.gitea.user ];
|
services.openssh.settings.AllowUsers = [ config.services.gitea.user ];
|
||||||
|
|
||||||
# Protect Gitea login from brute force attacks
|
|
||||||
services.fail2ban.jails.gitea = {
|
|
||||||
enabled = true;
|
|
||||||
settings = {
|
|
||||||
backend = "systemd";
|
|
||||||
port = "http,https";
|
|
||||||
# defaults: maxretry=5, findtime=10m, bantime=10m
|
|
||||||
};
|
|
||||||
filter.Definition = {
|
|
||||||
failregex = "^.*Failed authentication attempt for .* from <HOST>:.*$";
|
|
||||||
ignoreregex = "";
|
|
||||||
journalmatch = "_SYSTEMD_UNIT=gitea.service";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,17 @@ let
|
|||||||
}
|
}
|
||||||
{
|
{
|
||||||
name = "LLM Requests";
|
name = "LLM Requests";
|
||||||
datasource = {
|
datasource = promDs;
|
||||||
type = "grafana";
|
|
||||||
uid = "-- Grafana --";
|
|
||||||
};
|
|
||||||
enable = true;
|
enable = true;
|
||||||
iconColor = "purple";
|
iconColor = "purple";
|
||||||
showIn = 0;
|
target = {
|
||||||
type = "tags";
|
datasource = promDs;
|
||||||
tags = [ "llama-cpp" ];
|
expr = "llamacpp:requests_processing > 0";
|
||||||
|
instant = false;
|
||||||
|
range = true;
|
||||||
|
refId = "A";
|
||||||
|
};
|
||||||
|
titleFormat = "LLM inference";
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -613,13 +615,13 @@ let
|
|||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
datasource = promDs;
|
datasource = promDs;
|
||||||
expr = "zpool_used_bytes{pool=\"tank\"} / zpool_size_bytes{pool=\"tank\"} * 100";
|
expr = "zfs_pool_allocated_bytes{pool=\"tank\"} / zfs_pool_size_bytes{pool=\"tank\"} * 100";
|
||||||
legendFormat = "tank";
|
legendFormat = "tank";
|
||||||
refId = "A";
|
refId = "A";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
datasource = promDs;
|
datasource = promDs;
|
||||||
expr = "zpool_used_bytes{pool=\"hdds\"} / zpool_size_bytes{pool=\"hdds\"} * 100";
|
expr = "zfs_pool_allocated_bytes{pool=\"hdds\"} / zfs_pool_size_bytes{pool=\"hdds\"} * 100";
|
||||||
legendFormat = "hdds";
|
legendFormat = "hdds";
|
||||||
refId = "B";
|
refId = "B";
|
||||||
}
|
}
|
||||||
@@ -653,19 +655,19 @@ let
|
|||||||
targets = [
|
targets = [
|
||||||
{
|
{
|
||||||
datasource = promDs;
|
datasource = promDs;
|
||||||
expr = "partition_used_bytes{mount=\"/boot\"} / partition_size_bytes{mount=\"/boot\"} * 100";
|
expr = "(node_filesystem_size_bytes{mountpoint=\"/boot\"} - node_filesystem_avail_bytes{mountpoint=\"/boot\"}) / node_filesystem_size_bytes{mountpoint=\"/boot\"} * 100";
|
||||||
legendFormat = "/boot";
|
legendFormat = "/boot";
|
||||||
refId = "A";
|
refId = "A";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
datasource = promDs;
|
datasource = promDs;
|
||||||
expr = "partition_used_bytes{mount=\"/persistent\"} / partition_size_bytes{mount=\"/persistent\"} * 100";
|
expr = "(node_filesystem_size_bytes{mountpoint=\"/persistent\"} - node_filesystem_avail_bytes{mountpoint=\"/persistent\"}) / node_filesystem_size_bytes{mountpoint=\"/persistent\"} * 100";
|
||||||
legendFormat = "/persistent";
|
legendFormat = "/persistent";
|
||||||
refId = "B";
|
refId = "B";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
datasource = promDs;
|
datasource = promDs;
|
||||||
expr = "partition_used_bytes{mount=\"/nix\"} / partition_size_bytes{mount=\"/nix\"} * 100";
|
expr = "(node_filesystem_size_bytes{mountpoint=\"/nix\"} - node_filesystem_avail_bytes{mountpoint=\"/nix\"}) / node_filesystem_size_bytes{mountpoint=\"/nix\"} * 100";
|
||||||
legendFormat = "/nix";
|
legendFormat = "/nix";
|
||||||
refId = "C";
|
refId = "C";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
./dashboard.nix
|
./dashboard.nix
|
||||||
./exporters.nix
|
./exporters.nix
|
||||||
./jellyfin-annotations.nix
|
./jellyfin-annotations.nix
|
||||||
./disk-usage-collector.nix
|
|
||||||
./llama-cpp-annotations.nix
|
|
||||||
./zfs-scrub-annotations.nix
|
./zfs-scrub-annotations.nix
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
pkgs,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
textfileDir = "/var/lib/prometheus-node-exporter-textfiles";
|
|
||||||
|
|
||||||
diskUsageCollector = pkgs.writeShellApplication {
|
|
||||||
name = "disk-usage-collector";
|
|
||||||
runtimeInputs = with pkgs; [
|
|
||||||
coreutils
|
|
||||||
gawk
|
|
||||||
config.boot.zfs.package
|
|
||||||
util-linux # for mountpoint
|
|
||||||
];
|
|
||||||
text = builtins.readFile ./disk-usage-collector.sh;
|
|
||||||
};
|
|
||||||
in
|
|
||||||
lib.mkIf config.services.grafana.enable {
|
|
||||||
systemd.services.disk-usage-collector = {
|
|
||||||
description = "Collect ZFS pool and partition usage metrics for Prometheus";
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
ExecStart = lib.getExe diskUsageCollector;
|
|
||||||
};
|
|
||||||
environment.TEXTFILE = "${textfileDir}/disk-usage.prom";
|
|
||||||
};
|
|
||||||
|
|
||||||
systemd.timers.disk-usage-collector = {
|
|
||||||
wantedBy = [ "timers.target" ];
|
|
||||||
timerConfig = {
|
|
||||||
OnCalendar = "minutely";
|
|
||||||
RandomizedDelaySec = "10s";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Collects ZFS pool utilization and boot partition usage for Prometheus textfile collector
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
TEXTFILE="${TEXTFILE:?TEXTFILE env required}"
|
|
||||||
TMP="${TEXTFILE}.$$"
|
|
||||||
|
|
||||||
{
|
|
||||||
echo '# HELP zpool_size_bytes Total size of ZFS pool in bytes'
|
|
||||||
echo '# TYPE zpool_size_bytes gauge'
|
|
||||||
echo '# HELP zpool_used_bytes Used space in ZFS pool in bytes'
|
|
||||||
echo '# TYPE zpool_used_bytes gauge'
|
|
||||||
echo '# HELP zpool_free_bytes Free space in ZFS pool in bytes'
|
|
||||||
echo '# TYPE zpool_free_bytes gauge'
|
|
||||||
|
|
||||||
# -Hp: scripting mode, parseable, bytes
|
|
||||||
zpool list -Hp -o name,size,alloc,free | while IFS=$'\t' read -r name size alloc free; do
|
|
||||||
echo "zpool_size_bytes{pool=\"${name}\"} ${size}"
|
|
||||||
echo "zpool_used_bytes{pool=\"${name}\"} ${alloc}"
|
|
||||||
echo "zpool_free_bytes{pool=\"${name}\"} ${free}"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo '# HELP partition_size_bytes Total size of partition in bytes'
|
|
||||||
echo '# TYPE partition_size_bytes gauge'
|
|
||||||
echo '# HELP partition_used_bytes Used space on partition in bytes'
|
|
||||||
echo '# TYPE partition_used_bytes gauge'
|
|
||||||
echo '# HELP partition_free_bytes Free space on partition in bytes'
|
|
||||||
echo '# TYPE partition_free_bytes gauge'
|
|
||||||
|
|
||||||
# Boot drive partitions: /boot (ESP), /persistent, /nix
|
|
||||||
# Use df with 1K blocks and convert to bytes
|
|
||||||
for mount in /boot /persistent /nix; do
|
|
||||||
if mountpoint -q "$mount" 2>/dev/null; then
|
|
||||||
read -r size used avail _ <<< "$(df -k --output=size,used,avail "$mount" | tail -1)"
|
|
||||||
size_b=$((size * 1024))
|
|
||||||
used_b=$((used * 1024))
|
|
||||||
avail_b=$((avail * 1024))
|
|
||||||
echo "partition_size_bytes{mount=\"${mount}\"} ${size_b}"
|
|
||||||
echo "partition_used_bytes{mount=\"${mount}\"} ${used_b}"
|
|
||||||
echo "partition_free_bytes{mount=\"${mount}\"} ${avail_b}"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
} > "$TMP"
|
|
||||||
mv "$TMP" "$TEXTFILE"
|
|
||||||
@@ -12,6 +12,11 @@
|
|||||||
(lib.serviceFilePerms "grafana" [
|
(lib.serviceFilePerms "grafana" [
|
||||||
"Z ${service_configs.grafana.dir} 0700 grafana grafana"
|
"Z ${service_configs.grafana.dir} 0700 grafana grafana"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
domain = service_configs.grafana.domain;
|
||||||
|
port = service_configs.ports.private.grafana.port;
|
||||||
|
auth = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.grafana = {
|
services.grafana = {
|
||||||
@@ -85,11 +90,6 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."${service_configs.grafana.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${toString service_configs.ports.private.grafana.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
services.postgresql = {
|
services.postgresql = {
|
||||||
ensureDatabases = [ "grafana" ];
|
ensureDatabases = [ "grafana" ];
|
||||||
ensureUsers = [
|
ensureUsers = [
|
||||||
|
|||||||
@@ -1,40 +1,18 @@
|
|||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
pkgs,
|
|
||||||
service_configs,
|
service_configs,
|
||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
lib.mkIf (config.services.grafana.enable && config.services.jellyfin.enable) {
|
lib.mkIf (config.services.grafana.enable && config.services.jellyfin.enable) (
|
||||||
systemd.services.jellyfin-annotations = {
|
lib.mkGrafanaAnnotationService {
|
||||||
|
name = "jellyfin";
|
||||||
description = "Jellyfin stream annotation service for Grafana";
|
description = "Jellyfin stream annotation service for Grafana";
|
||||||
after = [
|
script = ./jellyfin-annotations.py;
|
||||||
"network.target"
|
|
||||||
"grafana.service"
|
|
||||||
];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${./jellyfin-annotations.py}";
|
|
||||||
Restart = "always";
|
|
||||||
RestartSec = "10s";
|
|
||||||
LoadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
|
|
||||||
DynamicUser = true;
|
|
||||||
StateDirectory = "jellyfin-annotations";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ProtectHome = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
RestrictAddressFamilies = [
|
|
||||||
"AF_INET"
|
|
||||||
"AF_INET6"
|
|
||||||
];
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
};
|
|
||||||
environment = {
|
environment = {
|
||||||
JELLYFIN_URL = "http://127.0.0.1:${toString service_configs.ports.private.jellyfin.port}";
|
JELLYFIN_URL = "http://127.0.0.1:${toString service_configs.ports.private.jellyfin.port}";
|
||||||
GRAFANA_URL = "http://127.0.0.1:${toString service_configs.ports.private.grafana.port}";
|
|
||||||
STATE_FILE = "/var/lib/jellyfin-annotations/state.json";
|
|
||||||
POLL_INTERVAL = "30";
|
POLL_INTERVAL = "30";
|
||||||
};
|
};
|
||||||
};
|
loadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
pkgs,
|
|
||||||
service_configs,
|
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
lib.mkIf (config.services.grafana.enable && config.services.llama-cpp.enable) {
|
|
||||||
systemd.services.llama-cpp-annotations = {
|
|
||||||
description = "LLM request annotation service for Grafana";
|
|
||||||
after = [
|
|
||||||
"grafana.service"
|
|
||||||
"llama-cpp.service"
|
|
||||||
];
|
|
||||||
wantedBy = [ "multi-user.target" ];
|
|
||||||
serviceConfig = {
|
|
||||||
ExecStart = "${pkgs.python3}/bin/python3 ${./llama-cpp-annotations.py}";
|
|
||||||
Restart = "always";
|
|
||||||
RestartSec = "10s";
|
|
||||||
DynamicUser = true;
|
|
||||||
StateDirectory = "llama-cpp-annotations";
|
|
||||||
NoNewPrivileges = true;
|
|
||||||
ProtectSystem = "strict";
|
|
||||||
ProtectHome = true;
|
|
||||||
PrivateTmp = true;
|
|
||||||
RestrictAddressFamilies = [
|
|
||||||
"AF_INET"
|
|
||||||
"AF_INET6"
|
|
||||||
];
|
|
||||||
MemoryDenyWriteExecute = true;
|
|
||||||
};
|
|
||||||
environment = {
|
|
||||||
GRAFANA_URL = "http://127.0.0.1:${toString service_configs.ports.private.grafana.port}";
|
|
||||||
STATE_FILE = "/var/lib/llama-cpp-annotations/state.json";
|
|
||||||
POLL_INTERVAL = "5";
|
|
||||||
CPU_THRESHOLD = "50";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Grafana annotation service for llama-cpp inference requests.
|
|
||||||
|
|
||||||
Monitors llama-server CPU usage via /proc. Creates a Grafana annotation
|
|
||||||
when inference starts (CPU spikes), closes it when inference ends.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import glob
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
GRAFANA_URL = os.environ.get("GRAFANA_URL", "http://127.0.0.1:3000")
|
|
||||||
STATE_FILE = os.environ.get("STATE_FILE", "/var/lib/llama-cpp-annotations/state.json")
|
|
||||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5"))
|
|
||||||
CPU_THRESHOLD = float(os.environ.get("CPU_THRESHOLD", "50"))
|
|
||||||
|
|
||||||
|
|
||||||
def find_llama_pid():
|
|
||||||
for path in glob.glob("/proc/[0-9]*/comm"):
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
if f.read().strip() == "llama-server":
|
|
||||||
return int(path.split("/")[2])
|
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_cpu_times(pid):
|
|
||||||
try:
|
|
||||||
with open(f"/proc/{pid}/stat") as f:
|
|
||||||
fields = f.read().split(")")[-1].split()
|
|
||||||
return int(fields[11]) + int(fields[12])
|
|
||||||
except (OSError, IndexError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def http_json(method, url, body=None):
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
data=data,
|
|
||||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
||||||
method=method,
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
||||||
return json.loads(resp.read())
|
|
||||||
|
|
||||||
|
|
||||||
def load_state():
|
|
||||||
try:
|
|
||||||
with open(STATE_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except (FileNotFoundError, json.JSONDecodeError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def save_state(state):
|
|
||||||
os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
|
|
||||||
tmp = STATE_FILE + ".tmp"
|
|
||||||
with open(tmp, "w") as f:
|
|
||||||
json.dump(state, f)
|
|
||||||
os.replace(tmp, STATE_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def grafana_post(text, start_ms):
|
|
||||||
try:
|
|
||||||
result = http_json(
|
|
||||||
"POST",
|
|
||||||
f"{GRAFANA_URL}/api/annotations",
|
|
||||||
{"time": start_ms, "text": text, "tags": ["llama-cpp"]},
|
|
||||||
)
|
|
||||||
return result.get("id")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error posting annotation: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def grafana_close(grafana_id, end_ms, text=None):
|
|
||||||
try:
|
|
||||||
body = {"timeEnd": end_ms}
|
|
||||||
if text is not None:
|
|
||||||
body["text"] = text
|
|
||||||
http_json(
|
|
||||||
"PATCH",
|
|
||||||
f"{GRAFANA_URL}/api/annotations/{grafana_id}",
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error closing annotation {grafana_id}: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
state = load_state()
|
|
||||||
prev_ticks = None
|
|
||||||
prev_time = None
|
|
||||||
hz = os.sysconf("SC_CLK_TCK")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
pid = find_llama_pid()
|
|
||||||
|
|
||||||
if pid is None:
|
|
||||||
prev_ticks = None
|
|
||||||
prev_time = None
|
|
||||||
time.sleep(POLL_INTERVAL)
|
|
||||||
continue
|
|
||||||
|
|
||||||
ticks = get_cpu_times(pid)
|
|
||||||
now = time.monotonic()
|
|
||||||
|
|
||||||
if ticks is None or prev_ticks is None or prev_time is None:
|
|
||||||
prev_ticks = ticks
|
|
||||||
prev_time = now
|
|
||||||
time.sleep(POLL_INTERVAL)
|
|
||||||
continue
|
|
||||||
|
|
||||||
dt = now - prev_time
|
|
||||||
if dt <= 0:
|
|
||||||
prev_ticks = ticks
|
|
||||||
prev_time = now
|
|
||||||
time.sleep(POLL_INTERVAL)
|
|
||||||
continue
|
|
||||||
|
|
||||||
cpu_pct = ((ticks - prev_ticks) / hz) / dt * 100
|
|
||||||
prev_ticks = ticks
|
|
||||||
prev_time = now
|
|
||||||
|
|
||||||
busy = cpu_pct > CPU_THRESHOLD
|
|
||||||
|
|
||||||
if busy and "active" not in state:
|
|
||||||
grafana_id = grafana_post("LLM request", now_ms)
|
|
||||||
if grafana_id is not None:
|
|
||||||
state["active"] = {
|
|
||||||
"grafana_id": grafana_id,
|
|
||||||
"start_ms": now_ms,
|
|
||||||
}
|
|
||||||
save_state(state)
|
|
||||||
|
|
||||||
elif not busy and "active" in state:
|
|
||||||
info = state.pop("active")
|
|
||||||
duration_s = (now_ms - info["start_ms"]) / 1000
|
|
||||||
text = f"LLM request ({duration_s:.1f}s)"
|
|
||||||
grafana_close(info["grafana_id"], now_ms, text)
|
|
||||||
save_state(state)
|
|
||||||
|
|
||||||
time.sleep(POLL_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -44,6 +44,12 @@ in
|
|||||||
listenAddress = "127.0.0.1";
|
listenAddress = "127.0.0.1";
|
||||||
apcupsdAddress = "127.0.0.1:3551";
|
apcupsdAddress = "127.0.0.1:3551";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
zfs = {
|
||||||
|
enable = true;
|
||||||
|
port = service_configs.ports.private.prometheus_zfs.port;
|
||||||
|
listenAddress = "127.0.0.1";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
scrapeConfigs = [
|
scrapeConfigs = [
|
||||||
@@ -89,6 +95,12 @@ in
|
|||||||
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.igpu_exporter.port}" ]; }
|
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.igpu_exporter.port}" ]; }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
job_name = "zfs";
|
||||||
|
static_configs = [
|
||||||
|
{ targets = [ "127.0.0.1:${toString service_configs.ports.private.prometheus_zfs.port}" ]; }
|
||||||
|
];
|
||||||
|
}
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,22 @@
|
|||||||
settings.bind = "127.0.0.1:${toString service_configs.ports.private.harmonia.port}";
|
settings.bind = "127.0.0.1:${toString service_configs.ports.private.harmonia.port}";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
# serve latest deploy store paths (unauthenticated — just a path string)
|
||||||
|
# CI writes to /var/lib/dotfiles-deploy/<hostname> after building
|
||||||
services.caddy.virtualHosts."nix-cache.${service_configs.https.domain}".extraConfig = ''
|
services.caddy.virtualHosts."nix-cache.${service_configs.https.domain}".extraConfig = ''
|
||||||
|
handle_path /deploy/* {
|
||||||
|
root * /var/lib/dotfiles-deploy
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
import ${config.age.secrets.nix-cache-auth.path}
|
import ${config.age.secrets.nix-cache-auth.path}
|
||||||
reverse_proxy :${toString service_configs.ports.private.harmonia.port}
|
reverse_proxy :${toString service_configs.ports.private.harmonia.port}
|
||||||
'';
|
}
|
||||||
|
'';
|
||||||
|
|
||||||
|
# directory for CI to record latest deploy store paths
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d /var/lib/dotfiles-deploy 0755 gitea-runner gitea-runner"
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
(lib.serviceFilePerms "immich-server" [
|
(lib.serviceFilePerms "immich-server" [
|
||||||
"Z ${config.services.immich.mediaLocation} 0770 ${config.services.immich.user} ${config.services.immich.group}"
|
"Z ${config.services.immich.mediaLocation} 0770 ${config.services.immich.user} ${config.services.immich.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "immich";
|
||||||
|
port = service_configs.ports.private.immich.port;
|
||||||
|
})
|
||||||
|
(lib.mkFail2banJail {
|
||||||
|
name = "immich";
|
||||||
|
unitName = "immich-server.service";
|
||||||
|
failregex = "^.*Failed login attempt for user .* from ip address <HOST>.*$";
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.immich = {
|
services.immich = {
|
||||||
@@ -29,10 +38,6 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."immich.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${builtins.toString config.services.immich.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
environment.systemPackages = with pkgs; [
|
environment.systemPackages = with pkgs; [
|
||||||
immich-go
|
immich-go
|
||||||
];
|
];
|
||||||
@@ -42,18 +47,4 @@
|
|||||||
"render"
|
"render"
|
||||||
];
|
];
|
||||||
|
|
||||||
# Protect Immich login from brute force attacks
|
|
||||||
services.fail2ban.jails.immich = {
|
|
||||||
enabled = true;
|
|
||||||
settings = {
|
|
||||||
backend = "systemd";
|
|
||||||
port = "http,https";
|
|
||||||
# defaults: maxretry=5, findtime=10m, bantime=10m
|
|
||||||
};
|
|
||||||
filter.Definition = {
|
|
||||||
failregex = "^.*Failed login attempt for user .* from ip address <HOST>.*$";
|
|
||||||
ignoreregex = "";
|
|
||||||
journalmatch = "_SYSTEMD_UNIT=immich-server.service";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,80 @@
|
|||||||
lib,
|
lib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
let
|
||||||
|
webhookPlugin = import ./jellyfin-webhook-plugin.nix { inherit pkgs lib; };
|
||||||
|
jellyfinPort = service_configs.ports.private.jellyfin.port;
|
||||||
|
webhookPort = service_configs.ports.private.jellyfin_qbittorrent_monitor_webhook.port;
|
||||||
|
in
|
||||||
lib.mkIf config.services.jellyfin.enable {
|
lib.mkIf config.services.jellyfin.enable {
|
||||||
|
# Materialise the Jellyfin Webhook plugin into Jellyfin's plugins dir before
|
||||||
|
# Jellyfin starts. Jellyfin rewrites meta.json at runtime, so a read-only
|
||||||
|
# nix-store symlink would EACCES -- we copy instead.
|
||||||
|
#
|
||||||
|
# `wantedBy = [ "jellyfin.service" ]` alone is insufficient on initial rollout:
|
||||||
|
# if jellyfin is already running at activation time, systemd won't start the
|
||||||
|
# oneshot until the next jellyfin restart. `restartTriggers` on jellyfin pinned
|
||||||
|
# to the plugin package + install script forces that restart whenever either
|
||||||
|
# changes, which invokes this unit via the `before`/`wantedBy` chain.
|
||||||
|
systemd.services.jellyfin-webhook-install = {
|
||||||
|
before = [ "jellyfin.service" ];
|
||||||
|
wantedBy = [ "jellyfin.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
User = config.services.jellyfin.user;
|
||||||
|
Group = config.services.jellyfin.group;
|
||||||
|
ExecStart = webhookPlugin.mkInstallScript {
|
||||||
|
pluginsDir = "${config.services.jellyfin.dataDir}/plugins";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.services.jellyfin.restartTriggers = [
|
||||||
|
webhookPlugin.package
|
||||||
|
(webhookPlugin.mkInstallScript {
|
||||||
|
pluginsDir = "${config.services.jellyfin.dataDir}/plugins";
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
# After Jellyfin starts, POST the plugin configuration so the webhook
|
||||||
|
# targets the monitor's receiver. Idempotent; runs on every boot.
|
||||||
|
systemd.services.jellyfin-webhook-configure = {
|
||||||
|
after = [ "jellyfin.service" ];
|
||||||
|
wants = [ "jellyfin.service" ];
|
||||||
|
before = [ "jellyfin-qbittorrent-monitor.service" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
DynamicUser = true;
|
||||||
|
LoadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
|
||||||
|
ExecStart = webhookPlugin.mkConfigureScript {
|
||||||
|
jellyfinUrl = "http://127.0.0.1:${toString jellyfinPort}";
|
||||||
|
webhooks = [
|
||||||
|
{
|
||||||
|
name = "qBittorrent Monitor";
|
||||||
|
uri = "http://127.0.0.1:${toString webhookPort}/";
|
||||||
|
notificationTypes = [
|
||||||
|
"PlaybackStart"
|
||||||
|
"PlaybackProgress"
|
||||||
|
"PlaybackStop"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
systemd.services."jellyfin-qbittorrent-monitor" = {
|
systemd.services."jellyfin-qbittorrent-monitor" = {
|
||||||
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
||||||
after = [
|
after = [
|
||||||
"network.target"
|
"network.target"
|
||||||
"jellyfin.service"
|
"jellyfin.service"
|
||||||
"qbittorrent.service"
|
"qbittorrent.service"
|
||||||
|
"jellyfin-webhook-configure.service"
|
||||||
];
|
];
|
||||||
|
wants = [ "jellyfin-webhook-configure.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
@@ -44,7 +110,7 @@ lib.mkIf config.services.jellyfin.enable {
|
|||||||
};
|
};
|
||||||
|
|
||||||
environment = {
|
environment = {
|
||||||
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.private.jellyfin.port}";
|
JELLYFIN_URL = "http://localhost:${builtins.toString jellyfinPort}";
|
||||||
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.torrent.port}";
|
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.private.torrent.port}";
|
||||||
CHECK_INTERVAL = "30";
|
CHECK_INTERVAL = "30";
|
||||||
# Bandwidth budget configuration
|
# Bandwidth budget configuration
|
||||||
@@ -53,6 +119,9 @@ lib.mkIf config.services.jellyfin.enable {
|
|||||||
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
|
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
|
||||||
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
|
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
|
||||||
STREAM_BITRATE_HEADROOM = "1.1"; # multiplier per stream for bitrate fluctuations
|
STREAM_BITRATE_HEADROOM = "1.1"; # multiplier per stream for bitrate fluctuations
|
||||||
|
# Webhook receiver: Jellyfin Webhook plugin POSTs events here to throttle immediately.
|
||||||
|
WEBHOOK_BIND = "127.0.0.1";
|
||||||
|
WEBHOOK_PORT = toString webhookPort;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import sys
|
|||||||
import signal
|
import signal
|
||||||
import json
|
import json
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||||
@@ -34,6 +36,8 @@ class JellyfinQBittorrentMonitor:
|
|||||||
default_stream_bitrate=10000000,
|
default_stream_bitrate=10000000,
|
||||||
min_torrent_speed=100,
|
min_torrent_speed=100,
|
||||||
stream_bitrate_headroom=1.1,
|
stream_bitrate_headroom=1.1,
|
||||||
|
webhook_port=0,
|
||||||
|
webhook_bind="127.0.0.1",
|
||||||
):
|
):
|
||||||
self.jellyfin_url = jellyfin_url
|
self.jellyfin_url = jellyfin_url
|
||||||
self.qbittorrent_url = qbittorrent_url
|
self.qbittorrent_url = qbittorrent_url
|
||||||
@@ -57,6 +61,12 @@ class JellyfinQBittorrentMonitor:
|
|||||||
self.streaming_stop_delay = streaming_stop_delay
|
self.streaming_stop_delay = streaming_stop_delay
|
||||||
self.last_state_change = 0
|
self.last_state_change = 0
|
||||||
|
|
||||||
|
# Webhook receiver: allows Jellyfin to push events instead of waiting for the poll
|
||||||
|
self.webhook_port = webhook_port
|
||||||
|
self.webhook_bind = webhook_bind
|
||||||
|
self.wake_event = threading.Event()
|
||||||
|
self.webhook_server = None
|
||||||
|
|
||||||
# Local network ranges (RFC 1918 private networks + localhost)
|
# Local network ranges (RFC 1918 private networks + localhost)
|
||||||
self.local_networks = [
|
self.local_networks = [
|
||||||
ipaddress.ip_network("10.0.0.0/8"),
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
@@ -79,9 +89,56 @@ class JellyfinQBittorrentMonitor:
|
|||||||
def signal_handler(self, signum, frame):
|
def signal_handler(self, signum, frame):
|
||||||
logger.info("Received shutdown signal, cleaning up...")
|
logger.info("Received shutdown signal, cleaning up...")
|
||||||
self.running = False
|
self.running = False
|
||||||
|
if self.webhook_server is not None:
|
||||||
|
# shutdown() blocks until serve_forever returns; run from a thread so we don't deadlock
|
||||||
|
threading.Thread(target=self.webhook_server.shutdown, daemon=True).start()
|
||||||
self.restore_normal_limits()
|
self.restore_normal_limits()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
def wake(self) -> None:
|
||||||
|
"""Signal the main loop to re-evaluate state immediately."""
|
||||||
|
self.wake_event.set()
|
||||||
|
|
||||||
|
def sleep_or_wake(self, seconds: float) -> None:
|
||||||
|
"""Wait up to `seconds`, returning early if a webhook wakes the loop."""
|
||||||
|
self.wake_event.wait(seconds)
|
||||||
|
self.wake_event.clear()
|
||||||
|
|
||||||
|
def start_webhook_server(self) -> None:
|
||||||
|
"""Start a background HTTP server that wakes the monitor on any POST."""
|
||||||
|
if not self.webhook_port:
|
||||||
|
return
|
||||||
|
|
||||||
|
monitor = self
|
||||||
|
|
||||||
|
class WebhookHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self): # noqa: N802
|
||||||
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
body = self.rfile.read(min(length, 65536)) if length else b""
|
||||||
|
event = "unknown"
|
||||||
|
try:
|
||||||
|
if body:
|
||||||
|
event = json.loads(body).get("NotificationType", "unknown")
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
logger.info(f"Webhook received: {event}")
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
monitor.wake()
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
return # suppress default access log
|
||||||
|
|
||||||
|
self.webhook_server = HTTPServer(
|
||||||
|
(self.webhook_bind, self.webhook_port), WebhookHandler
|
||||||
|
)
|
||||||
|
threading.Thread(
|
||||||
|
target=self.webhook_server.serve_forever, daemon=True, name="webhook-server"
|
||||||
|
).start()
|
||||||
|
logger.info(
|
||||||
|
f"Webhook receiver listening on http://{self.webhook_bind}:{self.webhook_port}"
|
||||||
|
)
|
||||||
|
|
||||||
def check_jellyfin_sessions(self) -> list[dict]:
|
def check_jellyfin_sessions(self) -> list[dict]:
|
||||||
headers = (
|
headers = (
|
||||||
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
||||||
@@ -297,10 +354,14 @@ class JellyfinQBittorrentMonitor:
|
|||||||
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
|
logger.info(f"Default stream bitrate: {self.default_stream_bitrate} bps")
|
||||||
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
|
logger.info(f"Minimum torrent speed: {self.min_torrent_speed} KB/s")
|
||||||
logger.info(f"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
logger.info(f"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
||||||
|
if self.webhook_port:
|
||||||
|
logger.info(f"Webhook receiver: {self.webhook_bind}:{self.webhook_port}")
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, self.signal_handler)
|
signal.signal(signal.SIGINT, self.signal_handler)
|
||||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||||
|
|
||||||
|
self.start_webhook_server()
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
self.sync_qbittorrent_state()
|
self.sync_qbittorrent_state()
|
||||||
@@ -309,7 +370,7 @@ class JellyfinQBittorrentMonitor:
|
|||||||
active_streams = self.check_jellyfin_sessions()
|
active_streams = self.check_jellyfin_sessions()
|
||||||
except ServiceUnavailable:
|
except ServiceUnavailable:
|
||||||
logger.warning("Jellyfin unavailable, maintaining current state")
|
logger.warning("Jellyfin unavailable, maintaining current state")
|
||||||
time.sleep(self.check_interval)
|
self.sleep_or_wake(self.check_interval)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
streaming_active = len(active_streams) > 0
|
streaming_active = len(active_streams) > 0
|
||||||
@@ -394,13 +455,13 @@ class JellyfinQBittorrentMonitor:
|
|||||||
|
|
||||||
self.current_state = desired_state
|
self.current_state = desired_state
|
||||||
self.last_active_streams = active_streams
|
self.last_active_streams = active_streams
|
||||||
time.sleep(self.check_interval)
|
self.sleep_or_wake(self.check_interval)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error in monitoring loop: {e}")
|
logger.error(f"Unexpected error in monitoring loop: {e}")
|
||||||
time.sleep(self.check_interval)
|
self.sleep_or_wake(self.check_interval)
|
||||||
|
|
||||||
self.restore_normal_limits()
|
self.restore_normal_limits()
|
||||||
logger.info("Monitor stopped")
|
logger.info("Monitor stopped")
|
||||||
@@ -421,6 +482,8 @@ if __name__ == "__main__":
|
|||||||
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
|
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
|
||||||
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
|
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
|
||||||
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
||||||
|
webhook_port = int(os.getenv("WEBHOOK_PORT", "0"))
|
||||||
|
webhook_bind = os.getenv("WEBHOOK_BIND", "127.0.0.1")
|
||||||
|
|
||||||
monitor = JellyfinQBittorrentMonitor(
|
monitor = JellyfinQBittorrentMonitor(
|
||||||
jellyfin_url=jellyfin_url,
|
jellyfin_url=jellyfin_url,
|
||||||
@@ -434,6 +497,8 @@ if __name__ == "__main__":
|
|||||||
default_stream_bitrate=default_stream_bitrate,
|
default_stream_bitrate=default_stream_bitrate,
|
||||||
min_torrent_speed=min_torrent_speed,
|
min_torrent_speed=min_torrent_speed,
|
||||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||||
|
webhook_port=webhook_port,
|
||||||
|
webhook_bind=webhook_bind,
|
||||||
)
|
)
|
||||||
|
|
||||||
monitor.run()
|
monitor.run()
|
||||||
|
|||||||
105
services/jellyfin/jellyfin-webhook-plugin.nix
Normal file
105
services/jellyfin/jellyfin-webhook-plugin.nix
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{ pkgs, lib }:
|
||||||
|
let
|
||||||
|
pluginVersion = "18.0.0.0";
|
||||||
|
# GUID from the plugin's meta.json; addresses it on /Plugins/<guid>/Configuration.
|
||||||
|
pluginGuid = "71552a5a-5c5c-4350-a2ae-ebe451a30173";
|
||||||
|
|
||||||
|
package = pkgs.stdenvNoCC.mkDerivation {
|
||||||
|
pname = "jellyfin-plugin-webhook";
|
||||||
|
version = pluginVersion;
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://repo.jellyfin.org/files/plugin/webhook/webhook_${pluginVersion}.zip";
|
||||||
|
hash = "sha256-LFFojiPnBGl9KJ0xVyPBnCmatcaeVbllRwRkz5Z3dqI=";
|
||||||
|
};
|
||||||
|
nativeBuildInputs = [ pkgs.unzip ];
|
||||||
|
unpackPhase = ''unzip "$src"'';
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp *.dll meta.json "$out/"
|
||||||
|
'';
|
||||||
|
dontFixup = true; # managed .NET assemblies must not be patched
|
||||||
|
};
|
||||||
|
|
||||||
|
# Minimal Handlebars template, base64 encoded. The monitor only needs the POST;
|
||||||
|
# NotificationType is parsed for the debug log line.
|
||||||
|
# Decoded: {"NotificationType":"{{NotificationType}}"}
|
||||||
|
templateB64 = "eyJOb3RpZmljYXRpb25UeXBlIjoie3tOb3RpZmljYXRpb25UeXBlfX0ifQ==";
|
||||||
|
|
||||||
|
# Build a PluginConfiguration payload accepted by Jellyfin's JSON deserializer.
|
||||||
|
# Each webhook is `{ name, uri, notificationTypes }`.
|
||||||
|
mkConfigJson =
|
||||||
|
webhooks:
|
||||||
|
builtins.toJSON {
|
||||||
|
ServerUrl = "";
|
||||||
|
GenericOptions = map (w: {
|
||||||
|
NotificationTypes = w.notificationTypes;
|
||||||
|
WebhookName = w.name;
|
||||||
|
WebhookUri = w.uri;
|
||||||
|
EnableMovies = true;
|
||||||
|
EnableEpisodes = true;
|
||||||
|
EnableVideos = true;
|
||||||
|
EnableWebhook = true;
|
||||||
|
Template = templateB64;
|
||||||
|
Headers = [
|
||||||
|
{
|
||||||
|
Key = "Content-Type";
|
||||||
|
Value = "application/json";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}) webhooks;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Oneshot that POSTs the plugin configuration. Retries past the window
|
||||||
|
# between Jellyfin API health and plugin registration.
|
||||||
|
mkConfigureScript =
|
||||||
|
{ jellyfinUrl, webhooks }:
|
||||||
|
pkgs.writeShellScript "jellyfin-webhook-configure" ''
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH=${
|
||||||
|
lib.makeBinPath [
|
||||||
|
pkgs.coreutils
|
||||||
|
pkgs.curl
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
URL=${lib.escapeShellArg jellyfinUrl}
|
||||||
|
AUTH="Authorization: MediaBrowser Token=\"$(cat "$CREDENTIALS_DIRECTORY/jellyfin-api-key")\""
|
||||||
|
CONFIG=${lib.escapeShellArg (mkConfigJson webhooks)}
|
||||||
|
|
||||||
|
for _ in $(seq 1 120); do curl -sf -o /dev/null "$URL/health" && break; sleep 1; done
|
||||||
|
curl -sf -o /dev/null "$URL/health"
|
||||||
|
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
if printf '%s' "$CONFIG" | curl -sf -X POST \
|
||||||
|
-H "$AUTH" -H "Content-Type: application/json" --data-binary @- \
|
||||||
|
"$URL/Plugins/${pluginGuid}/Configuration"; then
|
||||||
|
echo "Jellyfin webhook plugin configured"; exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Failed to configure webhook plugin" >&2; exit 1
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Materialise a writable copy of the plugin. Jellyfin rewrites meta.json at
|
||||||
|
# runtime, so a read-only nix-store symlink would EACCES.
|
||||||
|
mkInstallScript =
|
||||||
|
{ pluginsDir }:
|
||||||
|
pkgs.writeShellScript "jellyfin-webhook-install" ''
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH=${lib.makeBinPath [ pkgs.coreutils ]}
|
||||||
|
dst=${lib.escapeShellArg "${pluginsDir}/Webhook_${pluginVersion}"}
|
||||||
|
mkdir -p ${lib.escapeShellArg pluginsDir}
|
||||||
|
rm -rf "$dst" && mkdir -p "$dst"
|
||||||
|
cp ${package}/*.dll ${package}/meta.json "$dst/"
|
||||||
|
chmod u+rw "$dst"/*
|
||||||
|
'';
|
||||||
|
in
|
||||||
|
{
|
||||||
|
inherit
|
||||||
|
package
|
||||||
|
pluginVersion
|
||||||
|
pluginGuid
|
||||||
|
mkConfigureScript
|
||||||
|
mkInstallScript
|
||||||
|
;
|
||||||
|
}
|
||||||
@@ -26,6 +26,14 @@
|
|||||||
|
|
||||||
services.caddy.virtualHosts."jellyfin.${service_configs.https.domain}".extraConfig = ''
|
services.caddy.virtualHosts."jellyfin.${service_configs.https.domain}".extraConfig = ''
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.jellyfin.port} {
|
reverse_proxy :${builtins.toString service_configs.ports.private.jellyfin.port} {
|
||||||
|
# Disable response buffering for streaming. Caddy's default partial
|
||||||
|
# buffering delays fMP4-HLS segments and direct-play responses where
|
||||||
|
# Content-Length is known (so auto-flush doesn't trigger).
|
||||||
|
flush_interval -1
|
||||||
|
transport http {
|
||||||
|
# Localhost: compression wastes CPU re-encoding already-compressed media.
|
||||||
|
compression off
|
||||||
|
}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
|||||||
@@ -9,16 +9,23 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.services.llama-cpp;
|
cfg = config.services.llama-cpp;
|
||||||
modelUrl = "https://huggingface.co/bartowski/google_gemma-4-E2B-it-GGUF/resolve/main/google_gemma-4-E2B-it-Q4_K_M.gguf";
|
modelUrl = "https://huggingface.co/bartowski/google_gemma-4-E2B-it-GGUF/resolve/main/google_gemma-4-E2B-it-IQ2_M.gguf";
|
||||||
modelAlias = lib.removeSuffix ".gguf" (baseNameOf modelUrl);
|
modelAlias = lib.removeSuffix ".gguf" (baseNameOf modelUrl);
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
imports = [
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "llm";
|
||||||
|
port = service_configs.ports.private.llama_cpp.port;
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
services.llama-cpp = {
|
services.llama-cpp = {
|
||||||
enable = true;
|
enable = true;
|
||||||
model = toString (
|
model = toString (
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = modelUrl;
|
url = modelUrl;
|
||||||
sha256 = "5efe645db4e1909c7a1f4a9608df18e6c14383f5e86777fc49f769f9ba7d5fdf";
|
sha256 = "17e869ac54d0e59faa884d5319fc55ad84cd866f50f0b3073fbb25accc875a23";
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
port = service_configs.ports.private.llama_cpp.port;
|
port = service_configs.ports.private.llama_cpp.port;
|
||||||
@@ -26,8 +33,6 @@ in
|
|||||||
package = lib.optimizePackage (
|
package = lib.optimizePackage (
|
||||||
inputs.llamacpp.packages.${pkgs.system}.vulkan.overrideAttrs (old: {
|
inputs.llamacpp.packages.${pkgs.system}.vulkan.overrideAttrs (old: {
|
||||||
patches = (old.patches or [ ]) ++ [
|
patches = (old.patches or [ ]) ++ [
|
||||||
../patches/llamacpp/0003-gemma4-tokenizer-fix.patch
|
|
||||||
../patches/llamacpp/0004-gemma4-graph-fix.patch
|
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -51,17 +56,40 @@ in
|
|||||||
"4096"
|
"4096"
|
||||||
"-ub"
|
"-ub"
|
||||||
"4096"
|
"4096"
|
||||||
|
"--parallel"
|
||||||
|
"2"
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
# have to do this in order to get vulkan to work
|
# have to do this in order to get vulkan to work
|
||||||
systemd.services.llama-cpp.serviceConfig.DynamicUser = lib.mkForce false;
|
systemd.services.llama-cpp.serviceConfig.DynamicUser = lib.mkForce false;
|
||||||
|
|
||||||
|
# ANV driver's turbo3 shader compilation exceeds the default 8 MB thread stack.
|
||||||
|
systemd.services.llama-cpp.serviceConfig.LimitSTACK = lib.mkForce "67108864"; # 64 MB soft+hard
|
||||||
|
|
||||||
# llama-server tries to create ~/.cache; ProtectSystem=strict + impermanent
|
# llama-server tries to create ~/.cache; ProtectSystem=strict + impermanent
|
||||||
# root make /root read-only. Give it a writable cache dir and point HOME there.
|
# root make /root read-only. Give it a writable cache dir and point HOME there.
|
||||||
systemd.services.llama-cpp.serviceConfig.CacheDirectory = "llama-cpp";
|
systemd.services.llama-cpp.serviceConfig.CacheDirectory = "llama-cpp";
|
||||||
systemd.services.llama-cpp.environment.HOME = "/var/cache/llama-cpp";
|
systemd.services.llama-cpp.environment.HOME = "/var/cache/llama-cpp";
|
||||||
|
|
||||||
|
# turbo3 KV cache quantization runs a 14-barrier WHT butterfly per 128-element
|
||||||
|
# workgroup in SET_ROWS. With 4 concurrent slots and batch=4096, the combined
|
||||||
|
# GPU dispatch can exceed the default i915 CCS engine preempt timeout (7.5s),
|
||||||
|
# causing GPU HANG -> ErrorDeviceLost. Increase compute engine timeouts.
|
||||||
|
# Note: batch<4096 is not viable -- GDN chunked mode needs a larger compute
|
||||||
|
# buffer at smaller batch sizes, exceeding the A380's 6 GB VRAM.
|
||||||
|
# '+' prefix runs as root regardless of service User=.
|
||||||
|
systemd.services.llama-cpp.serviceConfig.ExecStartPre = [
|
||||||
|
"+${pkgs.writeShellScript "set-gpu-compute-timeout" ''
|
||||||
|
for f in /sys/class/drm/card*/engine/ccs*/preempt_timeout_ms; do
|
||||||
|
[ -w "$f" ] && echo 30000 > "$f"
|
||||||
|
done
|
||||||
|
for f in /sys/class/drm/card*/engine/ccs*/heartbeat_interval_ms; do
|
||||||
|
[ -w "$f" ] && echo 10000 > "$f"
|
||||||
|
done
|
||||||
|
''}"
|
||||||
|
];
|
||||||
|
|
||||||
# upstream module hardcodes --log-disable; override ExecStart to keep logs
|
# upstream module hardcodes --log-disable; override ExecStart to keep logs
|
||||||
# so we can see prompt processing progress via journalctl
|
# so we can see prompt processing progress via journalctl
|
||||||
systemd.services.llama-cpp.serviceConfig.ExecStart = lib.mkForce (
|
systemd.services.llama-cpp.serviceConfig.ExecStart = lib.mkForce (
|
||||||
@@ -72,10 +100,4 @@ in
|
|||||||
+ " ${utils.escapeSystemdExecArgs cfg.extraFlags}"
|
+ " ${utils.escapeSystemdExecArgs cfg.extraFlags}"
|
||||||
);
|
);
|
||||||
|
|
||||||
# Auth handled by llama-cpp --api-key-file (Bearer token).
|
|
||||||
# No caddy_auth — the API key is the auth layer, and caddy_auth's basic
|
|
||||||
# auth would block Bearer-only clients like oh-my-pi.
|
|
||||||
services.caddy.virtualHosts."llm.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${toString config.services.llama-cpp.port}
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
(lib.serviceFilePerms "continuwuity" [
|
(lib.serviceFilePerms "continuwuity" [
|
||||||
"Z /var/lib/private/continuwuity 0770 ${config.services.matrix-continuwuity.user} ${config.services.matrix-continuwuity.group}"
|
"Z /var/lib/private/continuwuity 0770 ${config.services.matrix-continuwuity.user} ${config.services.matrix-continuwuity.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
domain = service_configs.matrix.domain;
|
||||||
|
port = service_configs.ports.private.matrix.port;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.matrix-continuwuity = {
|
services.matrix-continuwuity = {
|
||||||
@@ -53,10 +57,6 @@
|
|||||||
respond /.well-known/matrix/client `{"m.server":{"base_url":"https://${service_configs.matrix.domain}"},"m.homeserver":{"base_url":"https://${service_configs.matrix.domain}"},"org.matrix.msc3575.proxy":{"base_url":"https://${config.services.matrix-continuwuity.settings.global.server_name}"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://${service_configs.livekit.domain}"}]}`
|
respond /.well-known/matrix/client `{"m.server":{"base_url":"https://${service_configs.matrix.domain}"},"m.homeserver":{"base_url":"https://${service_configs.matrix.domain}"},"org.matrix.msc3575.proxy":{"base_url":"https://${config.services.matrix-continuwuity.settings.global.server_name}"},"org.matrix.msc4143.rtc_foci":[{"type":"livekit","livekit_service_url":"https://${service_configs.livekit.domain}"}]}`
|
||||||
'';
|
'';
|
||||||
|
|
||||||
services.caddy.virtualHosts."${service_configs.matrix.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.matrix.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
# Exact duplicate for federation port
|
# Exact duplicate for federation port
|
||||||
services.caddy.virtualHosts."${service_configs.matrix.domain}:${builtins.toString service_configs.ports.public.matrix_federation.port}".extraConfig =
|
services.caddy.virtualHosts."${service_configs.matrix.domain}:${builtins.toString service_configs.ports.public.matrix_federation.port}".extraConfig =
|
||||||
config.services.caddy.virtualHosts."${service_configs.matrix.domain}".extraConfig;
|
config.services.caddy.virtualHosts."${service_configs.matrix.domain}".extraConfig;
|
||||||
|
|||||||
@@ -37,15 +37,21 @@
|
|||||||
|
|
||||||
servers.${service_configs.minecraft.server_name} = {
|
servers.${service_configs.minecraft.server_name} = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs.fabricServers.fabric-1_21_11;
|
package = pkgs.fabricServers.fabric-26_1_2.override { jre_headless = pkgs.openjdk25_headless; };
|
||||||
|
|
||||||
jvmOpts = lib.concatStringsSep " " [
|
jvmOpts = lib.concatStringsSep " " [
|
||||||
# Memory
|
# Memory
|
||||||
"-Xmx${builtins.toString service_configs.minecraft.memory.heap_size_m}M"
|
"-Xmx${builtins.toString service_configs.minecraft.memory.heap_size_m}M"
|
||||||
"-Xms${builtins.toString service_configs.minecraft.memory.heap_size_m}M"
|
"-Xms${builtins.toString service_configs.minecraft.memory.heap_size_m}M"
|
||||||
|
|
||||||
# GC
|
# GC
|
||||||
"-XX:+UseZGC"
|
"-XX:+UseZGC"
|
||||||
"-XX:+ZGenerational"
|
"-XX:+ZGenerational"
|
||||||
|
|
||||||
|
# added in new minecraft version
|
||||||
|
"-XX:+UseCompactObjectHeaders"
|
||||||
|
"-XX:+UseStringDeduplication"
|
||||||
|
|
||||||
# Base JVM optimizations (brucethemoose/Minecraft-Performance-Flags-Benchmarks)
|
# Base JVM optimizations (brucethemoose/Minecraft-Performance-Flags-Benchmarks)
|
||||||
"-XX:+UnlockExperimentalVMOptions"
|
"-XX:+UnlockExperimentalVMOptions"
|
||||||
"-XX:+UnlockDiagnosticVMOptions"
|
"-XX:+UnlockDiagnosticVMOptions"
|
||||||
@@ -67,6 +73,7 @@
|
|||||||
"-XX:NonProfiledCodeHeapSize=194M"
|
"-XX:NonProfiledCodeHeapSize=194M"
|
||||||
"-XX:NmethodSweepActivity=1"
|
"-XX:NmethodSweepActivity=1"
|
||||||
"-XX:+UseVectorCmov"
|
"-XX:+UseVectorCmov"
|
||||||
|
|
||||||
# Large pages (requires vm.nr_hugepages sysctl)
|
# Large pages (requires vm.nr_hugepages sysctl)
|
||||||
"-XX:+UseLargePages"
|
"-XX:+UseLargePages"
|
||||||
"-XX:LargePageSizeInBytes=${builtins.toString service_configs.minecraft.memory.large_page_size_m}M"
|
"-XX:LargePageSizeInBytes=${builtins.toString service_configs.minecraft.memory.large_page_size_m}M"
|
||||||
@@ -92,71 +99,68 @@
|
|||||||
with pkgs;
|
with pkgs;
|
||||||
builtins.attrValues {
|
builtins.attrValues {
|
||||||
FabricApi = fetchurl {
|
FabricApi = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/i5tSkVBH/fabric-api-0.141.3%2B1.21.11.jar";
|
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/fm7UYECV/fabric-api-0.145.4%2B26.1.2.jar";
|
||||||
sha512 = "c20c017e23d6d2774690d0dd774cec84c16bfac5461da2d9345a1cd95eee495b1954333c421e3d1c66186284d24a433f6b0cced8021f62e0bfa617d2384d0471";
|
sha512 = "ffd5ef62a745f76cd2e5481252cb7bc67006c809b4f436827d05ea22c01d19279e94a3b24df3d57e127af1cd08440b5de6a92a4ea8f39b2dcbbe1681275564c3";
|
||||||
};
|
};
|
||||||
|
|
||||||
FerriteCore = fetchurl {
|
# No 26.1.2 version available
|
||||||
url = "https://cdn.modrinth.com/data/uXXizFIs/versions/Ii0gP3D8/ferritecore-8.2.0-fabric.jar";
|
# FerriteCore = fetchurl {
|
||||||
sha512 = "3210926a82eb32efd9bcebabe2f6c053daf5c4337eebc6d5bacba96d283510afbde646e7e195751de795ec70a2ea44fef77cb54bf22c8e57bb832d6217418869";
|
# url = "https://cdn.modrinth.com/data/uXXizFIs/versions/d5ddUdiB/ferritecore-9.0.0-fabric.jar";
|
||||||
};
|
# sha512 = "d81fa97e11784c19d42f89c2f433831d007603dd7193cee45fa177e4a6a9c52b384b198586e04a0f7f63cd996fed713322578bde9a8db57e1188854ae5cbe584";
|
||||||
|
# };
|
||||||
|
|
||||||
Lithium = fetchurl {
|
Lithium = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/gvQqBUqZ/versions/Ow7wA0kG/lithium-fabric-0.21.4%2Bmc1.21.11.jar";
|
url = "https://cdn.modrinth.com/data/gvQqBUqZ/versions/v2xoRvRP/lithium-fabric-0.24.1%2Bmc26.1.2.jar";
|
||||||
sha512 = "f14a5c3d2fad786347ca25083f902139694f618b7c103947f2fd067a7c5ee88a63e1ef8926f7d693ea79ed7d00f57317bae77ef9c2d630bf5ed01ac97a752b94";
|
sha512 = "8711bc8c6f39be4c8511becb7a68e573ced56777bd691639f2fc62299b35bb4ccd2efe4a39bd9c308084b523be86a5f5c4bf921ab85f7a22bf075d8ea2359621";
|
||||||
};
|
};
|
||||||
|
|
||||||
NoChatReports = fetchurl {
|
NoChatReports = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/qQyHxfxd/versions/rhykGstm/NoChatReports-FABRIC-1.21.11-v2.18.0.jar";
|
url = "https://cdn.modrinth.com/data/qQyHxfxd/versions/2yrLNE3S/NoChatReports-FABRIC-26.1-v2.19.0.jar";
|
||||||
sha512 = "d2c35cc8d624616f441665aff67c0e366e4101dba243bad25ed3518170942c1a3c1a477b28805cd1a36c44513693b1c55e76bea627d3fced13927a3d67022ccc";
|
sha512 = "94d58a1a4cde4e3b1750bdf724e65c5f4ff3436c2532f36a465d497d26bf59f5ac996cddbff8ecdfed770c319aa2f2dcc9c7b2d19a35651c2a7735c5b2124dad";
|
||||||
};
|
};
|
||||||
|
|
||||||
squaremap = fetchurl {
|
squaremap = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/PFb7ZqK6/versions/BW8lMXBi/squaremap-fabric-mc1.21.11-1.3.12.jar";
|
url = "https://cdn.modrinth.com/data/PFb7ZqK6/versions/UBN6MFvH/squaremap-fabric-mc26.1.2-1.3.13.jar";
|
||||||
sha512 = "f62eb791a3f5812eb174565d318f2e6925353f846ef8ac56b4e595f481494e0c281f26b9e9fcfdefa855093c96b735b12f67ee17c07c2477aa7a3439238670d9";
|
sha512 = "97bc130184b5d0ddc4ff98a15acef6203459d982e0e2afbd49a2976d546c55a86ef22b841378b51dd782be9b2cfbe4cfa197717f2b7f6800fd8b4ff4df6e564f";
|
||||||
};
|
};
|
||||||
|
|
||||||
scalablelux = fetchurl {
|
scalablelux = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/Ps1zyz6x/versions/PV9KcrYQ/ScalableLux-0.1.6%2Bfabric.c25518a-all.jar";
|
url = "https://cdn.modrinth.com/data/Ps1zyz6x/versions/gYbHVCz8/ScalableLux-0.2.0%2Bfabric.2b63825-all.jar";
|
||||||
sha512 = "729515c1e75cf8d9cd704f12b3487ddb9664cf9928e7b85b12289c8fbbc7ed82d0211e1851375cbd5b385820b4fedbc3f617038fff5e30b302047b0937042ae7";
|
sha512 = "48565a4d8a1cbd623f0044086d971f2c0cf1c40e1d0b6636a61d41512f4c1c1ddff35879d9dba24b088a670ee254e2d5842d13a30b6d76df23706fa94ea4a58b";
|
||||||
};
|
};
|
||||||
|
|
||||||
c2me = fetchurl {
|
c2me = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/VSNURh3q/versions/QdLiMUjx/c2me-fabric-mc1.21.11-0.3.7%2Balpha.0.7.jar";
|
url = "https://cdn.modrinth.com/data/VSNURh3q/versions/yrNQQ1AQ/c2me-fabric-mc26.1.2-0.3.7%2Balpha.0.65.jar";
|
||||||
sha512 = "f9543febe2d649a82acd6d5b66189b6a3d820cf24aa503ba493fdb3bbd4e52e30912c4c763fe50006f9a46947ae8cd737d420838c61b93429542573ed67f958e";
|
sha512 = "6666ebaa3bfa403e386776590fc845b7c306107d37ebc7b1be3b057893fbf9f933abb2314c171d7fe19c177cf8823cb47fdc32040d34a9704f5ab656dd5d93f8";
|
||||||
};
|
};
|
||||||
|
|
||||||
krypton = fetchurl {
|
# No 26.1 version available
|
||||||
url = "https://cdn.modrinth.com/data/fQEb0iXm/versions/O9LmWYR7/krypton-0.2.10.jar";
|
# krypton = fetchurl {
|
||||||
sha512 = "4dcd7228d1890ddfc78c99ff284b45f9cf40aae77ef6359308e26d06fa0d938365255696af4cc12d524c46c4886cdcd19268c165a2bf0a2835202fe857da5cab";
|
# url = "https://cdn.modrinth.com/data/fQEb0iXm/versions/O9LmWYR7/krypton-0.2.10.jar";
|
||||||
};
|
# sha512 = "4dcd7228d1890ddfc78c99ff284b45f9cf40aae77ef6359308e26d06fa0d938365255696af4cc12d524c46c4886cdcd19268c165a2bf0a2835202fe857da5cab";
|
||||||
|
# };
|
||||||
|
|
||||||
better-fabric-console = fetchurl {
|
# No 26.1.2 version available
|
||||||
url = "https://cdn.modrinth.com/data/Y8o1j1Sf/versions/6aIKl5wy/better-fabric-console-mc1.21.11-1.2.9.jar";
|
# disconnect-packet-fix = fetchurl {
|
||||||
sha512 = "427247dafd99df202ee10b4bf60ffcbbecbabfadb01c167097ffb5b85670edb811f4d061c2551be816295cbbc6b8ec5ec464c14a6ff41912ef1f6c57b038d320";
|
# url = "https://cdn.modrinth.com/data/rd9rKuJT/versions/x9gVeaTU/disconnect-packet-fix-fabric-2.1.0.jar";
|
||||||
};
|
# sha512 = "bf84d02bdcd737706df123e452dd31ef535580fa4ced6af1e4ceea022fef94e4764775253e970b8caa1292e2fa00eb470557f70b290fafdb444479fa801b07a1";
|
||||||
|
# };
|
||||||
disconnect-packet-fix = fetchurl {
|
|
||||||
url = "https://cdn.modrinth.com/data/rd9rKuJT/versions/Gv74xveQ/disconnect-packet-fix-fabric-2.0.0.jar";
|
|
||||||
sha512 = "1fd6f09a41ce36284e1a8e9def53f3f6834d7201e69e54e24933be56445ba569fbc26278f28300d36926ba92db6f4f9c0ae245d23576aaa790530345587316db";
|
|
||||||
};
|
|
||||||
|
|
||||||
packet-fixer = fetchurl {
|
packet-fixer = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/c7m1mi73/versions/CUh1DWeO/packetfixer-fabric-3.3.4-1.21.11.jar";
|
url = "https://cdn.modrinth.com/data/c7m1mi73/versions/M8PqPQr4/packetfixer-fabric-3.3.4-26.1.2.jar";
|
||||||
sha512 = "33331b16cb40c5e6fbaade3cacc26f3a0e8fa5805a7186f94d7366a0e14dbeee9de2d2e8c76fa71f5e9dd24eb1c261667c35447e32570ea965ca0f154fdfba0a";
|
sha512 = "698020edba2a1fd80bb282bfd4832a00d6447b08eaafbc2e16a8f3bf89e187fc9a622c92dfe94ae140dd485fc0220a86890f12158ec08054e473fef8337829bc";
|
||||||
};
|
};
|
||||||
|
|
||||||
# fork of Modernfix for 1.21.11 (upstream will support 26.1)
|
# mVUS fork: upstream ModernFix no longer ships Fabric builds
|
||||||
modernfix = fetchurl {
|
modernfix = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/TjSm1wrD/versions/JwSO8JCN/modernfix-5.25.2-build.4.jar";
|
url = "https://cdn.modrinth.com/data/TjSm1wrD/versions/dqQ7mabN/modernfix-5.26.2-build.1.jar";
|
||||||
sha512 = "0d65c05ac0475408c58ef54215714e6301113101bf98bfe4bb2ba949fbfddd98225ac4e2093a5f9206a9e01ba80a931424b237bdfa3b6e178c741ca6f7f8c6a3";
|
sha512 = "fbef93c2dabf7bcd0ccd670226dfc4958f7ebe5d8c2b1158e88a65e6954a40f595efd58401d2a3dbb224660dca5952199cf64df29100e7bd39b1b1941290b57b";
|
||||||
};
|
};
|
||||||
|
|
||||||
debugify = fetchurl {
|
debugify = fetchurl {
|
||||||
url = "https://cdn.modrinth.com/data/QwxR6Gcd/versions/8Q49lnaU/debugify-1.21.11%2B1.0.jar";
|
url = "https://cdn.modrinth.com/data/QwxR6Gcd/versions/mfTTfiKn/debugify-26.1.2%2B1.0.jar";
|
||||||
sha512 = "04d82dd33f44ced37045f1f9a54ad4eacd70861ff74a8800f2d2df358579e6cb0ea86a34b0086b3e87026b1a0691dd6594b4fdc49f89106466eea840518beb03";
|
sha512 = "63db82f2163b9f7fc27ebea999ffcd7a961054435b3ed7d8bf32d905b5f60ce81715916b7fd4e9509dd23703d5492059f3ce7e5f176402f8ed4f985a415553f4";
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,12 +33,6 @@
|
|||||||
wants = [ "monero.service" ];
|
wants = [ "monero.service" ];
|
||||||
};
|
};
|
||||||
|
|
||||||
# Stop p2pool on UPS battery to conserve power
|
|
||||||
services.apcupsd.hooks = lib.mkIf config.services.apcupsd.enable {
|
|
||||||
onbattery = "systemctl stop p2pool";
|
|
||||||
offbattery = "systemctl start p2pool";
|
|
||||||
};
|
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [
|
networking.firewall.allowedTCPPorts = [
|
||||||
service_configs.ports.public.p2pool_p2p.port
|
service_configs.ports.public.p2pool_p2p.port
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ lib.mkIf config.services.xmrig.enable {
|
|||||||
environment = {
|
environment = {
|
||||||
POLL_INTERVAL = "3";
|
POLL_INTERVAL = "3";
|
||||||
GRACE_PERIOD = "15";
|
GRACE_PERIOD = "15";
|
||||||
# This server's background services (qbittorrent, monero, bazarr, etc.)
|
# Background services (qbittorrent, bitmagnet, postgresql, etc.) produce
|
||||||
# produce 5-14% non-nice CPU during normal operation. Thresholds must
|
# 15-25% non-nice CPU during normal operation. The stop threshold must
|
||||||
# sit above that noise floor.
|
# sit above transient spikes; the resume threshold must be below the
|
||||||
|
# steady-state floor to avoid restarting xmrig while services are active.
|
||||||
CPU_STOP_THRESHOLD = "40";
|
CPU_STOP_THRESHOLD = "40";
|
||||||
CPU_RESUME_THRESHOLD = "30";
|
CPU_RESUME_THRESHOLD = "10";
|
||||||
STARTUP_COOLDOWN = "10";
|
STARTUP_COOLDOWN = "10";
|
||||||
STATE_DIR = "/var/lib/xmrig-auto-pause";
|
STATE_DIR = "/var/lib/xmrig-auto-pause";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ in
|
|||||||
{
|
{
|
||||||
services.xmrig = {
|
services.xmrig = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = pkgs.xmrig;
|
package = lib.optimizePackage pkgs.xmrig;
|
||||||
|
|
||||||
settings = {
|
settings = {
|
||||||
autosave = true;
|
autosave = true;
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
(lib.serviceFilePerms "ntfy-sh" [
|
(lib.serviceFilePerms "ntfy-sh" [
|
||||||
"Z /var/lib/private/ntfy-sh 0700 ${config.services.ntfy-sh.user} ${config.services.ntfy-sh.group}"
|
"Z /var/lib/private/ntfy-sh 0700 ${config.services.ntfy-sh.user} ${config.services.ntfy-sh.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
domain = service_configs.ntfy.domain;
|
||||||
|
port = service_configs.ports.private.ntfy.port;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.ntfy-sh = {
|
services.ntfy-sh = {
|
||||||
@@ -27,8 +31,4 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."${service_configs.ntfy.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${builtins.toString service_configs.ports.private.ntfy.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,18 @@ in
|
|||||||
(lib.serviceFilePerms "qbittorrent" [
|
(lib.serviceFilePerms "qbittorrent" [
|
||||||
# 0770: group (media) needs write to delete files during upgrades —
|
# 0770: group (media) needs write to delete files during upgrades —
|
||||||
# Radarr/Sonarr must unlink the old file before placing the new one.
|
# Radarr/Sonarr must unlink the old file before placing the new one.
|
||||||
"Z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.SavePath} 0770 ${config.services.qbittorrent.user} ${service_configs.media_group}"
|
# Non-recursive (z not Z): UMask=0007 ensures new files get correct perms.
|
||||||
|
# A recursive Z rule would walk millions of files on the HDD pool at every boot.
|
||||||
|
"z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.SavePath} 0770 ${config.services.qbittorrent.user} ${service_configs.media_group}"
|
||||||
"z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.TempPath} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}"
|
"z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.TempPath} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}"
|
||||||
"Z ${config.services.qbittorrent.profileDir} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}"
|
"Z ${config.services.qbittorrent.profileDir} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "torrent";
|
||||||
|
port = service_configs.ports.private.torrent.port;
|
||||||
|
auth = true;
|
||||||
|
vpn = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.qbittorrent = {
|
services.qbittorrent = {
|
||||||
@@ -156,10 +164,34 @@ in
|
|||||||
_: path: "d ${path} 0770 ${config.services.qbittorrent.user} ${service_configs.media_group} -"
|
_: path: "d ${path} 0770 ${config.services.qbittorrent.user} ${service_configs.media_group} -"
|
||||||
) service_configs.torrent.categories;
|
) service_configs.torrent.categories;
|
||||||
|
|
||||||
services.caddy.virtualHosts."torrent.${service_configs.https.domain}".extraConfig = ''
|
# Periodically checkpoint qBittorrent's SQLite WAL (Write-Ahead Log).
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
# qBittorrent holds a read transaction open for its entire lifetime,
|
||||||
reverse_proxy ${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString config.services.qbittorrent.webuiPort}
|
# preventing SQLite's auto-checkpoint from running. The WAL grows
|
||||||
'';
|
# unbounded (observed: 405 MB) and must be replayed on next startup,
|
||||||
|
# causing 10+ minute "internal preparations" hangs.
|
||||||
|
# A second sqlite3 connection can checkpoint concurrently and safely.
|
||||||
|
# See: https://github.com/qbittorrent/qBittorrent/issues/20433
|
||||||
|
systemd.services.qbittorrent-wal-checkpoint = {
|
||||||
|
description = "Checkpoint qBittorrent SQLite WAL";
|
||||||
|
after = [ "qbittorrent.service" ];
|
||||||
|
requires = [ "qbittorrent.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${pkgs.sqlite}/bin/sqlite3 ${config.services.qbittorrent.profileDir}/qBittorrent/data/torrents.db 'PRAGMA wal_checkpoint(TRUNCATE);'";
|
||||||
|
User = config.services.qbittorrent.user;
|
||||||
|
Group = config.services.qbittorrent.group;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.qbittorrent-wal-checkpoint = {
|
||||||
|
description = "Periodically checkpoint qBittorrent SQLite WAL";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnUnitActiveSec = "4h";
|
||||||
|
OnBootSec = "30min";
|
||||||
|
RandomizedDelaySec = "10min";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
users.users.${config.services.qbittorrent.user}.extraGroups = [
|
users.users.${config.services.qbittorrent.user}.extraGroups = [
|
||||||
service_configs.media_group
|
service_configs.media_group
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
"Z ${service_configs.slskd.downloads} 0750 ${config.services.slskd.user} music"
|
"Z ${service_configs.slskd.downloads} 0750 ${config.services.slskd.user} music"
|
||||||
"Z ${service_configs.slskd.incomplete} 0750 ${config.services.slskd.user} music"
|
"Z ${service_configs.slskd.incomplete} 0750 ${config.services.slskd.user} music"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "soulseek";
|
||||||
|
port = service_configs.ports.private.soulseek_web.port;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
users.groups."music" = { };
|
users.groups."music" = { };
|
||||||
@@ -58,11 +62,6 @@
|
|||||||
users.users.${config.services.jellyfin.user}.extraGroups = [ "music" ];
|
users.users.${config.services.jellyfin.user}.extraGroups = [ "music" ];
|
||||||
users.users.${username}.extraGroups = [ "music" ];
|
users.users.${username}.extraGroups = [ "music" ];
|
||||||
|
|
||||||
# doesn't work with auth????
|
|
||||||
services.caddy.virtualHosts."soulseek.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
reverse_proxy :${builtins.toString config.services.slskd.settings.web.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
networking.firewall.allowedTCPPorts = [
|
networking.firewall.allowedTCPPorts = [
|
||||||
service_configs.ports.public.soulseek_listen.port
|
service_configs.ports.public.soulseek_listen.port
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
"Z ${service_configs.syncthing.signalBackupDir} 0750 ${config.services.syncthing.user} ${config.services.syncthing.group}"
|
"Z ${service_configs.syncthing.signalBackupDir} 0750 ${config.services.syncthing.user} ${config.services.syncthing.group}"
|
||||||
"Z ${service_configs.syncthing.grayjayBackupDir} 0750 ${config.services.syncthing.user} ${config.services.syncthing.group}"
|
"Z ${service_configs.syncthing.grayjayBackupDir} 0750 ${config.services.syncthing.user} ${config.services.syncthing.group}"
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "syncthing";
|
||||||
|
port = service_configs.ports.private.syncthing_gui.port;
|
||||||
|
auth = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.syncthing = {
|
services.syncthing = {
|
||||||
@@ -49,9 +54,4 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."syncthing.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${toString service_configs.ports.private.syncthing_gui.port}
|
|
||||||
'';
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
(lib.serviceMountWithZpool "trilium-server" service_configs.zpool_ssds [
|
(lib.serviceMountWithZpool "trilium-server" service_configs.zpool_ssds [
|
||||||
(service_configs.services_dir + "/trilium")
|
(service_configs.services_dir + "/trilium")
|
||||||
])
|
])
|
||||||
|
(lib.mkCaddyReverseProxy {
|
||||||
|
subdomain = "notes";
|
||||||
|
port = service_configs.ports.private.trilium.port;
|
||||||
|
auth = true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
services.trilium-server = {
|
services.trilium-server = {
|
||||||
@@ -19,8 +24,4 @@
|
|||||||
dataDir = service_configs.trilium.dataDir;
|
dataDir = service_configs.trilium.dataDir;
|
||||||
};
|
};
|
||||||
|
|
||||||
services.caddy.virtualHosts."notes.${service_configs.https.domain}".extraConfig = ''
|
|
||||||
import ${config.age.secrets.caddy_auth.path}
|
|
||||||
reverse_proxy :${toString service_configs.ports.private.trilium.port}
|
|
||||||
'';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
server.wait_for_unit("jellyfin.service")
|
server.wait_for_unit("jellyfin.service")
|
||||||
server.wait_for_unit("fail2ban.service")
|
server.wait_for_unit("fail2ban.service")
|
||||||
server.wait_for_open_port(8096)
|
server.wait_for_open_port(8096)
|
||||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=120)
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# Wait for Jellyfin to create real log files and reload fail2ban
|
# Wait for Jellyfin to create real log files and reload fail2ban
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; };
|
jfLib = import ./jellyfin-test-lib.nix { inherit pkgs lib; };
|
||||||
|
webhookPlugin = import ../services/jellyfin/jellyfin-webhook-plugin.nix { inherit pkgs lib; };
|
||||||
|
configureWebhook = webhookPlugin.mkConfigureScript {
|
||||||
|
jellyfinUrl = "http://localhost:8096";
|
||||||
|
webhooks = [
|
||||||
|
{
|
||||||
|
name = "qBittorrent Monitor";
|
||||||
|
uri = "http://127.0.0.1:9898/";
|
||||||
|
notificationTypes = [
|
||||||
|
"PlaybackStart"
|
||||||
|
"PlaybackProgress"
|
||||||
|
"PlaybackStop"
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
in
|
in
|
||||||
pkgs.testers.runNixOSTest {
|
pkgs.testers.runNixOSTest {
|
||||||
name = "jellyfin-qbittorrent-monitor";
|
name = "jellyfin-qbittorrent-monitor";
|
||||||
@@ -69,11 +84,30 @@ pkgs.testers.runNixOSTest {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
# Create directories for qBittorrent
|
# Create directories for qBittorrent.
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent"
|
"d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent"
|
||||||
"d /var/lib/qbittorrent/incomplete 0755 qbittorrent qbittorrent"
|
"d /var/lib/qbittorrent/incomplete 0755 qbittorrent qbittorrent"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
# Install the Jellyfin Webhook plugin before Jellyfin starts, mirroring
|
||||||
|
# the production module. Jellyfin rewrites meta.json at runtime so a
|
||||||
|
# read-only nix-store symlink would fail — we materialise a writable copy.
|
||||||
|
systemd.services."jellyfin-webhook-install" = {
|
||||||
|
description = "Install Jellyfin Webhook plugin files";
|
||||||
|
before = [ "jellyfin.service" ];
|
||||||
|
wantedBy = [ "jellyfin.service" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
User = "jellyfin";
|
||||||
|
Group = "jellyfin";
|
||||||
|
UMask = "0077";
|
||||||
|
ExecStart = webhookPlugin.mkInstallScript {
|
||||||
|
pluginsDir = "/var/lib/jellyfin/plugins";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Public test IP (RFC 5737 TEST-NET-3) so Jellyfin sees it as external
|
# Public test IP (RFC 5737 TEST-NET-3) so Jellyfin sees it as external
|
||||||
@@ -394,6 +428,97 @@ pkgs.testers.runNixOSTest {
|
|||||||
local_playback["PositionTicks"] = 50000000
|
local_playback["PositionTicks"] = 50000000
|
||||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing/Stopped' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||||
|
|
||||||
|
# === WEBHOOK TESTS ===
|
||||||
|
#
|
||||||
|
# Configure the Jellyfin Webhook plugin to target the monitor, then verify
|
||||||
|
# the real Jellyfin → plugin → monitor path reacts faster than any possible
|
||||||
|
# poll. CHECK_INTERVAL=30 rules out polling as the cause.
|
||||||
|
|
||||||
|
WEBHOOK_PORT = 9898
|
||||||
|
WEBHOOK_CREDS = "/tmp/webhook-creds"
|
||||||
|
|
||||||
|
# Start a webhook-enabled monitor with long poll interval.
|
||||||
|
server.succeed("systemctl stop monitor-test || true")
|
||||||
|
time.sleep(1)
|
||||||
|
server.succeed(f"""
|
||||||
|
systemd-run --unit=monitor-webhook \
|
||||||
|
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
||||||
|
--setenv=JELLYFIN_API_KEY={token} \
|
||||||
|
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
||||||
|
--setenv=CHECK_INTERVAL=30 \
|
||||||
|
--setenv=STREAMING_START_DELAY=1 \
|
||||||
|
--setenv=STREAMING_STOP_DELAY=1 \
|
||||||
|
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
|
||||||
|
--setenv=SERVICE_BUFFER=2000000 \
|
||||||
|
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
||||||
|
--setenv=MIN_TORRENT_SPEED=100 \
|
||||||
|
--setenv=WEBHOOK_PORT={WEBHOOK_PORT} \
|
||||||
|
--setenv=WEBHOOK_BIND=127.0.0.1 \
|
||||||
|
{python} {monitor}
|
||||||
|
""")
|
||||||
|
server.wait_until_succeeds(f"ss -ltn | grep -q ':{WEBHOOK_PORT}'", timeout=15)
|
||||||
|
time.sleep(2)
|
||||||
|
assert not is_throttled(), "Should start unthrottled"
|
||||||
|
|
||||||
|
# Drop the admin token where the configure script expects it (production uses agenix).
|
||||||
|
server.succeed(f"mkdir -p {WEBHOOK_CREDS} && echo '{token}' > {WEBHOOK_CREDS}/jellyfin-api-key")
|
||||||
|
server.succeed(
|
||||||
|
f"systemd-run --wait --unit=webhook-configure-test "
|
||||||
|
f"--setenv=CREDENTIALS_DIRECTORY={WEBHOOK_CREDS} "
|
||||||
|
f"${configureWebhook}"
|
||||||
|
)
|
||||||
|
|
||||||
|
with subtest("Real PlaybackStart event throttles via the plugin"):
|
||||||
|
playback_start = {
|
||||||
|
"ItemId": movie_id,
|
||||||
|
"MediaSourceId": media_source_id,
|
||||||
|
"PlaySessionId": "test-plugin-start",
|
||||||
|
"CanSeek": True,
|
||||||
|
"IsPaused": False,
|
||||||
|
}
|
||||||
|
start_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback_start)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||||
|
client.succeed(start_cmd)
|
||||||
|
server.wait_until_succeeds(
|
||||||
|
"curl -sf http://localhost:8080/api/v2/transfer/speedLimitsMode | grep -q '^1$'",
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
# Let STREAMING_STOP_DELAY (1s) elapse so the upcoming stop is not swallowed by hysteresis.
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
with subtest("Real PlaybackStop event unthrottles via the plugin"):
|
||||||
|
playback_stop = {
|
||||||
|
"ItemId": movie_id,
|
||||||
|
"MediaSourceId": media_source_id,
|
||||||
|
"PlaySessionId": "test-plugin-start",
|
||||||
|
"PositionTicks": 50000000,
|
||||||
|
}
|
||||||
|
stop_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(playback_stop)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||||
|
client.succeed(stop_cmd)
|
||||||
|
server.wait_until_succeeds(
|
||||||
|
"curl -sf http://localhost:8080/api/v2/transfer/speedLimitsMode | grep -q '^0$'",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Restore fast-polling monitor for the service-restart tests below.
|
||||||
|
server.succeed("systemctl stop monitor-webhook || true")
|
||||||
|
time.sleep(1)
|
||||||
|
server.succeed(f"""
|
||||||
|
systemd-run --unit=monitor-test \
|
||||||
|
--setenv=JELLYFIN_URL=http://localhost:8096 \
|
||||||
|
--setenv=JELLYFIN_API_KEY={token} \
|
||||||
|
--setenv=QBITTORRENT_URL=http://localhost:8080 \
|
||||||
|
--setenv=CHECK_INTERVAL=1 \
|
||||||
|
--setenv=STREAMING_START_DELAY=1 \
|
||||||
|
--setenv=STREAMING_STOP_DELAY=1 \
|
||||||
|
--setenv=TOTAL_BANDWIDTH_BUDGET=50000000 \
|
||||||
|
--setenv=SERVICE_BUFFER=2000000 \
|
||||||
|
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
||||||
|
--setenv=MIN_TORRENT_SPEED=100 \
|
||||||
|
{python} {monitor}
|
||||||
|
""")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
# === SERVICE RESTART TESTS ===
|
# === SERVICE RESTART TESTS ===
|
||||||
|
|
||||||
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def setup_jellyfin(machine, retry, auth_header, auth_payload, empty_payload):
|
|||||||
machine.wait_for_unit("jellyfin.service")
|
machine.wait_for_unit("jellyfin.service")
|
||||||
machine.wait_for_open_port(8096)
|
machine.wait_for_open_port(8096)
|
||||||
machine.wait_until_succeeds(
|
machine.wait_until_succeeds(
|
||||||
"curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60
|
"curl -sf http://localhost:8096/health | grep -q Healthy", timeout=120
|
||||||
)
|
)
|
||||||
|
|
||||||
machine.wait_until_succeeds(
|
machine.wait_until_succeeds(
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
{
|
|
||||||
pkgs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
|
||||||
mockGrafana = ./mock-grafana-server.py;
|
|
||||||
script = ../services/grafana/llama-cpp-annotations.py;
|
|
||||||
python = pkgs.python3;
|
|
||||||
|
|
||||||
mockLlamaProcess = ./mock-llama-server-proc.py;
|
|
||||||
in
|
|
||||||
pkgs.testers.runNixOSTest {
|
|
||||||
name = "llama-cpp-annotations";
|
|
||||||
|
|
||||||
nodes.machine =
|
|
||||||
{ pkgs, ... }:
|
|
||||||
{
|
|
||||||
environment.systemPackages = [
|
|
||||||
pkgs.python3
|
|
||||||
pkgs.curl
|
|
||||||
pkgs.procps
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
testScript = ''
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
GRAFANA_PORT = 13000
|
|
||||||
ANNOTS_FILE = "/tmp/annotations.json"
|
|
||||||
LLAMA_STATE = "/tmp/llama-state.txt"
|
|
||||||
STATE_FILE = "/tmp/llama-annot-state.json"
|
|
||||||
PYTHON = "${python}/bin/python3"
|
|
||||||
MOCK_GRAFANA = "${mockGrafana}"
|
|
||||||
MOCK_LLAMA = "${mockLlamaProcess}"
|
|
||||||
SCRIPT = "${script}"
|
|
||||||
|
|
||||||
def read_annotations():
|
|
||||||
out = machine.succeed(f"cat {ANNOTS_FILE} 2>/dev/null || echo '[]'")
|
|
||||||
return json.loads(out.strip())
|
|
||||||
|
|
||||||
def set_busy():
|
|
||||||
machine.succeed(f"echo busy > {LLAMA_STATE}")
|
|
||||||
|
|
||||||
def set_idle():
|
|
||||||
machine.succeed(f"echo idle > {LLAMA_STATE}")
|
|
||||||
|
|
||||||
start_all()
|
|
||||||
machine.wait_for_unit("multi-user.target")
|
|
||||||
|
|
||||||
with subtest("Start mock services"):
|
|
||||||
machine.succeed(f"echo '[]' > {ANNOTS_FILE}")
|
|
||||||
machine.succeed(
|
|
||||||
f"systemd-run --unit=mock-grafana {PYTHON} {MOCK_GRAFANA} {GRAFANA_PORT} {ANNOTS_FILE}"
|
|
||||||
)
|
|
||||||
machine.succeed(
|
|
||||||
f"systemd-run --unit=mock-llama {PYTHON} {MOCK_LLAMA} {LLAMA_STATE}"
|
|
||||||
)
|
|
||||||
machine.wait_until_succeeds(
|
|
||||||
f"curl -sf http://127.0.0.1:{GRAFANA_PORT}/api/annotations -X POST "
|
|
||||||
f"-H 'Content-Type: application/json' -d '{{\"text\":\"ping\",\"tags\":[]}}' | grep -q id",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
machine.wait_until_succeeds(
|
|
||||||
"pgrep -x llama-server",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
machine.succeed(f"echo '[]' > {ANNOTS_FILE}")
|
|
||||||
|
|
||||||
with subtest("Start annotation service"):
|
|
||||||
machine.succeed(
|
|
||||||
f"systemd-run --unit=llama-annot "
|
|
||||||
f"--setenv=GRAFANA_URL=http://127.0.0.1:{GRAFANA_PORT} "
|
|
||||||
f"--setenv=STATE_FILE={STATE_FILE} "
|
|
||||||
f"--setenv=POLL_INTERVAL=2 "
|
|
||||||
f"--setenv=CPU_THRESHOLD=10 "
|
|
||||||
f"{PYTHON} {SCRIPT}"
|
|
||||||
)
|
|
||||||
time.sleep(5)
|
|
||||||
|
|
||||||
with subtest("No annotations when idle"):
|
|
||||||
annots = read_annotations()
|
|
||||||
assert annots == [], f"Expected no annotations, got: {annots}"
|
|
||||||
|
|
||||||
with subtest("Annotation created when llama-server becomes busy"):
|
|
||||||
set_busy()
|
|
||||||
machine.wait_until_succeeds(
|
|
||||||
f"cat {ANNOTS_FILE} | {PYTHON} -c "
|
|
||||||
f"\"import sys,json; a=json.load(sys.stdin); exit(0 if a else 1)\"",
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
annots = read_annotations()
|
|
||||||
assert len(annots) == 1, f"Expected 1 annotation, got: {annots}"
|
|
||||||
assert "llama-cpp" in annots[0].get("tags", []), f"Missing tag: {annots[0]}"
|
|
||||||
assert "LLM request" in annots[0]["text"], f"Missing text: {annots[0]['text']}"
|
|
||||||
assert "timeEnd" not in annots[0], f"timeEnd should not be set: {annots[0]}"
|
|
||||||
|
|
||||||
with subtest("Annotation closed when llama-server becomes idle"):
|
|
||||||
set_idle()
|
|
||||||
machine.wait_until_succeeds(
|
|
||||||
f"cat {ANNOTS_FILE} | {PYTHON} -c "
|
|
||||||
f"\"import sys,json; a=json.load(sys.stdin); exit(0 if a and 'timeEnd' in a[0] else 1)\"",
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
annots = read_annotations()
|
|
||||||
assert len(annots) == 1, f"Expected 1, got: {annots}"
|
|
||||||
assert "timeEnd" in annots[0], f"timeEnd missing: {annots[0]}"
|
|
||||||
assert annots[0]["timeEnd"] > annots[0]["time"], "timeEnd should be after time"
|
|
||||||
assert "s)" in annots[0].get("text", ""), f"Duration missing: {annots[0]}"
|
|
||||||
|
|
||||||
with subtest("State survives restart"):
|
|
||||||
set_busy()
|
|
||||||
machine.wait_until_succeeds(
|
|
||||||
f"cat {ANNOTS_FILE} | {PYTHON} -c "
|
|
||||||
f"\"import sys,json; a=json.load(sys.stdin); exit(0 if len(a)==2 else 1)\"",
|
|
||||||
timeout=20,
|
|
||||||
)
|
|
||||||
machine.succeed("systemctl stop llama-annot || true")
|
|
||||||
time.sleep(1)
|
|
||||||
machine.succeed(
|
|
||||||
f"systemd-run --unit=llama-annot-2 "
|
|
||||||
f"--setenv=GRAFANA_URL=http://127.0.0.1:{GRAFANA_PORT} "
|
|
||||||
f"--setenv=STATE_FILE={STATE_FILE} "
|
|
||||||
f"--setenv=POLL_INTERVAL=2 "
|
|
||||||
f"--setenv=CPU_THRESHOLD=10 "
|
|
||||||
f"{PYTHON} {SCRIPT}"
|
|
||||||
)
|
|
||||||
time.sleep(6)
|
|
||||||
annots = read_annotations()
|
|
||||||
assert len(annots) == 2, f"Restart should not duplicate, got: {annots}"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Mock llama-server process for NixOS VM tests.
|
|
||||||
|
|
||||||
Sets /proc/self/comm to "llama-server" via prctl so that monitoring scripts
|
|
||||||
(llama-cpp-annotations, llama-cpp-xmrig-pause) can discover this process
|
|
||||||
the same way they discover the real one.
|
|
||||||
|
|
||||||
Usage: python3 mock-llama-server-proc.py <state-file>
|
|
||||||
|
|
||||||
The state file controls behavior:
|
|
||||||
"busy" -> burn CPU in a tight loop (simulates prompt processing / inference)
|
|
||||||
"idle" -> sleep (simulates waiting for requests)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ctypes
|
|
||||||
import ctypes.util
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
STATE_FILE = sys.argv[1]
|
|
||||||
|
|
||||||
# PR_SET_NAME = 15, sets /proc/self/comm
|
|
||||||
libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
|
|
||||||
libc.prctl(15, b"llama-server", 0, 0, 0)
|
|
||||||
|
|
||||||
with open(STATE_FILE, "w") as f:
|
|
||||||
f.write("idle")
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
with open(STATE_FILE) as f:
|
|
||||||
state = f.read().strip()
|
|
||||||
except Exception:
|
|
||||||
state = "idle"
|
|
||||||
|
|
||||||
if state == "busy":
|
|
||||||
end = time.monotonic() + 0.1
|
|
||||||
while time.monotonic() < end:
|
|
||||||
_ = sum(range(10000))
|
|
||||||
else:
|
|
||||||
time.sleep(0.5)
|
|
||||||
@@ -28,9 +28,6 @@ in
|
|||||||
# zfs scrub annotations test
|
# zfs scrub annotations test
|
||||||
zfsScrubAnnotationsTest = handleTest ./zfs-scrub-annotations.nix;
|
zfsScrubAnnotationsTest = handleTest ./zfs-scrub-annotations.nix;
|
||||||
|
|
||||||
# llama-cpp tests
|
|
||||||
llamaCppAnnotationsTest = handleTest ./llama-cpp-annotations.nix;
|
|
||||||
|
|
||||||
# xmrig auto-pause test
|
# xmrig auto-pause test
|
||||||
xmrigAutoPauseTest = handleTest ./xmrig-auto-pause.nix;
|
xmrigAutoPauseTest = handleTest ./xmrig-auto-pause.nix;
|
||||||
# ntfy alerts test
|
# ntfy alerts test
|
||||||
|
|||||||
Reference in New Issue
Block a user