Compare commits

..

26 Commits

Author SHA1 Message Date
9f949f13d1 minecraft: update mods + add modernfix + debugify 2026-02-28 02:30:19 -05:00
59080fe1b3 fmt 2026-02-28 02:25:38 -05:00
12fca8840d update 2026-02-28 01:57:42 -05:00
49f06fc26c arr-init: extract to standalone flake repo 2026-02-27 15:39:19 -05:00
2c0811cfe9 minecraft: make more responsive 2026-02-27 00:04:07 -05:00
9692fe5f08 update 2026-02-25 19:01:56 -05:00
c142b5d045 ntfy-alerts: suppress notifications for sanoid 2026-02-25 02:10:37 -05:00
16c84fdcb6 zfs: fix sanoid dataset name for jellyfin cache 2026-02-24 21:21:24 -05:00
196f06e41f flake: expose tests as checks output 2026-02-24 14:51:11 -05:00
8013435d99 ntfy-alerts: init 2026-02-24 14:44:00 -05:00
28e3090c72 matrix: update 2026-02-24 13:05:00 -05:00
a22c5b30fe update 2026-02-24 13:04:55 -05:00
c2908f594c matrix: update 2026-02-23 15:24:31 -05:00
9df3f3cae9 update 2026-02-23 15:13:47 -05:00
ea75dad5ba matrix: update 2026-02-21 22:47:33 -05:00
1e25d86d44 update 2026-02-21 22:19:36 -05:00
23475927a1 qbt: increase ConnectionSpeed 2026-02-20 15:27:56 -05:00
fe4040bf3b matrix: update 2026-02-20 15:25:44 -05:00
d91b651152 formating 2026-02-20 15:19:46 -05:00
0a3f93c98d qbt: fix permissions 2026-02-20 14:12:13 -05:00
304ad7f308 qbt: tweak 2026-02-20 11:05:53 -05:00
4fe33b9b32 update 2026-02-19 23:15:26 -05:00
0a0c14993d qbt: Coalesce Read Write 2026-02-19 23:14:27 -05:00
155ebbafcd qbittorrent: enable queueing and AutoTMM 2026-02-19 23:12:18 -05:00
2fed80cdb2 firewall: trust wg-br interface 2026-02-19 23:12:18 -05:00
318908d8ca arr-init: add module for API-based configuration 2026-02-19 23:12:16 -05:00
18 changed files with 523 additions and 986 deletions

View File

@@ -19,7 +19,7 @@
./modules/secureboot.nix ./modules/secureboot.nix
./modules/no-rgb.nix ./modules/no-rgb.nix
./modules/security.nix ./modules/security.nix
./modules/arr-init.nix ./modules/ntfy-alerts.nix
./services/postgresql.nix ./services/postgresql.nix
./services/jellyfin.nix ./services/jellyfin.nix
@@ -64,6 +64,7 @@
./services/syncthing.nix ./services/syncthing.nix
./services/ntfy.nix ./services/ntfy.nix
./services/ntfy-alerts.nix
]; ];
services.kmscon.enable = true; services.kmscon.enable = true;
@@ -132,6 +133,37 @@
compressor = "zstd"; compressor = "zstd";
supportedFilesystems = [ "f2fs" ]; supportedFilesystems = [ "f2fs" ];
}; };
# BBR congestion control handles variable-latency VPN connections much
# better than CUBIC by probing bandwidth continuously rather than
# reacting to packet loss.
kernelModules = [ "tcp_bbr" ];
kernel.sysctl = {
# Use BBR + fair queuing for smooth throughput through the WireGuard VPN
"net.core.default_qdisc" = "fq";
"net.ipv4.tcp_congestion_control" = "bbr";
# Disable slow-start after idle: prevents TCP from resetting window
# size on each burst cycle (the primary cause of the 0 -> 40 MB/s spikes)
"net.ipv4.tcp_slow_start_after_idle" = 0;
# Larger socket buffers to accommodate the VPN bandwidth-delay product
# (22ms RTT * target throughput). Current 2.5MB max is too small.
"net.core.rmem_max" = 16777216;
"net.core.wmem_max" = 16777216;
"net.ipv4.tcp_rmem" = "4096 87380 16777216";
"net.ipv4.tcp_wmem" = "4096 65536 16777216";
# Higher backlog for the large number of concurrent torrent connections
"net.core.netdev_max_backlog" = 5000;
# Minecraft server optimizations
# Disable autogroup for better scheduling of game server threads
"kernel.sched_autogroup_enabled" = 0;
# Huge pages for Minecraft JVM (4000MB heap / 2MB per page + ~200 overhead)
"vm.nr_hugepages" = 2200;
};
}; };
environment.etc = { environment.etc = {

93
flake.lock generated
View File

@@ -25,13 +25,33 @@
"type": "github" "type": "github"
} }
}, },
"arr-init": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1772249948,
"narHash": "sha256-v68tO12mTCET68eZG583U+OlBL4f6kAoHS9iKA/xLzQ=",
"ref": "refs/heads/main",
"rev": "d21eb9f5b0a30bb487de7c0afbbbaf19324eaa49",
"revCount": 1,
"type": "git",
"url": "ssh://gitea@git.gardling.com/titaniumtown/arr-init"
},
"original": {
"type": "git",
"url": "ssh://gitea@git.gardling.com/titaniumtown/arr-init"
}
},
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1770419512, "lastModified": 1771796463,
"narHash": "sha256-o8Vcdz6B6bkiGUYkZqFwH3Pv1JwZyXht3dMtS7RchIo=", "narHash": "sha256-9bCDuUzpwJXcHMQYMS1yNuzYMmKO/CCwCexpjWOl62I=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "2510f2cbc3ccd237f700bb213756a8f35c32d8d7", "rev": "3d3de3313e263e04894f284ac18177bd26169bad",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -69,11 +89,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1771271879, "lastModified": 1771881364,
"narHash": "sha256-Vn32sMuvV35ChjVGZE4d8NNmCq3E/6HjaK2uVUUp2JI=", "narHash": "sha256-A5uE/hMium5of/QGC6JwF5TGoDAfpNtW00T0s9u/PN8=",
"owner": "nix-community", "owner": "nix-community",
"repo": "disko", "repo": "disko",
"rev": "e963ed5aea88ad0c093adde7c1c2abd4e1b48beb", "rev": "a4cb7bf73f264d40560ba527f9280469f1f081c6",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -177,11 +197,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1770260404, "lastModified": 1772020340,
"narHash": "sha256-3iVX1+7YUIt23hBx1WZsUllhbmP2EnXrV8tCRbLxHc8=", "narHash": "sha256-aqBl3GNpCadMoJ/hVkWTijM1Aeilc278MjM+LA3jK6g=",
"owner": "nix-community", "owner": "nix-community",
"repo": "home-manager", "repo": "home-manager",
"rev": "0d782ee42c86b196acff08acfbf41bb7d13eed5b", "rev": "36e38ca0d9afe4c55405fdf22179a5212243eecc",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -243,11 +263,11 @@
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
}, },
"locked": { "locked": {
"lastModified": 1770734117, "lastModified": 1772216104,
"narHash": "sha256-PNXSnK507MRj+hYMgnUR7InNJzVCmOfsjHV4YXZgpwQ=", "narHash": "sha256-1TnGN26vnCEQk5m4AavJZxGZTb/6aZyphemRPRwFUfs=",
"owner": "nix-community", "owner": "nix-community",
"repo": "lanzaboote", "repo": "lanzaboote",
"rev": "2038a9a19adb886eccba775321b055fdbdc5029d", "rev": "dbe5112de965bbbbff9f0729a9789c20a65ab047",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -265,11 +285,11 @@
"systems": "systems_3" "systems": "systems_3"
}, },
"locked": { "locked": {
"lastModified": 1771296409, "lastModified": 1772160153,
"narHash": "sha256-p0fEFcqNnhYBKsHTint5pwkcnQk1b68OeQJh95B9Adg=", "narHash": "sha256-lk5IxQzY9ZeeEyjKNT7P6dFnlRpQgkus4Ekc/+slypY=",
"owner": "Infinidoge", "owner": "Infinidoge",
"repo": "nix-minecraft", "repo": "nix-minecraft",
"rev": "22cb60087e549a90f6b0347e84ac178c0c9085ad", "rev": "deca3fb710b502ba10cd5cdc8f66c2cc184b92df",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -280,11 +300,11 @@
}, },
"nixos-hardware": { "nixos-hardware": {
"locked": { "locked": {
"lastModified": 1771257191, "lastModified": 1771969195,
"narHash": "sha256-H1l+zHq+ZinWH7F1IidpJ2farmbfHXjaxAm1RKWE1KI=", "narHash": "sha256-qwcDBtrRvJbrrnv1lf/pREQi8t2hWZxVAyeMo7/E9sw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixos-hardware", "repo": "nixos-hardware",
"rev": "66e1a090ded57a0f88e2b381a7d4daf4a5722c3f", "rev": "41c6b421bdc301b2624486e11905c9af7b8ec68e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -296,11 +316,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1771208521, "lastModified": 1772047000,
"narHash": "sha256-X01Q3DgSpjeBpapoGA4rzKOn25qdKxbPnxHeMLNoHTU=", "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "fa56d7d6de78f5a7f997b0ea2bc6efd5868ad9e8", "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -336,11 +356,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1769939035, "lastModified": 1771858127,
"narHash": "sha256-Fok2AmefgVA0+eprw2NDwqKkPGEI5wvR+twiZagBvrg=", "narHash": "sha256-Gtre9YoYl3n25tJH2AoSdjuwcqij5CPxL3U3xysYD08=",
"owner": "cachix", "owner": "cachix",
"repo": "pre-commit-hooks.nix", "repo": "pre-commit-hooks.nix",
"rev": "a8ca480175326551d6c4121498316261cbb5b260", "rev": "49bbbfc218bf3856dfa631cead3b052d78248b83",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -352,6 +372,7 @@
"root": { "root": {
"inputs": { "inputs": {
"agenix": "agenix", "agenix": "agenix",
"arr-init": "arr-init",
"deploy-rs": "deploy-rs", "deploy-rs": "deploy-rs",
"disko": "disko", "disko": "disko",
"home-manager": "home-manager", "home-manager": "home-manager",
@@ -376,11 +397,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1770520253, "lastModified": 1771988922,
"narHash": "sha256-6rWuHgSENXKnC6HGGAdRolQrnp/8IzscDn7FQEo1uEQ=", "narHash": "sha256-Fc6FHXtfEkLtuVJzd0B6tFYMhmcPLuxr90rWfb/2jtQ=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "ebb8a141f60bb0ec33836333e0ca7928a072217f", "rev": "f4443dc3f0b6c5e6b77d923156943ce816d1fcb9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -413,11 +434,11 @@
"senior_project-website": { "senior_project-website": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1769471280, "lastModified": 1771869552,
"narHash": "sha256-6BADVRSHHwO3NcAua44hagAJTqPNDxEhPjBMehURiHQ=", "narHash": "sha256-veaVrRWCSy7HYAAjUFLw8HASKcj+3f0W+sCwS3QiaM4=",
"owner": "Titaniumtown", "owner": "Titaniumtown",
"repo": "senior-project-website", "repo": "senior-project-website",
"rev": "d6f443ede6c90a049085b4598e438849e19e74f4", "rev": "28a2b93492dac877dce0b38f078eacf74fce26e7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -433,11 +454,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1771207491, "lastModified": 1772071250,
"narHash": "sha256-08s9LKq9Et4y9r6FSJLJUnRCyJHZMauAIok45ulQo0k=", "narHash": "sha256-LDWvJDR1J8xE8TBJjzWnOA0oVP/l9xBFC4npQPJDHN4=",
"owner": "nix-community", "owner": "nix-community",
"repo": "srvos", "repo": "srvos",
"rev": "434ed3900e9a7b23638da97ebe16ab0e0be7fef5", "rev": "5cd73bcf984b72d8046e1175d13753de255adfb9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -509,11 +530,11 @@
"trackerlist": { "trackerlist": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1771283390, "lastModified": 1772233783,
"narHash": "sha256-rkSYYntpKP/OD1vXWw/W+GGRBSaC5OoHLR/yqJhlq/M=", "narHash": "sha256-2jPUBKpPuT4dCXwVFuZvTH3QyURixsfJZD7Zqs0atPY=",
"owner": "ngosang", "owner": "ngosang",
"repo": "trackerslist", "repo": "trackerslist",
"rev": "a7c87dd33cacc627b67447bdef591bd9a8b7d878", "rev": "85c4f103f130b070a192343c334f50c2f56b61a9",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -68,6 +68,11 @@
ytbn-graphing-software = { ytbn-graphing-software = {
url = "git+https://git.gardling.com/titaniumtown/YTBN-Graphing-Software"; url = "git+https://git.gardling.com/titaniumtown/YTBN-Graphing-Software";
}; };
arr-init = {
url = "git+ssh://gitea@git.gardling.com/titaniumtown/arr-init";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs =
@@ -83,7 +88,7 @@
srvos, srvos,
deploy-rs, deploy-rs,
impermanence, impermanence,
agenix, arr-init,
... ...
}@inputs: }@inputs:
let let
@@ -235,6 +240,10 @@
buildPlatform = builtins.currentSystem; buildPlatform = builtins.currentSystem;
}; };
lib = import ./modules/lib.nix { inherit inputs pkgs service_configs; }; lib = import ./modules/lib.nix { inherit inputs pkgs service_configs; };
testSuite = import ./tests/tests.nix {
inherit pkgs lib inputs;
config = self.nixosConfigurations.muffin.config;
};
in in
{ {
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-tree; formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-tree;
@@ -291,6 +300,8 @@
lanzaboote.nixosModules.lanzaboote lanzaboote.nixosModules.lanzaboote
arr-init.nixosModules.default
home-manager.nixosModules.home-manager home-manager.nixosModules.home-manager
( (
{ {
@@ -319,14 +330,9 @@
}; };
}; };
packages.${system} = checks.${system} = testSuite;
let
testSuite = import ./tests/tests.nix { packages.${system} = {
inherit pkgs lib inputs;
config = self.nixosConfigurations.muffin.config;
};
in
{
tests = pkgs.linkFarm "all-tests" ( tests = pkgs.linkFarm "all-tests" (
pkgs.lib.mapAttrsToList (name: test: { pkgs.lib.mapAttrsToList (name: test: {
name = name; name = name;

View File

@@ -65,5 +65,20 @@
owner = "root"; owner = "root";
group = "root"; group = "root";
}; };
# ntfy-alerts secrets
ntfy-alerts-topic = {
file = ../secrets/ntfy-alerts-topic.age;
mode = "0400";
owner = "root";
group = "root";
};
ntfy-alerts-token = {
file = ../secrets/ntfy-alerts-token.age;
mode = "0400";
owner = "root";
group = "root";
};
}; };
} }

View File

@@ -1,495 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.arrInit;
bazarrCfg = config.services.bazarrInit;
downloadClientModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name of the download client (e.g. \"qBittorrent\").";
example = "qBittorrent";
};
implementation = lib.mkOption {
type = lib.types.str;
description = "Implementation identifier for the Servarr API.";
example = "QBittorrent";
};
configContract = lib.mkOption {
type = lib.types.str;
description = "Config contract identifier for the Servarr API.";
example = "QBittorrentSettings";
};
protocol = lib.mkOption {
type = lib.types.enum [
"torrent"
"usenet"
];
default = "torrent";
description = "Download protocol type.";
};
fields = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = { };
description = ''
Flat key/value pairs for the download client configuration.
These are converted to the API's [{name, value}] array format.
'';
example = {
host = "192.168.15.1";
port = 6011;
useSsl = false;
tvCategory = "tvshows";
};
};
};
};
syncedAppModule = lib.types.submodule {
options = {
name = lib.mkOption {
type = lib.types.str;
description = "Display name of the application to sync (e.g. \"Sonarr\").";
example = "Sonarr";
};
implementation = lib.mkOption {
type = lib.types.str;
description = "Implementation identifier for the Prowlarr application API.";
example = "Sonarr";
};
configContract = lib.mkOption {
type = lib.types.str;
description = "Config contract identifier for the Prowlarr application API.";
example = "SonarrSettings";
};
syncLevel = lib.mkOption {
type = lib.types.str;
default = "fullSync";
description = "Sync level for the application.";
};
prowlarrUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the Prowlarr instance.";
example = "http://localhost:9696";
};
baseUrl = lib.mkOption {
type = lib.types.str;
description = "URL of the target application.";
example = "http://localhost:8989";
};
apiKeyFrom = lib.mkOption {
type = lib.types.str;
description = "Path to the config.xml file to read the API key from at runtime.";
example = "/services/sonarr/config.xml";
};
syncCategories = lib.mkOption {
type = lib.types.listOf lib.types.int;
default = [ ];
description = "List of sync category IDs for the application.";
example = [
5000
5010
5020
];
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on for reading the API key.";
example = "sonarr";
};
};
};
instanceModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Servarr application API initialization";
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service this init depends on.";
example = "sonarr";
};
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the application data directory containing config.xml.";
example = "/var/lib/sonarr";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the Servarr application.";
example = 8989;
};
apiVersion = lib.mkOption {
type = lib.types.str;
default = "v3";
description = "API version string used in the base URL.";
};
networkNamespacePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "If set, run this init service inside the given network namespace path (e.g. /run/netns/wg).";
};
downloadClients = lib.mkOption {
type = lib.types.listOf downloadClientModule;
default = [ ];
description = "List of download clients to configure via the API.";
};
rootFolders = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of root folder paths to configure via the API.";
example = [
"/media/tv"
"/media/movies"
];
};
syncedApps = lib.mkOption {
type = lib.types.listOf syncedAppModule;
default = [ ];
description = "Applications to register for indexer sync (Prowlarr only).";
};
};
};
bazarrProviderModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "provider connection";
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to the provider's data directory containing config.xml.";
example = "/services/sonarr";
};
port = lib.mkOption {
type = lib.types.port;
description = "API port of the provider.";
example = 8989;
};
serviceName = lib.mkOption {
type = lib.types.str;
description = "Name of the systemd service to depend on.";
example = "sonarr";
};
};
};
bazarrInitModule = lib.types.submodule {
options = {
enable = lib.mkEnableOption "Bazarr API initialization";
dataDir = lib.mkOption {
type = lib.types.str;
description = "Path to Bazarr's data directory containing config/config.ini.";
example = "/services/bazarr";
};
port = lib.mkOption {
type = lib.types.port;
default = 6767;
description = "API port of Bazarr.";
};
sonarr = lib.mkOption {
type = bazarrProviderModule;
default = {
enable = false;
};
description = "Sonarr provider configuration.";
};
radarr = lib.mkOption {
type = bazarrProviderModule;
default = {
enable = false;
};
description = "Radarr provider configuration.";
};
};
};
curl = "${pkgs.curl}/bin/curl";
jq = "${pkgs.jq}/bin/jq";
grep = "${pkgs.gnugrep}/bin/grep";
awk = "${pkgs.gawk}/bin/awk";
mkDownloadClientPayload =
dc:
builtins.toJSON {
enable = true;
protocol = dc.protocol;
priority = 1;
name = dc.name;
implementation = dc.implementation;
configContract = dc.configContract;
fields = lib.mapAttrsToList (n: v: {
name = n;
value = v;
}) dc.fields;
tags = [ ];
};
mkDownloadClientSection = dc: ''
# Download client: ${dc.name}
echo "Checking download client '${dc.name}'..."
EXISTING_DC=$(${curl} -sf "$BASE_URL/downloadclient" -H "X-Api-Key: $API_KEY")
if echo "$EXISTING_DC" | ${jq} -e --arg name ${lib.escapeShellArg dc.name} '.[] | select(.name == $name)' > /dev/null 2>&1; then
echo "Download client '${dc.name}' already exists, skipping"
else
echo "Adding download client '${dc.name}'..."
${curl} -sf -X POST "$BASE_URL/downloadclient?forceSave=true" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d ${lib.escapeShellArg (mkDownloadClientPayload dc)}
echo "Download client '${dc.name}' added"
fi
'';
mkRootFolderSection = path: ''
# Root folder: ${path}
echo "Checking root folder '${path}'..."
EXISTING_RF=$(${curl} -sf "$BASE_URL/rootfolder" -H "X-Api-Key: $API_KEY")
if echo "$EXISTING_RF" | ${jq} -e --arg path ${lib.escapeShellArg path} '.[] | select(.path == $path)' > /dev/null 2>&1; then
echo "Root folder '${path}' already exists, skipping"
else
echo "Adding root folder '${path}'..."
${curl} -sf -X POST "$BASE_URL/rootfolder" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d ${lib.escapeShellArg (builtins.toJSON { inherit path; })}
echo "Root folder '${path}' added"
fi
'';
mkSyncedAppSection = app: ''
# Synced app: ${app.name}
echo "Checking synced app '${app.name}'..."
TARGET_API_KEY=$(${grep} -oP '(?<=<ApiKey>)[^<]+' ${lib.escapeShellArg app.apiKeyFrom})
EXISTING_APPS=$(${curl} -sf "$BASE_URL/applications" -H "X-Api-Key: $API_KEY")
if echo "$EXISTING_APPS" | ${jq} -e --arg name ${lib.escapeShellArg app.name} '.[] | select(.name == $name)' > /dev/null 2>&1; then
echo "Synced app '${app.name}' already exists, skipping"
else
echo "Adding synced app '${app.name}'..."
PAYLOAD=$(${jq} -n \
--arg name ${lib.escapeShellArg app.name} \
--arg implementation ${lib.escapeShellArg app.implementation} \
--arg configContract ${lib.escapeShellArg app.configContract} \
--arg syncLevel ${lib.escapeShellArg app.syncLevel} \
--arg prowlarrUrl ${lib.escapeShellArg app.prowlarrUrl} \
--arg baseUrl ${lib.escapeShellArg app.baseUrl} \
--arg apiKey "$TARGET_API_KEY" \
--argjson syncCategories ${builtins.toJSON app.syncCategories} \
'{
name: $name,
implementation: $implementation,
configContract: $configContract,
syncLevel: $syncLevel,
fields: [
{name: "prowlarrUrl", value: $prowlarrUrl},
{name: "baseUrl", value: $baseUrl},
{name: "apiKey", value: $apiKey},
{name: "syncCategories", value: $syncCategories}
],
tags: []
}')
${curl} -sf -X POST "$BASE_URL/applications?forceSave=true" \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d "$PAYLOAD"
echo "Synced app '${app.name}' added"
fi
'';
mkInitScript =
name: inst:
pkgs.writeShellScript "${name}-init" ''
set -euo pipefail
CONFIG_XML="${inst.dataDir}/config.xml"
if [ ! -f "$CONFIG_XML" ]; then
echo "Config file $CONFIG_XML not found, skipping ${name} init"
exit 0
fi
API_KEY=$(${grep} -oP '(?<=<ApiKey>)[^<]+' "$CONFIG_XML")
BASE_URL="http://localhost:${builtins.toString inst.port}/api/${inst.apiVersion}"
# Wait for API to become available
echo "Waiting for ${name} API..."
for i in $(seq 1 90); do
if ${curl} -sf "$BASE_URL/system/status" -H "X-Api-Key: $API_KEY" > /dev/null 2>&1; then
echo "${name} API is ready"
break
fi
if [ "$i" -eq 90 ]; then
echo "${name} API not available after 90 seconds" >&2
exit 1
fi
sleep 1
done
${lib.concatMapStringsSep "\n" mkDownloadClientSection inst.downloadClients}
${lib.concatMapStringsSep "\n" mkRootFolderSection inst.rootFolders}
${lib.concatMapStringsSep "\n" mkSyncedAppSection inst.syncedApps}
echo "${name} init complete"
'';
# Get list of service names that syncedApps depend on
getSyncedAppDeps = inst: map (app: "${app.serviceName}.service") inst.syncedApps;
enabledInstances = lib.filterAttrs (_: inst: inst.enable) cfg;
mkBazarrProviderSection = type: provider:
let
ltype = lib.toLower type;
in ''
# ${type} provider
echo "Checking ${type} provider..."
PROVIDER_API_KEY=$(${grep} -oP '(?<=<ApiKey>)[^<]+' ${lib.escapeShellArg "${provider.dataDir}/config.xml"})
EXISTING=$(${curl} -sf "$BASE_URL/api/system/settings" -H "X-API-KEY: $API_KEY")
USE_FLAG=$(echo "$EXISTING" | ${jq} -r '.general.use_${ltype}')
EXISTING_KEY=$(echo "$EXISTING" | ${jq} -r '.${ltype}.apikey // ""')
if [ "$USE_FLAG" = "true" ] && [ -n "$EXISTING_KEY" ]; then
echo "${type} provider already configured, skipping"
else
echo "Adding ${type} provider..."
${curl} -sf -X POST "$BASE_URL/api/system/settings" \
-H "X-API-KEY: $API_KEY" \
-d "settings-general-use_${ltype}=true" \
-d "settings-${ltype}-ip=localhost" \
-d "settings-${ltype}-port=${builtins.toString provider.port}" \
-d "settings-${ltype}-apikey=$PROVIDER_API_KEY" \
-d "settings-${ltype}-ssl=false" \
-d "settings-${ltype}-base_url=/"
echo "${type} provider added"
fi
'';
mkBazarrInitScript = pkgs.writeShellScript "bazarr-init" ''
set -euo pipefail
CONFIG_YAML="${bazarrCfg.dataDir}/config/config.yaml"
if [ ! -f "$CONFIG_YAML" ]; then
echo "Config file $CONFIG_YAML not found, skipping bazarr init"
exit 0
fi
API_KEY=$(${awk} '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, ""); print; exit}' "$CONFIG_YAML")
BASE_URL="http://localhost:${builtins.toString bazarrCfg.port}"
# Wait for API to become available
echo "Waiting for Bazarr API..."
for i in $(seq 1 90); do
if ${curl} -sf "$BASE_URL/api/system/status" -H "X-API-KEY: $API_KEY" > /dev/null 2>&1; then
echo "Bazarr API is ready"
break
fi
if [ "$i" -eq 90 ]; then
echo "Bazarr API not available after 90 seconds" >&2
exit 1
fi
sleep 1
done
${lib.optionalString bazarrCfg.sonarr.enable (mkBazarrProviderSection "Sonarr" bazarrCfg.sonarr)}
${lib.optionalString bazarrCfg.radarr.enable (mkBazarrProviderSection "Radarr" bazarrCfg.radarr)}
echo "Bazarr init complete"
'';
bazarrDeps = [
"bazarr.service"
]
++ (lib.optional bazarrCfg.sonarr.enable "${bazarrCfg.sonarr.serviceName}.service")
++ (lib.optional bazarrCfg.radarr.enable "${bazarrCfg.radarr.serviceName}.service");
in
{
options.services.arrInit = lib.mkOption {
type = lib.types.attrsOf instanceModule;
default = { };
description = ''
Attribute set of Servarr application instances to initialize via their APIs.
Each instance generates a systemd oneshot service that idempotently configures
download clients, root folders, and synced applications.
'';
};
options.services.bazarrInit = lib.mkOption {
type = bazarrInitModule;
default = {
enable = false;
};
description = ''
Bazarr API initialization for connecting Sonarr and Radarr providers.
Bazarr uses a different API than Servarr applications, so it has its own module.
'';
};
config = lib.mkMerge [
(lib.mkIf (enabledInstances != { }) {
systemd.services = lib.mapAttrs' (
name: inst:
lib.nameValuePair "${inst.serviceName}-init" {
description = "Initialize ${name} API connections";
after = [
"${inst.serviceName}.service"
]
++ (getSyncedAppDeps inst)
++ (lib.optional (inst.networkNamespacePath != null) "wg.service");
requires = [ "${inst.serviceName}.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${mkInitScript name inst}";
}
// lib.optionalAttrs (inst.networkNamespacePath != null) {
NetworkNamespacePath = inst.networkNamespacePath;
};
}
) enabledInstances;
})
(lib.mkIf bazarrCfg.enable {
systemd.services.bazarr-init = {
description = "Initialize Bazarr API connections";
after = bazarrDeps;
requires = bazarrDeps;
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
ExecStart = "${mkBazarrInitScript}";
};
};
})
];
}

132
modules/ntfy-alerts.nix Normal file
View File

@@ -0,0 +1,132 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.ntfyAlerts;
curl = "${pkgs.curl}/bin/curl";
hostname = config.networking.hostName;
# Build the curl auth args as a proper bash array fragment
authCurlArgs =
if cfg.tokenFile != null then
''
if [ -f "${cfg.tokenFile}" ]; then
TOKEN=$(cat "${cfg.tokenFile}" 2>/dev/null || echo "")
if [ -n "$TOKEN" ]; then
AUTH_ARGS=(-H "Authorization: Bearer $TOKEN")
fi
fi
''
else
"";
# Systemd failure alert script
systemdAlertScript = pkgs.writeShellScript "ntfy-systemd-alert" ''
set -euo pipefail
UNIT_NAME="$1"
SERVER_URL="${cfg.serverUrl}"
TOPIC=$(cat "${cfg.topicFile}" 2>/dev/null | tr -d '[:space:]')
if [ -z "$TOPIC" ]; then
echo "ERROR: Could not read topic from ${cfg.topicFile}"
exit 1
fi
# Get journal output for context
JOURNAL_OUTPUT=$(${pkgs.systemd}/bin/journalctl -u "$UNIT_NAME" -n 15 --no-pager 2>/dev/null || echo "No journal output available")
# Build auth args
AUTH_ARGS=()
${authCurlArgs}
# Send notification
${curl} -sf --max-time 15 -X POST \
"$SERVER_URL/$TOPIC" \
-H "Title: [${hostname}] Service failed: $UNIT_NAME" \
-H "Priority: high" \
-H "Tags: warning" \
"''${AUTH_ARGS[@]}" \
-d "$JOURNAL_OUTPUT" || true
'';
in
{
options.services.ntfyAlerts = {
enable = lib.mkEnableOption "ntfy push notifications for system alerts";
serverUrl = lib.mkOption {
type = lib.types.str;
description = "The ntfy server URL (e.g. https://ntfy.example.com)";
example = "https://ntfy.example.com";
};
topicFile = lib.mkOption {
type = lib.types.path;
description = "Path to a file containing the ntfy topic name to publish alerts to.";
example = "/run/agenix/ntfy-alerts-topic";
};
tokenFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
Path to a file containing the ntfy auth token.
If set, uses Authorization: Bearer header for authentication.
'';
example = "/run/secrets/ntfy-token";
};
};
config = lib.mkIf cfg.enable {
# Per-service OnFailure for monitored services
systemd.services = {
"ntfy-alert@" = {
description = "Send ntfy notification for failed service %i";
unitConfig.OnFailure = lib.mkForce "";
serviceConfig = {
Type = "oneshot";
ExecStart = "${systemdAlertScript} %i";
TimeoutSec = 30;
};
};
# TODO: sanoid's ExecStartPre runs `zfs allow` which blocks on TXG sync;
# on the hdds pool (slow spinning disks + large async frees) this causes
# 30+ minute hangs and guaranteed timeouts. Suppress until we fix sanoid
# to run as root without `zfs allow`. See: nixpkgs#72060, openzfs/zfs#14180
"sanoid".unitConfig.OnFailure = lib.mkForce "";
};
# Global OnFailure drop-in for all services
systemd.packages = [
(pkgs.writeTextDir "etc/systemd/system/service.d/onfailure.conf" ''
[Unit]
OnFailure=ntfy-alert@%p.service
'')
# Sanoid-specific drop-in to override the global OnFailure (see TODO above)
(pkgs.writeTextDir "etc/systemd/system/sanoid.service.d/onfailure.conf" ''
[Unit]
OnFailure=
'')
];
# ZED (ZFS Event Daemon) ntfy notification settings
services.zfs.zed = {
enableMail = false;
settings = {
ZED_NTFY_URL = cfg.serverUrl;
ZED_NTFY_TOPIC = "$(cat ${cfg.topicFile} | tr -d '[:space:]')";
ZED_NTFY_ACCESS_TOKEN = lib.mkIf (cfg.tokenFile != null) "$(cat ${cfg.tokenFile})";
ZED_NOTIFY_VERBOSE = true;
};
};
};
}

View File

@@ -64,7 +64,7 @@ in
yearly = 0; yearly = 0;
}; };
datasets."${service_configs.zpool_ssds}/services/jellyfin_cache" = { datasets."${service_configs.zpool_ssds}/services/jellyfin/cache" = {
recursive = true; recursive = true;
autoprune = true; autoprune = true;
autosnap = true; autosnap = true;

Binary file not shown.

Binary file not shown.

View File

@@ -12,8 +12,8 @@ let
domain = "forgejo.ellis.link"; domain = "forgejo.ellis.link";
owner = "continuwuation"; owner = "continuwuation";
repo = "continuwuity"; repo = "continuwuity";
rev = "082c44f3556e4e939c31cb66dda261af4f70bea8"; rev = "052c4dfa2165fdc4839fed95b71446120273cf23";
hash = "sha256-v7W6ZqSYB2TSkRj6Hte/UxBTCad94b+uzpROQ9jlwdQ="; hash = "sha256-kQV4glRrKczoJpn9QIMgB5ac+saZQjSZPel+9K9Ykcs=";
}; };
in in
pkgs.matrix-continuwuity.overrideAttrs (old: { pkgs.matrix-continuwuity.overrideAttrs (old: {
@@ -21,7 +21,7 @@ let
cargoDeps = pkgs.rustPlatform.fetchCargoVendor { cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
inherit src; inherit src;
name = "${old.pname}-vendor"; name = "${old.pname}-vendor";
hash = "sha256-Ib4yAT0Ncch8QT8CioF9s3fN34E50ZhbcX7m0lgwJkI="; hash = "sha256-vlOXQL8wwEGFX+w0G/eIeHW3J1UDzhJ501kYhAghDV8=";
}; };
patches = (old.patches or [ ]) ++ [ patches = (old.patches or [ ]) ++ [

View File

@@ -35,7 +35,38 @@
let let
heap_size = "4000M"; heap_size = "4000M";
in in
"-Xmx${heap_size} -Xms${heap_size} -XX:+UseZGC -XX:+ZGenerational"; lib.concatStringsSep " " [
# Memory
"-Xmx${heap_size}"
"-Xms${heap_size}"
# GC
"-XX:+UseZGC"
"-XX:+ZGenerational"
# Base JVM optimizations (brucethemoose/Minecraft-Performance-Flags-Benchmarks)
"-XX:+UnlockExperimentalVMOptions"
"-XX:+UnlockDiagnosticVMOptions"
"-XX:+AlwaysActAsServerClassMachine"
"-XX:+AlwaysPreTouch"
"-XX:+DisableExplicitGC"
"-XX:+UseNUMA"
"-XX:+PerfDisableSharedMem"
"-XX:+UseFastUnorderedTimeStamps"
"-XX:+UseCriticalJavaThreadPriority"
"-XX:ThreadPriorityPolicy=1"
"-XX:AllocatePrefetchStyle=3"
"-XX:-DontCompileHugeMethods"
"-XX:MaxNodeLimit=240000"
"-XX:NodeLimitFudgeFactor=8000"
"-XX:ReservedCodeCacheSize=400M"
"-XX:NonNMethodCodeHeapSize=12M"
"-XX:ProfiledCodeHeapSize=194M"
"-XX:NonProfiledCodeHeapSize=194M"
"-XX:NmethodSweepActivity=1"
"-XX:+UseVectorCmov"
# Large pages (requires vm.nr_hugepages sysctl)
"-XX:+UseLargePages"
"-XX:LargePageSizeInBytes=2m"
];
serverProperties = { serverProperties = {
server-port = service_configs.ports.minecraft; server-port = service_configs.ports.minecraft;
@@ -87,8 +118,8 @@
}; };
c2me = fetchurl { c2me = fetchurl {
url = "https://cdn.modrinth.com/data/VSNURh3q/versions/DLKF3HZk/c2me-fabric-mc1.21.11-0.3.6%2Bbeta.1.0.jar"; url = "https://cdn.modrinth.com/data/VSNURh3q/versions/QdLiMUjx/c2me-fabric-mc1.21.11-0.3.7%2Balpha.0.7.jar";
sha512 = "d4f983aeb5083033b525522e623a9a9ba86b6fc9c83db008cc0575d0077e736ac9bee0b6b0e03b8d1c89ae27a4e5cdc269041f61eb0d1a10757de4c30b065467"; sha512 = "f9543febe2d649a82acd6d5b66189b6a3d820cf24aa503ba493fdb3bbd4e52e30912c4c763fe50006f9a46947ae8cd737d420838c61b93429542573ed67f958e";
}; };
krypton = fetchurl { krypton = fetchurl {
@@ -110,12 +141,32 @@
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/CUh1DWeO/packetfixer-fabric-3.3.4-1.21.11.jar";
sha512 = "33331b16cb40c5e6fbaade3cacc26f3a0e8fa5805a7186f94d7366a0e14dbeee9de2d2e8c76fa71f5e9dd24eb1c261667c35447e32570ea965ca0f154fdfba0a"; sha512 = "33331b16cb40c5e6fbaade3cacc26f3a0e8fa5805a7186f94d7366a0e14dbeee9de2d2e8c76fa71f5e9dd24eb1c261667c35447e32570ea965ca0f154fdfba0a";
}; };
# fork of Modernfix for 1.21.11 (upstream will support 26.1)
modernfix = fetchurl {
url = "https://cdn.modrinth.com/data/TjSm1wrD/versions/JwSO8JCN/modernfix-5.25.2-build.4.jar";
sha512 = "0d65c05ac0475408c58ef54215714e6301113101bf98bfe4bb2ba949fbfddd98225ac4e2093a5f9206a9e01ba80a931424b237bdfa3b6e178c741ca6f7f8c6a3";
};
debugify = fetchurl {
url = "https://cdn.modrinth.com/data/QwxR6Gcd/versions/8Q49lnaU/debugify-1.21.11%2B1.0.jar";
sha512 = "04d82dd33f44ced37045f1f9a54ad4eacd70861ff74a8800f2d2df358579e6cb0ea86a34b0086b3e87026b1a0691dd6594b4fdc49f89106466eea840518beb03";
};
} }
); );
}; };
}; };
}; };
systemd.services.minecraft-server-main = {
serviceConfig = {
Nice = -5;
IOSchedulingPriority = 0;
LimitMEMLOCK = "infinity"; # Required for large pages
};
};
services.caddy.virtualHosts = lib.mkIf (config.services.caddy.enable) { services.caddy.virtualHosts = lib.mkIf (config.services.caddy.enable) {
"map.${service_configs.https.domain}".extraConfig = '' "map.${service_configs.https.domain}".extraConfig = ''
root * ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web root * ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web

View File

@@ -20,5 +20,4 @@
restricted = true; restricted = true;
}; };
}; };
} }

10
services/ntfy-alerts.nix Normal file
View File

@@ -0,0 +1,10 @@
{ config, service_configs, ... }:
{
services.ntfyAlerts = {
enable = true;
serverUrl = "https://${service_configs.ntfy.domain}";
topicFile = config.age.secrets.ntfy-alerts-topic.path;
tokenFile = config.age.secrets.ntfy-alerts-token.path;
};
}

View File

@@ -18,7 +18,9 @@
]) ])
(lib.vpnNamespaceOpenPort config.services.qbittorrent.webuiPort "qbittorrent") (lib.vpnNamespaceOpenPort config.services.qbittorrent.webuiPort "qbittorrent")
(lib.serviceFilePerms "qbittorrent" [ (lib.serviceFilePerms "qbittorrent" [
"Z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.SavePath} 0750 ${config.services.qbittorrent.user} ${service_configs.media_group}" # 0770: group (media) needs write to delete files during upgrades —
# 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}"
"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}"
]) ])
@@ -28,6 +30,11 @@
enable = true; enable = true;
webuiPort = service_configs.ports.torrent; webuiPort = service_configs.ports.torrent;
profileDir = "/var/lib/qBittorrent"; profileDir = "/var/lib/qBittorrent";
# Set the service group to 'media' so the systemd unit runs with media as
# the primary GID. Linux assigns new file ownership from the process's GID
# (set by systemd's Group= directive), not from /etc/passwd. Without this,
# downloads land as qbittorrent:qbittorrent (0700), blocking Radarr/Sonarr.
group = service_configs.media_group;
serverConfig.LegalNotice.Accepted = true; serverConfig.LegalNotice.Accepted = true;
@@ -48,7 +55,7 @@
serverConfig.BitTorrent = { serverConfig.BitTorrent = {
Session = { Session = {
MaxConnectionsPerTorrent = 100; MaxConnectionsPerTorrent = 50;
MaxUploadsPerTorrent = 10; MaxUploadsPerTorrent = 10;
MaxConnections = -1; MaxConnections = -1;
MaxUploads = -1; MaxUploads = -1;
@@ -57,7 +64,7 @@
# queueing # queueing
QueueingSystemEnabled = true; QueueingSystemEnabled = true;
MaxActiveDownloads = 8; # num of torrents that can download at the same time MaxActiveDownloads = 5; # keep focused: fewer torrents, each gets more bandwidth
MaxActiveUploads = -1; MaxActiveUploads = -1;
MaxActiveTorrents = -1; MaxActiveTorrents = -1;
IgnoreSlowTorrentsForQueueing = true; IgnoreSlowTorrentsForQueueing = true;
@@ -84,8 +91,7 @@
inherit (config.services.qbittorrent.serverConfig.Preferences.Downloads) TempPath; inherit (config.services.qbittorrent.serverConfig.Preferences.Downloads) TempPath;
TempPathEnabled = true; TempPathEnabled = true;
# how many connections per sec ConnectionSpeed = 200;
ConnectionSpeed = 300;
# Automatic Torrent Management: use category save paths for new torrents # Automatic Torrent Management: use category save paths for new torrents
DisableAutoTMMByDefault = false; DisableAutoTMMByDefault = false;
@@ -95,6 +101,7 @@
ChokingAlgorithm = "RateBased"; ChokingAlgorithm = "RateBased";
PieceExtentAffinity = true; PieceExtentAffinity = true;
SuggestMode = true; SuggestMode = true;
CoalesceReadWrite = true;
}; };
Network = { Network = {

View File

@@ -1,419 +0,0 @@
{
config,
lib,
pkgs,
...
}:
let
testPkgs = pkgs.appendOverlays [ (import ../modules/overlays.nix) ];
in
testPkgs.testers.runNixOSTest {
name = "arr-init";
nodes.machine =
{ pkgs, ... }:
{
imports = [
../modules/arr-init.nix
];
system.stateVersion = config.system.stateVersion;
virtualisation.memorySize = 4096;
environment.systemPackages = with pkgs; [
curl
jq
gnugrep
];
systemd.services.mock-qbittorrent =
let
mockQbitScript = pkgs.writeScript "mock-qbittorrent.py" ''
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
CATEGORIES = {
"tv": {"name": "tv", "savePath": "/downloads"},
"movies": {"name": "movies", "savePath": "/downloads"},
}
class QBitMock(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok.", content_type="text/plain"):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Set-Cookie", "SID=mock_session_id; Path=/")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
path = self.path.split("?")[0]
if path == "/api/v2/app/webapiVersion":
self._respond(body=b"2.9.3")
elif path == "/api/v2/app/version":
self._respond(body=b"v5.0.0")
elif path == "/api/v2/torrents/info":
self._respond(body=b"[]", content_type="application/json")
elif path == "/api/v2/torrents/categories":
body = json.dumps(CATEGORIES).encode()
self._respond(body=body, content_type="application/json")
elif path == "/api/v2/app/preferences":
body = json.dumps({"save_path": "/tmp"}).encode()
self._respond(body=body, content_type="application/json")
else:
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode()
path = urlparse(self.path).path
query = parse_qs(urlparse(self.path).query)
form = parse_qs(body)
params = {**query, **form}
if path == "/api/v2/torrents/createCategory":
name = params.get("category", [""])[0]
save_path = params.get("savePath", params.get("save_path", [""]))[0] or "/downloads"
if name:
CATEGORIES[name] = {"name": name, "savePath": save_path}
if path in ["/api/v2/torrents/editCategory", "/api/v2/torrents/removeCategory"]:
self._respond()
return
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 6011), QBitMock).serve_forever()
'';
in
{
description = "Mock qBittorrent API";
wantedBy = [ "multi-user.target" ];
before = [
"sonarr-init.service"
"radarr-init.service"
];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockQbitScript}";
Type = "simple";
};
};
systemd.tmpfiles.rules = [
"d /media/tv 0755 sonarr sonarr -"
"d /media/movies 0755 radarr radarr -"
];
services.sonarr = {
enable = true;
dataDir = "/var/lib/sonarr/.config/NzbDrone";
settings.server.port = lib.mkDefault 8989;
};
services.radarr = {
enable = true;
dataDir = "/var/lib/radarr/.config/Radarr";
settings.server.port = lib.mkDefault 7878;
};
services.prowlarr = {
enable = true;
};
services.bazarr = {
enable = true;
listenPort = 6767;
};
services.arrInit.sonarr = {
enable = true;
serviceName = "sonarr";
dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989;
downloadClients = [
{
name = "qBittorrent";
implementation = "QBittorrent";
configContract = "QBittorrentSettings";
protocol = "torrent";
fields = {
host = "127.0.0.1";
port = 6011;
useSsl = false;
tvCategory = "tv";
};
}
];
rootFolders = [ "/media/tv" ];
};
services.arrInit.radarr = {
enable = true;
serviceName = "radarr";
dataDir = "/var/lib/radarr/.config/Radarr";
port = 7878;
downloadClients = [
{
name = "qBittorrent";
implementation = "QBittorrent";
configContract = "QBittorrentSettings";
protocol = "torrent";
fields = {
host = "127.0.0.1";
port = 6011;
useSsl = false;
movieCategory = "movies";
};
}
];
rootFolders = [ "/media/movies" ];
};
services.arrInit.prowlarr = {
enable = true;
serviceName = "prowlarr";
dataDir = "/var/lib/prowlarr";
port = 9696;
apiVersion = "v1";
syncedApps = [
{
name = "Sonarr";
implementation = "Sonarr";
configContract = "SonarrSettings";
prowlarrUrl = "http://localhost:9696";
baseUrl = "http://localhost:8989";
apiKeyFrom = "/var/lib/sonarr/.config/NzbDrone/config.xml";
syncCategories = [
5000
5010
5020
5030
5040
5045
5050
5090
];
serviceName = "sonarr";
}
{
name = "Radarr";
implementation = "Radarr";
configContract = "RadarrSettings";
prowlarrUrl = "http://localhost:9696";
baseUrl = "http://localhost:7878";
apiKeyFrom = "/var/lib/radarr/.config/Radarr/config.xml";
syncCategories = [
2000
2010
2020
2030
2040
2045
2050
2060
2070
2080
];
serviceName = "radarr";
}
];
};
services.bazarrInit = {
enable = true;
dataDir = "/var/lib/bazarr";
port = 6767;
sonarr = {
enable = true;
dataDir = "/var/lib/sonarr/.config/NzbDrone";
port = 8989;
serviceName = "sonarr";
};
radarr = {
enable = true;
dataDir = "/var/lib/radarr/.config/Radarr";
port = 7878;
serviceName = "radarr";
};
};
};
testScript = ''
start_all()
# Wait for services to start
machine.wait_for_unit("mock-qbittorrent.service")
machine.wait_until_succeeds("curl -sf http://localhost:6011/api/v2/app/version", timeout=30)
machine.wait_for_unit("sonarr.service")
machine.wait_for_unit("radarr.service")
machine.wait_for_unit("prowlarr.service")
machine.wait_for_unit("bazarr.service")
# Wait for Sonarr API to be ready (config.xml is auto-generated on first start)
machine.wait_until_succeeds(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/system/status -H \"X-Api-Key: $API_KEY\"",
timeout=120,
)
# Wait for Radarr API to be ready
machine.wait_until_succeeds(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && "
"curl -sf http://localhost:7878/api/v3/system/status -H \"X-Api-Key: $API_KEY\"",
timeout=120,
)
# Wait for Prowlarr API to be ready
machine.wait_until_succeeds(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/prowlarr/config.xml) && "
"curl -sf http://localhost:9696/api/v1/system/status -H \"X-Api-Key: $API_KEY\"",
timeout=180,
)
# Ensure init services run after config.xml exists
machine.succeed("systemctl restart sonarr-init.service")
machine.succeed("systemctl restart radarr-init.service")
machine.wait_for_unit("sonarr-init.service")
machine.wait_for_unit("radarr-init.service")
# Wait for init services to complete
machine.wait_for_unit("sonarr-init.service")
machine.wait_for_unit("radarr-init.service")
# Verify Sonarr download clients
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.[] | select(.name == \"qBittorrent\")'"
)
# Verify Sonarr root folders
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.[] | select(.path == \"/media/tv\")'"
)
# Verify Radarr download clients
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && "
"curl -sf http://localhost:7878/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.[] | select(.name == \"qBittorrent\")'"
)
# Verify Radarr root folders
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && "
"curl -sf http://localhost:7878/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.[] | select(.path == \"/media/movies\")'"
)
# Restart prowlarr-init now that all config.xml files exist
machine.succeed("systemctl restart prowlarr-init.service")
machine.wait_for_unit("prowlarr-init.service")
# Verify Sonarr registered as synced app in Prowlarr
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/prowlarr/config.xml) && "
"curl -sf http://localhost:9696/api/v1/applications -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.[] | select(.name == \"Sonarr\")'"
)
# Verify Radarr registered as synced app in Prowlarr
machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/prowlarr/config.xml) && "
"curl -sf http://localhost:9696/api/v1/applications -H \"X-Api-Key: $API_KEY\" | "
"jq -e '.[] | select(.name == \"Radarr\")'"
)
# Idempotency test: restart init services and verify no duplicate entries
machine.succeed("systemctl restart sonarr-init.service")
machine.succeed("systemctl restart radarr-init.service")
machine.succeed("systemctl restart prowlarr-init.service")
# Verify Sonarr still has exactly 1 download client
result = machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | "
"jq '. | length'"
).strip()
assert result == "1", f"Expected 1 Sonarr download client, got {result}"
# Verify Sonarr still has exactly 1 root folder
result = machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/sonarr/.config/NzbDrone/config.xml) && "
"curl -sf http://localhost:8989/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | "
"jq '. | length'"
).strip()
assert result == "1", f"Expected 1 Sonarr root folder, got {result}"
# Verify Radarr still has exactly 1 download client
result = machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && "
"curl -sf http://localhost:7878/api/v3/downloadclient -H \"X-Api-Key: $API_KEY\" | "
"jq '. | length'"
).strip()
assert result == "1", f"Expected 1 Radarr download client, got {result}"
# Verify Radarr still has exactly 1 root folder
result = machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/radarr/.config/Radarr/config.xml) && "
"curl -sf http://localhost:7878/api/v3/rootfolder -H \"X-Api-Key: $API_KEY\" | "
"jq '. | length'"
).strip()
assert result == "1", f"Expected 1 Radarr root folder, got {result}"
# Verify Prowlarr still has exactly 2 synced apps
result = machine.succeed(
"API_KEY=$(grep -oP '(?<=<ApiKey>)[^<]+' /var/lib/prowlarr/config.xml) && "
"curl -sf http://localhost:9696/api/v1/applications -H \"X-Api-Key: $API_KEY\" | "
"jq '. | length'"
).strip()
assert result == "2", f"Expected 2 Prowlarr synced apps, got {result}"
# Wait for Bazarr to generate config.yaml
machine.wait_until_succeeds(
"test -f /var/lib/bazarr/config/config.yaml",
timeout=120,
)
# Wait for Bazarr API to be ready
machine.wait_until_succeeds(
"API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && "
"curl -sf http://localhost:6767/api/system/status -H \"X-API-KEY: $API_KEY\"",
timeout=120,
)
# Restart bazarr-init now that config.yaml exists
machine.succeed("systemctl restart bazarr-init.service")
machine.wait_for_unit("bazarr-init.service")
# Verify Sonarr provider configured in Bazarr
machine.succeed(
"API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && "
"curl -sf http://localhost:6767/api/system/settings -H \"X-API-KEY: $API_KEY\" | "
"jq -e '.general.use_sonarr == true and (.sonarr.apikey // \"\") != \"\"'"
)
# Verify Radarr provider configured in Bazarr
machine.succeed(
"API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && "
"curl -sf http://localhost:6767/api/system/settings -H \"X-API-KEY: $API_KEY\" | "
"jq -e '.general.use_radarr == true and (.radarr.apikey // \"\") != \"\"'"
)
# Idempotency: restart bazarr-init and verify no duplicate config
machine.succeed("systemctl restart bazarr-init.service")
machine.wait_for_unit("bazarr-init.service")
machine.succeed(
"API_KEY=$(awk '/^auth:/{f=1} f && /apikey:/{gsub(/.*apikey: /, \"\"); print; exit}' /var/lib/bazarr/config/config.yaml) && "
"curl -sf http://localhost:6767/api/system/settings -H \"X-API-KEY: $API_KEY\" | "
"jq -e '.general.use_sonarr == true and (.sonarr.apikey // \"\") != \"\"'"
)
'';
}

View File

@@ -60,6 +60,10 @@ testPkgs.testers.runNixOSTest {
wants = lib.mkForce [ ]; wants = lib.mkForce [ ];
after = lib.mkForce [ ]; after = lib.mkForce [ ];
requires = lib.mkForce [ ]; requires = lib.mkForce [ ];
serviceConfig = {
Nice = lib.mkForce 0;
LimitMEMLOCK = lib.mkForce "infinity";
};
}; };
# Test-specific overrides only - reduce memory for testing # Test-specific overrides only - reduce memory for testing

174
tests/ntfy-alerts.nix Normal file
View File

@@ -0,0 +1,174 @@
{
config,
lib,
pkgs,
...
}:
let
testPkgs = pkgs.appendOverlays [ (import ../modules/overlays.nix) ];
in
testPkgs.testers.runNixOSTest {
name = "ntfy-alerts";
nodes.machine =
{ pkgs, ... }:
{
imports = [
../modules/ntfy-alerts.nix
];
system.stateVersion = config.system.stateVersion;
virtualisation.memorySize = 2048;
environment.systemPackages = with pkgs; [
curl
jq
];
# Create test topic file
systemd.tmpfiles.rules = [
"f /run/ntfy-test-topic 0644 root root - test-alerts"
];
# Mock ntfy server that records POST requests
systemd.services.mock-ntfy =
let
mockNtfyScript = pkgs.writeScript "mock-ntfy.py" ''
import json
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
REQUESTS_FILE = "/tmp/ntfy-requests.json"
class MockNtfy(BaseHTTPRequestHandler):
def _respond(self, code=200, body=b"Ok"):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body if isinstance(body, bytes) else body.encode())
def do_GET(self):
self._respond()
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode() if content_length > 0 else ""
request_data = {
"timestamp": datetime.now().isoformat(),
"path": self.path,
"headers": dict(self.headers),
"body": body,
}
# Load existing requests or start new list
requests = []
if os.path.exists(REQUESTS_FILE):
try:
with open(REQUESTS_FILE, "r") as f:
requests = json.load(f)
except:
requests = []
requests.append(request_data)
with open(REQUESTS_FILE, "w") as f:
json.dump(requests, f, indent=2)
self._respond()
def log_message(self, format, *args):
pass
HTTPServer(("0.0.0.0", 8080), MockNtfy).serve_forever()
'';
in
{
description = "Mock ntfy server";
wantedBy = [ "multi-user.target" ];
before = [ "ntfy-alert@test-fail.service" ];
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python3 ${mockNtfyScript}";
Type = "simple";
};
};
# Test service that will fail
systemd.services.test-fail = {
description = "Test service that fails";
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/false";
};
};
# Configure ntfy-alerts to use mock server
services.ntfyAlerts = {
enable = true;
serverUrl = "http://localhost:8080";
topicFile = "/run/ntfy-test-topic";
};
};
testScript = ''
import json
import time
start_all()
# Wait for mock ntfy server to be ready
machine.wait_for_unit("mock-ntfy.service")
machine.wait_until_succeeds("curl -sf http://localhost:8080/", timeout=30)
# Verify the ntfy-alert@ template service exists
machine.succeed("systemctl list-unit-files | grep ntfy-alert@")
# Verify the global OnFailure drop-in is configured
machine.succeed("cat /etc/systemd/system/service.d/onfailure.conf | grep -q 'OnFailure=ntfy-alert@%p.service'")
# Trigger the test-fail service
machine.succeed("systemctl start test-fail.service || true")
# Wait a moment for the failure notification to be sent
time.sleep(2)
# Verify the ntfy-alert@test-fail service ran
machine.succeed("systemctl is-active ntfy-alert@test-fail.service || systemctl is-failed ntfy-alert@test-fail.service || true")
# Check that the mock server received a POST request
machine.wait_until_succeeds("test -f /tmp/ntfy-requests.json", timeout=30)
# Verify the request content
result = machine.succeed("cat /tmp/ntfy-requests.json")
requests = json.loads(result)
assert len(requests) >= 1, f"Expected at least 1 request, got {len(requests)}"
# Check the first request
req = requests[0]
assert "/test-alerts" in req["path"], f"Expected path to contain /test-alerts, got {req['path']}"
assert "Title" in req["headers"], "Expected Title header"
assert "test-fail" in req["headers"]["Title"], f"Expected Title to contain 'test-fail', got {req['headers']['Title']}"
assert req["headers"]["Priority"] == "high", f"Expected Priority 'high', got {req['headers'].get('Priority')}"
assert req["headers"]["Tags"] == "warning", f"Expected Tags 'warning', got {req['headers'].get('Tags')}"
print(f"Received notification: Title={req['headers']['Title']}, Body={req['body'][:100]}...")
# Idempotency test: trigger failure again
machine.succeed("rm /tmp/ntfy-requests.json")
machine.succeed("systemctl reset-failed test-fail.service || true")
machine.succeed("systemctl start test-fail.service || true")
time.sleep(2)
# Verify another notification was sent
machine.wait_until_succeeds("test -f /tmp/ntfy-requests.json", timeout=30)
result = machine.succeed("cat /tmp/ntfy-requests.json")
requests = json.loads(result)
assert len(requests) >= 1, f"Expected at least 1 request after second failure, got {len(requests)}"
print("All tests passed!")
'';
}

View File

@@ -22,6 +22,6 @@ in
fail2banImmichTest = handleTest ./fail2ban-immich.nix; fail2banImmichTest = handleTest ./fail2ban-immich.nix;
fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix; fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix;
# arr tests # ntfy alerts test
arrInitTest = handleTest ./arr-init.nix; ntfyAlertsTest = handleTest ./ntfy-alerts.nix;
} }