Compare commits
1 Commits
main
...
602079cec0
| Author | SHA1 | Date | |
|---|---|---|---|
|
602079cec0
|
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,3 +1,3 @@
|
||||
secrets/** filter=git-crypt diff=git-crypt
|
||||
usb-secrets/usb-secrets-key* filter=git-crypt diff=git-crypt
|
||||
usb-secrets/usb-secrets/usb-secrets-key filter=git-crypt diff=git-crypt
|
||||
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.agenix.nixosModules.default
|
||||
];
|
||||
|
||||
# Configure all agenix secrets
|
||||
age.secrets = {
|
||||
# ZFS encryption key
|
||||
zfs-key = {
|
||||
file = ../secrets/zfs-key.age;
|
||||
file = ./secrets/zfs-key.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
@@ -22,7 +17,7 @@
|
||||
|
||||
# Secureboot keys archive
|
||||
secureboot-tar = {
|
||||
file = ../secrets/secureboot.tar.age;
|
||||
file = ./secrets/secureboot.tar.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
@@ -30,7 +25,7 @@
|
||||
|
||||
# System passwords
|
||||
hashedPass = {
|
||||
file = ../secrets/hashedPass.age;
|
||||
file = ./secrets/hashedPass.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
@@ -38,21 +33,22 @@
|
||||
|
||||
# Service authentication
|
||||
caddy_auth = {
|
||||
file = ../secrets/caddy_auth.age;
|
||||
file = ./secrets/caddy_auth.age;
|
||||
mode = "0400";
|
||||
owner = "caddy";
|
||||
group = "caddy";
|
||||
};
|
||||
|
||||
# TODO! fix permissions
|
||||
jellyfin-api-key = {
|
||||
file = ../secrets/jellyfin-api-key.age;
|
||||
mode = "0400";
|
||||
file = ./secrets/jellyfin-api-key.age;
|
||||
mode = "0444";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
slskd_env = {
|
||||
file = ../secrets/slskd_env.age;
|
||||
file = ./secrets/slskd_env.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
@@ -60,22 +56,7 @@
|
||||
|
||||
# Network configuration
|
||||
wg0-conf = {
|
||||
file = ../secrets/wg0.conf.age;
|
||||
mode = "0400";
|
||||
owner = "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;
|
||||
file = ./secrets/wg0.conf.age;
|
||||
mode = "0400";
|
||||
owner = "root";
|
||||
group = "root";
|
||||
@@ -11,15 +11,13 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
./modules/hardware.nix
|
||||
./modules/zfs.nix
|
||||
./modules/impermanence.nix
|
||||
./modules/usb-secrets.nix
|
||||
./modules/age-secrets.nix
|
||||
./modules/secureboot.nix
|
||||
./modules/no-rgb.nix
|
||||
./modules/security.nix
|
||||
./modules/ntfy-alerts.nix
|
||||
./hardware.nix
|
||||
./zfs.nix
|
||||
./impermanence.nix
|
||||
./usb-secrets.nix
|
||||
./age-secrets.nix
|
||||
./secureboot.nix
|
||||
./no-rgb.nix
|
||||
|
||||
./services/postgresql.nix
|
||||
./services/jellyfin.nix
|
||||
@@ -30,45 +28,20 @@
|
||||
|
||||
./services/wg.nix
|
||||
./services/qbittorrent.nix
|
||||
./services/jellyfin-qbittorrent-monitor.nix
|
||||
./services/bitmagnet.nix
|
||||
|
||||
./services/arr/prowlarr.nix
|
||||
./services/arr/sonarr.nix
|
||||
./services/arr/radarr.nix
|
||||
./services/arr/bazarr.nix
|
||||
./services/arr/jellyseerr.nix
|
||||
./services/arr/recyclarr.nix
|
||||
./services/arr/init.nix
|
||||
|
||||
./services/soulseek.nix
|
||||
|
||||
# ./services/llama-cpp.nix
|
||||
|
||||
./services/ups.nix
|
||||
|
||||
./services/bitwarden.nix
|
||||
|
||||
./services/matrix.nix
|
||||
./services/coturn.nix
|
||||
./services/livekit.nix
|
||||
|
||||
./services/monero.nix
|
||||
./services/xmrig.nix
|
||||
|
||||
# KEEP UNTIL 2028
|
||||
./services/caddy_senior_project.nix
|
||||
|
||||
./services/graphing-calculator.nix
|
||||
|
||||
./services/ssh.nix
|
||||
|
||||
./services/syncthing.nix
|
||||
|
||||
./services/ntfy.nix
|
||||
./services/ntfy-alerts.nix
|
||||
];
|
||||
|
||||
services.kmscon.enable = true;
|
||||
|
||||
systemd.targets = {
|
||||
sleep.enable = false;
|
||||
suspend.enable = false;
|
||||
@@ -76,9 +49,6 @@
|
||||
hybrid-sleep.enable = false;
|
||||
};
|
||||
|
||||
# Disable serial getty on ttyS0 to prevent dmesg warnings
|
||||
systemd.services."serial-getty@ttyS0".enable = false;
|
||||
|
||||
# srvos enables vim, i don't want to use vim, disable it here:
|
||||
programs.vim = {
|
||||
defaultEditor = false;
|
||||
@@ -106,17 +76,8 @@
|
||||
nix = {
|
||||
# optimize the store
|
||||
optimise.automatic = true;
|
||||
|
||||
# garbage collection
|
||||
gc = {
|
||||
automatic = true;
|
||||
dates = "weekly";
|
||||
options = "--delete-older-than 7d";
|
||||
};
|
||||
};
|
||||
|
||||
hardware.intelgpu.driver = "xe";
|
||||
|
||||
boot = {
|
||||
# 6.12 LTS until 2026
|
||||
kernelPackages = pkgs.linuxPackages_6_12_hardened;
|
||||
@@ -133,40 +94,6 @@
|
||||
compressor = "zstd";
|
||||
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;
|
||||
# Faster cleanup of dead connections from torrent peer churn
|
||||
"net.ipv4.tcp_fin_timeout" = 15; # default 60
|
||||
"net.ipv4.tcp_tw_reuse" = 1;
|
||||
|
||||
# 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 = {
|
||||
@@ -176,10 +103,23 @@
|
||||
# Set your time zone.
|
||||
time.timeZone = "America/New_York";
|
||||
|
||||
# Enable the OpenSSH daemon.
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
AllowUsers = [
|
||||
username
|
||||
"root"
|
||||
];
|
||||
PasswordAuthentication = false;
|
||||
PermitRootLogin = "yes"; # for deploying configs
|
||||
};
|
||||
};
|
||||
|
||||
hardware.graphics = {
|
||||
enable = true;
|
||||
extraPackages = with pkgs; [
|
||||
libva-vdpau-driver
|
||||
vaapiVdpau
|
||||
intel-compute-runtime # OpenCL filter support (hardware tonemapping and subtitle burn-in)
|
||||
vpl-gpu-rt # QSV on 11th gen or newer
|
||||
];
|
||||
@@ -218,6 +158,7 @@
|
||||
lsof
|
||||
|
||||
reflac
|
||||
list-usb-drives
|
||||
|
||||
pfetch-rs
|
||||
|
||||
@@ -236,15 +177,13 @@
|
||||
hostName = hostname;
|
||||
hostId = "0f712d56";
|
||||
firewall.enable = true;
|
||||
firewall.trustedInterfaces = [ "wg-br" ];
|
||||
useDHCP = false;
|
||||
enableIPv6 = false;
|
||||
|
||||
interfaces.${eth_interface} = {
|
||||
ipv4.addresses = [
|
||||
{
|
||||
address = "192.168.1.50";
|
||||
# address = "10.1.1.102";
|
||||
address = "10.1.1.102";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
@@ -256,8 +195,7 @@
|
||||
];
|
||||
};
|
||||
defaultGateway = {
|
||||
#address = "10.1.1.1";
|
||||
address = "192.168.1.1";
|
||||
address = "10.1.1.1";
|
||||
interface = eth_interface;
|
||||
};
|
||||
# TODO! fix this
|
||||
@@ -277,9 +215,20 @@
|
||||
"render"
|
||||
service_configs.media_group
|
||||
];
|
||||
|
||||
# TODO! use proper secrets management
|
||||
hashedPasswordFile = config.age.secrets.hashedPass.path;
|
||||
|
||||
openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH" # laptop
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJjT5QZ3zRDb+V6Em20EYpSEgPW5e/U+06uQGJdraxi" # desktop
|
||||
];
|
||||
};
|
||||
|
||||
# used for deploying configs to server
|
||||
users.users.root.openssh.authorizedKeys.keys =
|
||||
config.users.users.${username}.openssh.authorizedKeys.keys;
|
||||
|
||||
# https://nixos.wiki/wiki/Fish#Setting_fish_as_your_shell
|
||||
programs.fish.enable = true;
|
||||
programs.bash = {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{ inputs, ... }:
|
||||
{
|
||||
imports = [
|
||||
inputs.disko.nixosModules.disko
|
||||
];
|
||||
|
||||
disko.devices = {
|
||||
disk = {
|
||||
main = {
|
||||
|
||||
340
flake.lock
generated
340
flake.lock
generated
@@ -2,21 +2,19 @@
|
||||
"nodes": {
|
||||
"agenix": {
|
||||
"inputs": {
|
||||
"darwin": [],
|
||||
"home-manager": [
|
||||
"home-manager"
|
||||
],
|
||||
"darwin": "darwin",
|
||||
"home-manager": "home-manager",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770165109,
|
||||
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
|
||||
"lastModified": 1760836749,
|
||||
"narHash": "sha256-wyT7Pl6tMFbFrs8Lk/TlEs81N6L+VSybPfiIgzU8lbQ=",
|
||||
"owner": "ryantm",
|
||||
"repo": "agenix",
|
||||
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
|
||||
"rev": "2f0f812f69f3eb4140157fe15e12739adf82e32a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -25,33 +23,13 @@
|
||||
"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": {
|
||||
"locked": {
|
||||
"lastModified": 1771796463,
|
||||
"narHash": "sha256-9bCDuUzpwJXcHMQYMS1yNuzYMmKO/CCwCexpjWOl62I=",
|
||||
"lastModified": 1754269165,
|
||||
"narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "3d3de3313e263e04894f284ac18177bd26169bad",
|
||||
"rev": "444e81206df3f7d92780680e45858e31d2f07a08",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -60,6 +38,28 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"darwin": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agenix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1744478979,
|
||||
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
|
||||
"owner": "lnl7",
|
||||
"repo": "nix-darwin",
|
||||
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "lnl7",
|
||||
"ref": "master",
|
||||
"repo": "nix-darwin",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"deploy-rs": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
@@ -69,11 +69,11 @@
|
||||
"utils": "utils"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770019181,
|
||||
"narHash": "sha256-hwsYgDnby50JNVpTRYlF3UR/Rrpt01OrxVuryF40CFY=",
|
||||
"lastModified": 1756719547,
|
||||
"narHash": "sha256-N9gBKUmjwRKPxAafXEk1EGadfk2qDZPBQp4vXWPHINQ=",
|
||||
"owner": "serokell",
|
||||
"repo": "deploy-rs",
|
||||
"rev": "77c906c0ba56aabdbc72041bf9111b565cdd6171",
|
||||
"rev": "125ae9e3ecf62fb2c0fd4f2d894eb971f1ecaed2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -89,11 +89,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771881364,
|
||||
"narHash": "sha256-A5uE/hMium5of/QGC6JwF5TGoDAfpNtW00T0s9u/PN8=",
|
||||
"lastModified": 1760701190,
|
||||
"narHash": "sha256-y7UhnWlER8r776JsySqsbTUh2Txf7K30smfHlqdaIQw=",
|
||||
"owner": "nix-community",
|
||||
"repo": "disko",
|
||||
"rev": "a4cb7bf73f264d40560ba527f9280469f1f081c6",
|
||||
"rev": "3a9450b26e69dcb6f8de6e2b07b3fc1c288d85f5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -121,15 +121,15 @@
|
||||
"flake-compat_2": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"lastModified": 1747046372,
|
||||
"narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -150,9 +150,48 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": [
|
||||
"lanzaboote",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1754091436,
|
||||
"narHash": "sha256-XKqDMN1/Qj1DKivQvscI4vmHfDfvYR2pfuFOJiCeewM=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "67df8c627c2c39c41dbec76a1f201929929ab0bd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-parts_2": {
|
||||
"inputs": {
|
||||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1730504689,
|
||||
"narHash": "sha256-hgmguH29K2fvs9szpq2r3pz2/8cJd2LPS+b4tfNFCwE=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "506278e768c2a08bec68eb62932193e341f55c90",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems_4"
|
||||
"systems": "systems_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
@@ -172,7 +211,7 @@
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"lanzaboote",
|
||||
"pre-commit",
|
||||
"pre-commit-hooks-nix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
@@ -193,20 +232,20 @@
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"agenix",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772020340,
|
||||
"narHash": "sha256-aqBl3GNpCadMoJ/hVkWTijM1Aeilc278MjM+LA3jK6g=",
|
||||
"lastModified": 1745494811,
|
||||
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "36e38ca0d9afe4c55405fdf22179a5212243eecc",
|
||||
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "release-25.11",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
@@ -214,37 +253,31 @@
|
||||
"home-manager_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"impermanence",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1768598210,
|
||||
"narHash": "sha256-kkgA32s/f4jaa4UG+2f8C225Qvclxnqs76mf8zvTVPg=",
|
||||
"lastModified": 1758463745,
|
||||
"narHash": "sha256-uhzsV0Q0I9j2y/rfweWeGif5AWe0MGrgZ/3TjpDYdGA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "c47b2cc64a629f8e075de52e4742de688f930dc6",
|
||||
"rev": "3b955f5f0a942f9f60cdc9cacb7844335d0f21c3",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"ref": "release-25.05",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"impermanence": {
|
||||
"inputs": {
|
||||
"home-manager": "home-manager_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1769548169,
|
||||
"narHash": "sha256-03+JxvzmfwRu+5JafM0DLbxgHttOQZkUtDWBmeUkN8Y=",
|
||||
"lastModified": 1737831083,
|
||||
"narHash": "sha256-LJggUHbpyeDvNagTUrdhe/pRVp4pnS6wVKALS782gRI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "impermanence",
|
||||
"rev": "7b1d382faf603b6d264f58627330f9faa5cba149",
|
||||
"rev": "4b3e914cdf97a5b536a889e939fb2fd2b043a170",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -256,18 +289,20 @@
|
||||
"lanzaboote": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-parts": "flake-parts",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"pre-commit": "pre-commit",
|
||||
"pre-commit-hooks-nix": "pre-commit-hooks-nix",
|
||||
"rust-overlay": "rust-overlay"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772216104,
|
||||
"narHash": "sha256-1TnGN26vnCEQk5m4AavJZxGZTb/6aZyphemRPRwFUfs=",
|
||||
"lastModified": 1756744479,
|
||||
"narHash": "sha256-EyZXusK/wRD3V9vDh00W2Re3Eg8UQ+LjVBQrrH9dq1U=",
|
||||
"owner": "nix-community",
|
||||
"repo": "lanzaboote",
|
||||
"rev": "dbe5112de965bbbbff9f0729a9789c20a65ab047",
|
||||
"rev": "747b7912f49e2885090c83364d88cf853a020ac1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -276,20 +311,41 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"llamacpp": {
|
||||
"inputs": {
|
||||
"flake-parts": "flake-parts_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1761247817,
|
||||
"narHash": "sha256-+cK37gDW7FSU4DfKZ/NWEWcNgV/pDpi/DjG8fzSs/uo=",
|
||||
"owner": "ggml-org",
|
||||
"repo": "llama.cpp",
|
||||
"rev": "0bf47a1dbba4d36f2aff4e8c34b06210ba34e688",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ggml-org",
|
||||
"repo": "llama.cpp",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nix-minecraft": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat_3",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems_3"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772160153,
|
||||
"narHash": "sha256-lk5IxQzY9ZeeEyjKNT7P6dFnlRpQgkus4Ekc/+slypY=",
|
||||
"lastModified": 1761098484,
|
||||
"narHash": "sha256-1CWvNp7sjAijTwbawENQtzh2I36z+K4QHgnlm8fXULA=",
|
||||
"owner": "Infinidoge",
|
||||
"repo": "nix-minecraft",
|
||||
"rev": "deca3fb710b502ba10cd5cdc8f66c2cc184b92df",
|
||||
"rev": "4aa18ac239b378bed4d07eb012c6edaa5e34f1d1",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -300,11 +356,11 @@
|
||||
},
|
||||
"nixos-hardware": {
|
||||
"locked": {
|
||||
"lastModified": 1771969195,
|
||||
"narHash": "sha256-qwcDBtrRvJbrrnv1lf/pREQi8t2hWZxVAyeMo7/E9sw=",
|
||||
"lastModified": 1760958188,
|
||||
"narHash": "sha256-2m1S4jl+GEDtlt2QqeHil8Ny456dcGSKJAM7q3j/BFU=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixos-hardware",
|
||||
"rev": "41c6b421bdc301b2624486e11905c9af7b8ec68e",
|
||||
"rev": "d6645c340ef7d821602fd2cd199e8d1eed10afbc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -316,39 +372,38 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772047000,
|
||||
"narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=",
|
||||
"lastModified": 1761016216,
|
||||
"narHash": "sha256-G/iC4t/9j/52i/nm+0/4ybBmAF4hzR8CNHC75qEhjHo=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e",
|
||||
"rev": "481cf557888e05d3128a76f14c76397b7d7cc869",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.11",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1764517877,
|
||||
"narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c",
|
||||
"type": "github"
|
||||
"lastModified": 1730504152,
|
||||
"narHash": "sha256-lXvH/vOfb4aGYyvFmZK/HlsNsr/0CVWlwYvo2rxJk3s=",
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/cc2f28000298e1269cea6612cd06ec9979dd5d7f.tar.gz"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
"type": "tarball",
|
||||
"url": "https://github.com/NixOS/nixpkgs/archive/cc2f28000298e1269cea6612cd06ec9979dd5d7f.tar.gz"
|
||||
}
|
||||
},
|
||||
"pre-commit": {
|
||||
"pre-commit-hooks-nix": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat_2",
|
||||
"flake-compat": [
|
||||
"lanzaboote",
|
||||
"flake-compat"
|
||||
],
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"lanzaboote",
|
||||
@@ -356,11 +411,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771858127,
|
||||
"narHash": "sha256-Gtre9YoYl3n25tJH2AoSdjuwcqij5CPxL3U3xysYD08=",
|
||||
"lastModified": 1750779888,
|
||||
"narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=",
|
||||
"owner": "cachix",
|
||||
"repo": "pre-commit-hooks.nix",
|
||||
"rev": "49bbbfc218bf3856dfa631cead3b052d78248b83",
|
||||
"rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -372,12 +427,12 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"agenix": "agenix",
|
||||
"arr-init": "arr-init",
|
||||
"deploy-rs": "deploy-rs",
|
||||
"disko": "disko",
|
||||
"home-manager": "home-manager",
|
||||
"home-manager": "home-manager_2",
|
||||
"impermanence": "impermanence",
|
||||
"lanzaboote": "lanzaboote",
|
||||
"llamacpp": "llamacpp",
|
||||
"nix-minecraft": "nix-minecraft",
|
||||
"nixos-hardware": "nixos-hardware",
|
||||
"nixpkgs": "nixpkgs",
|
||||
@@ -385,8 +440,7 @@
|
||||
"srvos": "srvos",
|
||||
"trackerlist": "trackerlist",
|
||||
"vpn-confinement": "vpn-confinement",
|
||||
"website": "website",
|
||||
"ytbn-graphing-software": "ytbn-graphing-software"
|
||||
"website": "website"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
@@ -397,32 +451,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771988922,
|
||||
"narHash": "sha256-Fc6FHXtfEkLtuVJzd0B6tFYMhmcPLuxr90rWfb/2jtQ=",
|
||||
"lastModified": 1754189623,
|
||||
"narHash": "sha256-fstu5eb30UYwsxow0aQqkzxNxGn80UZjyehQVNVHuBk=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "f4443dc3f0b6c5e6b77d923156943ce816d1fcb9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"rust-overlay_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"ytbn-graphing-software",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1764729618,
|
||||
"narHash": "sha256-z4RA80HCWv2los1KD346c+PwNPzMl79qgl7bCVgz8X0=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "52764074a85145d5001bf0aa30cb71936e9ad5b8",
|
||||
"rev": "c582ff7f0d8a7ea689ae836dfb1773f1814f472a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -434,11 +467,11 @@
|
||||
"senior_project-website": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1771869552,
|
||||
"narHash": "sha256-veaVrRWCSy7HYAAjUFLw8HASKcj+3f0W+sCwS3QiaM4=",
|
||||
"lastModified": 1759112913,
|
||||
"narHash": "sha256-5SoOzDT/2W50PrIUfZmwOoiS5VBtqJ+T4jvfMxURAb0=",
|
||||
"owner": "Titaniumtown",
|
||||
"repo": "senior-project-website",
|
||||
"rev": "28a2b93492dac877dce0b38f078eacf74fce26e7",
|
||||
"rev": "37284cf4cde9010ddd74b0246813baab28383089",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -454,11 +487,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772071250,
|
||||
"narHash": "sha256-LDWvJDR1J8xE8TBJjzWnOA0oVP/l9xBFC4npQPJDHN4=",
|
||||
"lastModified": 1761201560,
|
||||
"narHash": "sha256-l0IRzcO4DlBPsheig/LIxdNLK7b0dCw+xDz+8smoOzs=",
|
||||
"owner": "nix-community",
|
||||
"repo": "srvos",
|
||||
"rev": "5cd73bcf984b72d8046e1175d13753de255adfb9",
|
||||
"rev": "bfa539938ff13d48aad3da1bece8d0b47ed5bb77",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -512,29 +545,14 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_4": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"trackerlist": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1772233783,
|
||||
"narHash": "sha256-2jPUBKpPuT4dCXwVFuZvTH3QyURixsfJZD7Zqs0atPY=",
|
||||
"lastModified": 1761171037,
|
||||
"narHash": "sha256-L8kJV5lrOe4yE2dHsByLGxXaZaiNgVPBG6rzjyb0TXA=",
|
||||
"owner": "ngosang",
|
||||
"repo": "trackerslist",
|
||||
"rev": "85c4f103f130b070a192343c334f50c2f56b61a9",
|
||||
"rev": "49171cd110b6f02471045d21099bd84cfc99e412",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -563,11 +581,11 @@
|
||||
},
|
||||
"vpn-confinement": {
|
||||
"locked": {
|
||||
"lastModified": 1767604552,
|
||||
"narHash": "sha256-FddhMxnc99KYOZ/S3YNqtDSoxisIhVtJ7L4s8XD2u0A=",
|
||||
"lastModified": 1759956062,
|
||||
"narHash": "sha256-NUZu0Rb0fwUjfdp51zMm0xM3lcK8Kw4c97LLog7+JjA=",
|
||||
"owner": "Maroka-chan",
|
||||
"repo": "VPN-Confinement",
|
||||
"rev": "a6b2da727853886876fd1081d6bb2880752937f3",
|
||||
"rev": "fabe7247b720b5eb4c3c053e24a2b3b70e64c52b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -579,11 +597,11 @@
|
||||
"website": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1768266466,
|
||||
"narHash": "sha256-d4dZzEcIKuq4DhNtXczaflpRifAtcOgNr45W2Bexnps=",
|
||||
"lastModified": 1756870892,
|
||||
"narHash": "sha256-jPcADOXoREf92sA/wHX9tXfuRRDpwIba4NIqz/Gl8Kg=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "06011a27456b3b9f983ef1aa142b5773bcb52b6e",
|
||||
"revCount": 23,
|
||||
"rev": "890c7633d469862a54b9c5993050b1f287242f2d",
|
||||
"revCount": 20,
|
||||
"type": "git",
|
||||
"url": "https://git.gardling.com/titaniumtown/website"
|
||||
},
|
||||
@@ -591,26 +609,6 @@
|
||||
"type": "git",
|
||||
"url": "https://git.gardling.com/titaniumtown/website"
|
||||
}
|
||||
},
|
||||
"ytbn-graphing-software": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs_2",
|
||||
"rust-overlay": "rust-overlay_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765615270,
|
||||
"narHash": "sha256-12C6LccKRe5ys0iRd+ob+BliswUSmqOKWhMTI8fNpr0=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "ac6265eae734363f95909df9a3739bf6360fa721",
|
||||
"revCount": 1130,
|
||||
"type": "git",
|
||||
"url": "https://git.gardling.com/titaniumtown/YTBN-Graphing-Software"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://git.gardling.com/titaniumtown/YTBN-Graphing-Software"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
158
flake.nix
158
flake.nix
@@ -2,7 +2,7 @@
|
||||
description = "Flake for server muffin";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
|
||||
lanzaboote = {
|
||||
url = "github:nix-community/lanzaboote";
|
||||
@@ -19,7 +19,7 @@
|
||||
vpn-confinement.url = "github:Maroka-chan/VPN-Confinement";
|
||||
|
||||
home-manager = {
|
||||
url = "github:nix-community/home-manager/release-25.11";
|
||||
url = "github:nix-community/home-manager/release-25.05";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
llamacpp = {
|
||||
url = "github:ggml-org/llama.cpp";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
srvos = {
|
||||
url = "github:nix-community/srvos";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
@@ -40,14 +45,11 @@
|
||||
|
||||
impermanence = {
|
||||
url = "github:nix-community/impermanence";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
agenix = {
|
||||
url = "github:ryantm/agenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.home-manager.follows = "home-manager";
|
||||
inputs.darwin.follows = "";
|
||||
};
|
||||
|
||||
senior_project-website = {
|
||||
@@ -64,15 +66,6 @@
|
||||
url = "github:ngosang/trackerslist";
|
||||
flake = false;
|
||||
};
|
||||
|
||||
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 =
|
||||
@@ -88,7 +81,7 @@
|
||||
srvos,
|
||||
deploy-rs,
|
||||
impermanence,
|
||||
arr-init,
|
||||
agenix,
|
||||
...
|
||||
}@inputs:
|
||||
let
|
||||
@@ -101,14 +94,11 @@
|
||||
zpool_ssds = "tank";
|
||||
zpool_hdds = "hdds";
|
||||
torrents_path = "/torrents";
|
||||
services_dir = "/services";
|
||||
services_dir = "/${zpool_ssds}/services";
|
||||
music_dir = "/${zpool_ssds}/music";
|
||||
media_group = "media";
|
||||
|
||||
cpu_arch = "znver3";
|
||||
|
||||
ports = {
|
||||
http = 80;
|
||||
https = 443;
|
||||
jellyfin = 8096; # no services.jellyfin option for this
|
||||
torrent = 6011;
|
||||
@@ -119,27 +109,12 @@
|
||||
soulseek_listen = 50300;
|
||||
llama_cpp = 8991;
|
||||
vaultwarden = 8222;
|
||||
syncthing_gui = 8384;
|
||||
syncthing_protocol = 22000;
|
||||
syncthing_discovery = 21027;
|
||||
minecraft = 25565;
|
||||
matrix = 6167;
|
||||
matrix_federation = 8448;
|
||||
coturn = 3478;
|
||||
coturn_tls = 5349;
|
||||
ntfy = 2586;
|
||||
livekit = 7880;
|
||||
lk_jwt = 8081;
|
||||
prowlarr = 9696;
|
||||
sonarr = 8989;
|
||||
radarr = 7878;
|
||||
bazarr = 6767;
|
||||
jellyseerr = 5055;
|
||||
};
|
||||
|
||||
https = {
|
||||
certs = services_dir + "/http_certs";
|
||||
domain = "gardling.com";
|
||||
wg_ip = "192.168.15.1";
|
||||
};
|
||||
|
||||
gitea = {
|
||||
@@ -180,73 +155,17 @@
|
||||
vaultwarden = {
|
||||
path = "/var/lib/vaultwarden";
|
||||
};
|
||||
|
||||
monero = {
|
||||
dataDir = services_dir + "/monero";
|
||||
};
|
||||
|
||||
matrix = {
|
||||
dataDir = "/var/lib/continuwuity";
|
||||
domain = "matrix.${https.domain}";
|
||||
};
|
||||
|
||||
ntfy = {
|
||||
domain = "ntfy.${https.domain}";
|
||||
};
|
||||
|
||||
livekit = {
|
||||
domain = "livekit.${https.domain}";
|
||||
};
|
||||
|
||||
syncthing = {
|
||||
dataDir = services_dir + "/syncthing";
|
||||
signalBackupDir = "/${zpool_ssds}/bak/signal";
|
||||
grayjayBackupDir = "/${zpool_ssds}/bak/grayjay";
|
||||
};
|
||||
|
||||
prowlarr = {
|
||||
dataDir = services_dir + "/prowlarr";
|
||||
};
|
||||
|
||||
sonarr = {
|
||||
dataDir = services_dir + "/sonarr";
|
||||
};
|
||||
|
||||
radarr = {
|
||||
dataDir = services_dir + "/radarr";
|
||||
};
|
||||
|
||||
bazarr = {
|
||||
dataDir = services_dir + "/bazarr";
|
||||
};
|
||||
|
||||
jellyseerr = {
|
||||
configDir = services_dir + "/jellyseerr";
|
||||
};
|
||||
|
||||
recyclarr = {
|
||||
dataDir = services_dir + "/recyclarr";
|
||||
};
|
||||
|
||||
media = {
|
||||
moviesDir = torrents_path + "/media/movies";
|
||||
tvDir = torrents_path + "/media/tv";
|
||||
};
|
||||
};
|
||||
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
targetPlatform = system;
|
||||
hostPlatform = system;
|
||||
buildPlatform = builtins.currentSystem;
|
||||
};
|
||||
lib = import ./modules/lib.nix { inherit inputs pkgs service_configs; };
|
||||
testSuite = import ./tests/tests.nix {
|
||||
inherit pkgs lib inputs;
|
||||
config = self.nixosConfigurations.muffin.config;
|
||||
};
|
||||
lib = import ./lib.nix { inherit inputs pkgs; };
|
||||
in
|
||||
{
|
||||
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-tree;
|
||||
formatter.x86_64-linux = nixpkgs.legacyPackages.x86_64-linux.nixfmt-rfc-style;
|
||||
nixosConfigurations.${hostname} = lib.nixosSystem {
|
||||
inherit system;
|
||||
specialArgs = {
|
||||
@@ -284,23 +203,25 @@
|
||||
srvos.nixosModules.mixins-terminfo
|
||||
|
||||
./disk-config.nix
|
||||
disko.nixosModules.disko
|
||||
./configuration.nix
|
||||
|
||||
impermanence.nixosModules.impermanence
|
||||
|
||||
vpn-confinement.nixosModules.default
|
||||
|
||||
# get nix-minecraft working!
|
||||
nix-minecraft.nixosModules.minecraft-servers
|
||||
{
|
||||
nixpkgs.overlays = [
|
||||
nix-minecraft.overlay
|
||||
(import ./modules/overlays.nix)
|
||||
(import ./overlays.nix)
|
||||
];
|
||||
nixpkgs.config.allowUnfreePredicate =
|
||||
pkg:
|
||||
builtins.elem (nixpkgs.lib.getName pkg) [
|
||||
"minecraft-server"
|
||||
];
|
||||
}
|
||||
|
||||
lanzaboote.nixosModules.lanzaboote
|
||||
|
||||
arr-init.nixosModules.default
|
||||
agenix.nixosModules.default
|
||||
|
||||
home-manager.nixosModules.home-manager
|
||||
(
|
||||
@@ -309,7 +230,7 @@
|
||||
...
|
||||
}:
|
||||
{
|
||||
home-manager.users.${username} = import ./modules/home.nix;
|
||||
home-manager.users.${username} = import ./home.nix;
|
||||
}
|
||||
)
|
||||
]
|
||||
@@ -330,19 +251,24 @@
|
||||
};
|
||||
};
|
||||
|
||||
checks.${system} = testSuite;
|
||||
|
||||
packages.${system} = {
|
||||
tests = pkgs.linkFarm "all-tests" (
|
||||
pkgs.lib.mapAttrsToList (name: test: {
|
||||
name = name;
|
||||
path = test;
|
||||
}) testSuite
|
||||
);
|
||||
}
|
||||
// (pkgs.lib.mapAttrs' (name: test: {
|
||||
name = "test-${name}";
|
||||
value = test;
|
||||
}) testSuite);
|
||||
packages.${system} =
|
||||
let
|
||||
testSuite = import ./tests/tests.nix {
|
||||
inherit pkgs lib inputs;
|
||||
config = self.nixosConfigurations.muffin.config;
|
||||
};
|
||||
in
|
||||
{
|
||||
tests = pkgs.linkFarm "all-tests" (
|
||||
pkgs.lib.mapAttrsToList (name: test: {
|
||||
name = name;
|
||||
path = test;
|
||||
}) testSuite
|
||||
);
|
||||
}
|
||||
// (pkgs.lib.mapAttrs' (name: test: {
|
||||
name = "test-${name}";
|
||||
value = test;
|
||||
}) testSuite);
|
||||
};
|
||||
}
|
||||
|
||||
52
impermanence.nix
Normal file
52
impermanence.nix
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
username,
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
environment.persistence."/persistent" = {
|
||||
hideMounts = true;
|
||||
directories = [
|
||||
"/var/log"
|
||||
"/var/lib/systemd/coredump"
|
||||
"/var/lib/nixos"
|
||||
|
||||
"/var/lib/systemd/timers"
|
||||
];
|
||||
|
||||
files = [
|
||||
# SSH host keys
|
||||
"/etc/ssh/ssh_host_ed25519_key"
|
||||
"/etc/ssh/ssh_host_ed25519_key.pub"
|
||||
"/etc/ssh/ssh_host_rsa_key"
|
||||
"/etc/ssh/ssh_host_rsa_key.pub"
|
||||
|
||||
# Machine ID
|
||||
"/etc/machine-id"
|
||||
|
||||
# ZFS cache
|
||||
"/etc/zfs/zpool.cache"
|
||||
];
|
||||
|
||||
users.${username} = {
|
||||
files = [
|
||||
".local/share/fish/fish_history"
|
||||
];
|
||||
};
|
||||
|
||||
users.root = {
|
||||
home = "/root";
|
||||
|
||||
files = [
|
||||
".local/share/fish/fish_history"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /etc 755 root"
|
||||
];
|
||||
}
|
||||
103
lib.nix
Normal file
103
lib.nix
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
inputs,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
inputs.nixpkgs.lib.extend (
|
||||
final: prev:
|
||||
let
|
||||
lib = prev;
|
||||
in
|
||||
{
|
||||
serviceMountDeps =
|
||||
serviceName: dirs:
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
systemd.services."${serviceName}_mounts" = {
|
||||
wants = [ "zfs.target" ];
|
||||
before = [ "${serviceName}.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${lib.getExe pkgs.ensureZfsMounts} ${lib.strings.concatStringsSep " " dirs}";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.${serviceName} = {
|
||||
wants = [ "${serviceName}_mounts.service" ];
|
||||
after = [ "${serviceName}_mounts.service" ];
|
||||
requires = [ "${serviceName}_mounts.service" ];
|
||||
};
|
||||
};
|
||||
|
||||
# stolen from: https://stackoverflow.com/a/42398526
|
||||
optimizeWithFlags =
|
||||
pkg: flags:
|
||||
lib.overrideDerivation pkg (
|
||||
old:
|
||||
let
|
||||
newflags = lib.foldl' (acc: x: "${acc} ${x}") "" flags;
|
||||
oldflags = if (lib.hasAttr "NIX_CFLAGS_COMPILE" old) then "${old.NIX_CFLAGS_COMPILE}" else "";
|
||||
in
|
||||
{
|
||||
NIX_CFLAGS_COMPILE = "${oldflags} ${newflags}";
|
||||
# stdenv = pkgs.clang19Stdenv;
|
||||
}
|
||||
);
|
||||
|
||||
optimizePackage =
|
||||
pkg:
|
||||
final.optimizeWithFlags pkg [
|
||||
"-O3"
|
||||
"-march=znver3"
|
||||
"-mtune=znver3"
|
||||
];
|
||||
|
||||
vpnNamespaceOpenPort =
|
||||
port: service:
|
||||
{ ... }:
|
||||
{
|
||||
vpnNamespaces.wg = {
|
||||
portMappings = [
|
||||
{
|
||||
from = port;
|
||||
to = port;
|
||||
}
|
||||
];
|
||||
|
||||
openVPNPorts = [
|
||||
{
|
||||
port = port;
|
||||
protocol = "both";
|
||||
}
|
||||
];
|
||||
};
|
||||
systemd.services.${service}.vpnConfinement = {
|
||||
enable = true;
|
||||
vpnNamespace = "wg";
|
||||
};
|
||||
};
|
||||
|
||||
serviceDependZpool =
|
||||
serviceName: zpool:
|
||||
{ config, ... }:
|
||||
{
|
||||
config = lib.mkIf (zpool != "") {
|
||||
systemd.services.${serviceName} = {
|
||||
wants = [ "zfs-import-${zpool}.service" ];
|
||||
after = [ "zfs-import-${zpool}.service" ];
|
||||
requires = [ "zfs-import-${zpool}.service" ];
|
||||
};
|
||||
|
||||
# assert that the pool is even enabled
|
||||
assertions = [
|
||||
{
|
||||
assertion = builtins.elem zpool config.boot.zfs.extraPools;
|
||||
message = "${zpool} is not enabled in `boot.zfs.extraPools`";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
username,
|
||||
service_configs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.impermanence.nixosModules.impermanence
|
||||
];
|
||||
|
||||
environment.persistence."/persistent" = {
|
||||
hideMounts = true;
|
||||
directories = [
|
||||
"/var/log"
|
||||
"/var/lib/systemd/coredump"
|
||||
"/var/lib/nixos"
|
||||
|
||||
"/var/lib/systemd/timers"
|
||||
|
||||
# ZFS cache directory - persisting the directory instead of the file
|
||||
# avoids "device busy" errors when ZFS atomically updates the cache
|
||||
"/etc/zfs"
|
||||
];
|
||||
|
||||
files = [
|
||||
# Machine ID
|
||||
"/etc/machine-id"
|
||||
];
|
||||
|
||||
users.${username} = {
|
||||
files = [
|
||||
".local/share/fish/fish_history"
|
||||
];
|
||||
};
|
||||
|
||||
users.root = {
|
||||
files = [
|
||||
".local/share/fish/fish_history"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Store SSH host keys directly in /persistent to survive tmpfs root wipes.
|
||||
# This is more reliable than bind mounts for service-generated files.
|
||||
services.openssh.hostKeys = [
|
||||
{
|
||||
path = "/persistent/etc/ssh/ssh_host_ed25519_key";
|
||||
type = "ed25519";
|
||||
}
|
||||
{
|
||||
path = "/persistent/etc/ssh/ssh_host_rsa_key";
|
||||
type = "rsa";
|
||||
bits = 4096;
|
||||
}
|
||||
];
|
||||
|
||||
# Enforce root ownership on /persistent/etc. The impermanence activation
|
||||
# script copies ownership from /persistent/etc to /etc via
|
||||
# `chown --reference`. If /persistent/etc ever gets non-root ownership,
|
||||
# sshd StrictModes rejects /etc/ssh/authorized_keys.d/root and root SSH
|
||||
# breaks while non-root users still work.
|
||||
# Use "z" (set ownership, non-recursive) not "d" (create only, no-op on existing).
|
||||
systemd.tmpfiles.rules = [
|
||||
"z /persistent/etc 0755 root root"
|
||||
];
|
||||
}
|
||||
184
modules/lib.nix
184
modules/lib.nix
@@ -1,184 +0,0 @@
|
||||
{
|
||||
inputs,
|
||||
pkgs,
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
inputs.nixpkgs.lib.extend (
|
||||
final: prev:
|
||||
let
|
||||
lib = prev;
|
||||
in
|
||||
{
|
||||
# stolen from: https://stackoverflow.com/a/42398526
|
||||
optimizeWithFlags =
|
||||
pkg: flags:
|
||||
lib.overrideDerivation pkg (
|
||||
old:
|
||||
let
|
||||
newflags = lib.foldl' (acc: x: "${acc} ${x}") "" flags;
|
||||
oldflags = if (lib.hasAttr "NIX_CFLAGS_COMPILE" old) then "${old.NIX_CFLAGS_COMPILE}" else "";
|
||||
in
|
||||
{
|
||||
NIX_CFLAGS_COMPILE = "${oldflags} ${newflags}";
|
||||
# stdenv = pkgs.clang19Stdenv;
|
||||
}
|
||||
);
|
||||
|
||||
optimizePackage =
|
||||
pkg:
|
||||
final.optimizeWithFlags pkg [
|
||||
"-O3"
|
||||
"-march=${service_configs.cpu_arch}"
|
||||
"-mtune=${service_configs.cpu_arch}"
|
||||
];
|
||||
|
||||
vpnNamespaceOpenPort =
|
||||
port: service:
|
||||
{ ... }:
|
||||
{
|
||||
vpnNamespaces.wg = {
|
||||
portMappings = [
|
||||
{
|
||||
from = port;
|
||||
to = port;
|
||||
}
|
||||
];
|
||||
|
||||
openVPNPorts = [
|
||||
{
|
||||
port = port;
|
||||
protocol = "both";
|
||||
}
|
||||
];
|
||||
};
|
||||
systemd.services.${service}.vpnConfinement = {
|
||||
enable = true;
|
||||
vpnNamespace = "wg";
|
||||
};
|
||||
};
|
||||
|
||||
serviceMountWithZpool =
|
||||
serviceName: zpool: dirs:
|
||||
{ pkgs, config, ... }:
|
||||
{
|
||||
systemd.services."${serviceName}-mounts" = {
|
||||
wants = [ "zfs.target" ] ++ lib.optionals (zpool != "") [ "zfs-import-${zpool}.service" ];
|
||||
after = lib.optionals (zpool != "") [ "zfs-import-${zpool}.service" ];
|
||||
before = [ "${serviceName}.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = [
|
||||
(lib.getExe (
|
||||
pkgs.writeShellApplication {
|
||||
name = "ensure-zfs-mounts-with-pool-${serviceName}-${zpool}";
|
||||
runtimeInputs = with pkgs; [
|
||||
gawk
|
||||
coreutils
|
||||
config.boot.zfs.package
|
||||
];
|
||||
|
||||
text = ''
|
||||
set -euo pipefail
|
||||
|
||||
echo "Ensuring ZFS mounts for service: ${serviceName} (pool: ${zpool})"
|
||||
echo "Directories: ${lib.strings.concatStringsSep ", " dirs}"
|
||||
|
||||
# Validate mounts exist (ensureZfsMounts already has proper PATH)
|
||||
${lib.getExe pkgs.ensureZfsMounts} ${lib.strings.concatStringsSep " " dirs}
|
||||
|
||||
# Additional runtime check: verify paths are on correct zpool
|
||||
${lib.optionalString (zpool != "") ''
|
||||
echo "Verifying ZFS mountpoints are on pool '${zpool}'..."
|
||||
|
||||
if ! zfs_list_output=$(zfs list -H -o name,mountpoint 2>&1); then
|
||||
echo "ERROR: Failed to query ZFS datasets: $zfs_list_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2043
|
||||
for target in ${lib.strings.concatStringsSep " " dirs}; do
|
||||
echo "Checking: $target"
|
||||
|
||||
# Find dataset that has this mountpoint
|
||||
dataset=$(echo "$zfs_list_output" | awk -v target="$target" '$2 == target {print $1; exit}')
|
||||
|
||||
if [ -z "$dataset" ]; then
|
||||
echo "ERROR: No ZFS dataset found for mountpoint: $target" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract pool name from dataset (first part before /)
|
||||
actual_pool=$(echo "$dataset" | cut -d'/' -f1)
|
||||
|
||||
if [ "$actual_pool" != "${zpool}" ]; then
|
||||
echo "ERROR: ZFS pool mismatch for $target" >&2
|
||||
echo " Expected pool: ${zpool}" >&2
|
||||
echo " Actual pool: $actual_pool" >&2
|
||||
echo " Dataset: $dataset" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$target is on $dataset (pool: $actual_pool)"
|
||||
done
|
||||
|
||||
echo "All paths verified successfully on pool '${zpool}'"
|
||||
''}
|
||||
|
||||
echo "Mount validation completed for ${serviceName} (pool: ${zpool})"
|
||||
'';
|
||||
}
|
||||
))
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.${serviceName} = {
|
||||
wants = [
|
||||
"${serviceName}-mounts.service"
|
||||
];
|
||||
after = [
|
||||
"${serviceName}-mounts.service"
|
||||
];
|
||||
requires = [
|
||||
"${serviceName}-mounts.service"
|
||||
];
|
||||
};
|
||||
|
||||
# assert that the pool is even enabled
|
||||
#assertions = lib.optionals (zpool != "") [
|
||||
# {
|
||||
# assertion = builtins.elem zpool config.boot.zfs.extraPools;
|
||||
# message = "${zpool} is not enabled in `boot.zfs.extraPools`";
|
||||
# }
|
||||
#];
|
||||
};
|
||||
|
||||
serviceFilePerms =
|
||||
serviceName: tmpfilesRules:
|
||||
{ pkgs, ... }:
|
||||
let
|
||||
confFile = pkgs.writeText "${serviceName}-file-perms.conf" (
|
||||
lib.concatStringsSep "\n" tmpfilesRules
|
||||
);
|
||||
in
|
||||
{
|
||||
systemd.services."${serviceName}-file-perms" = {
|
||||
after = [ "${serviceName}-mounts.service" ];
|
||||
before = [ "${serviceName}.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${pkgs.systemd}/bin/systemd-tmpfiles --create ${confFile}";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.${serviceName} = {
|
||||
wants = [ "${serviceName}-file-perms.service" ];
|
||||
after = [ "${serviceName}-file-perms.service" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
systemd.services.no-rgb =
|
||||
let
|
||||
no-rgb = (
|
||||
pkgs.writeShellApplication {
|
||||
name = "no-rgb";
|
||||
runtimeInputs = with pkgs; [
|
||||
openrgb
|
||||
coreutils
|
||||
gnugrep
|
||||
];
|
||||
|
||||
text = ''
|
||||
# Retry loop to wait for hardware to be ready
|
||||
NUM_DEVICES=0
|
||||
for attempt in 1 2 3 4 5; do
|
||||
DEVICE_LIST=$(openrgb --noautoconnect --list-devices 2>/dev/null) || DEVICE_LIST=""
|
||||
NUM_DEVICES=$(echo "$DEVICE_LIST" | grep -cE '^[0-9]+: ') || NUM_DEVICES=0
|
||||
if [ "$NUM_DEVICES" -gt 0 ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -lt 5 ]; then
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
# If no devices found after retries, exit gracefully
|
||||
if [ "$NUM_DEVICES" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Disable RGB on each device
|
||||
for i in $(seq 0 $((NUM_DEVICES - 1))); do
|
||||
openrgb --noautoconnect --device "$i" --mode direct --color 000000 || true
|
||||
done
|
||||
'';
|
||||
}
|
||||
);
|
||||
in
|
||||
{
|
||||
description = "disable rgb";
|
||||
after = [ "systemd-udev-settle.service" ];
|
||||
serviceConfig = {
|
||||
ExecStart = lib.getExe no-rgb;
|
||||
Type = "oneshot";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
};
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
};
|
||||
|
||||
services.hardware.openrgb = {
|
||||
enable = true;
|
||||
package = pkgs.openrgb-with-all-plugins;
|
||||
motherboard = "amd";
|
||||
};
|
||||
|
||||
services.udev.packages = [ pkgs.openrgb-with-all-plugins ];
|
||||
hardware.i2c.enable = true;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
boot = {
|
||||
loader.systemd-boot.enable = lib.mkForce false;
|
||||
|
||||
lanzaboote = {
|
||||
enable = true;
|
||||
# needed to be in `/etc/secureboot` for sbctl to work
|
||||
pkiBundle = "/etc/secureboot";
|
||||
};
|
||||
|
||||
};
|
||||
system.activationScripts = {
|
||||
# extract secureboot keys from agenix-decrypted tar
|
||||
"secureboot-keys" = {
|
||||
deps = [ "agenix" ];
|
||||
text = ''
|
||||
#!/bin/sh
|
||||
# Check if keys already exist (e.g., from disko-install)
|
||||
if [[ -d ${config.boot.lanzaboote.pkiBundle} && -f ${config.boot.lanzaboote.pkiBundle}/db.key ]]; then
|
||||
echo "Secureboot keys already present, skipping extraction"
|
||||
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
|
||||
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
|
||||
else
|
||||
echo "Extracting secureboot keys from agenix"
|
||||
rm -fr ${config.boot.lanzaboote.pkiBundle} || true
|
||||
mkdir -p ${config.boot.lanzaboote.pkiBundle}
|
||||
${pkgs.gnutar}/bin/tar xf ${config.age.secrets.secureboot-tar.path} -C ${config.boot.lanzaboote.pkiBundle}
|
||||
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
|
||||
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
|
||||
fi
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
# memory allocator
|
||||
# BREAKS REDIS-IMMICH
|
||||
# environment.memoryAllocator.provider = "graphene-hardened";
|
||||
|
||||
# disable coredumps
|
||||
systemd.coredump.enable = false;
|
||||
|
||||
services = {
|
||||
dbus.implementation = "broker";
|
||||
/*
|
||||
logrotate.enable = true;
|
||||
journald = {
|
||||
storage = "volatile"; # Store logs in memory
|
||||
upload.enable = false; # Disable remote log upload (the default)
|
||||
extraConfig = ''
|
||||
SystemMaxUse=500M
|
||||
SystemMaxFileSize=50M
|
||||
'';
|
||||
};
|
||||
*/
|
||||
};
|
||||
|
||||
services.fail2ban = {
|
||||
enable = true;
|
||||
# Use iptables actions for compatibility
|
||||
banaction = "iptables-multiport";
|
||||
banaction-allports = "iptables-allports";
|
||||
};
|
||||
}
|
||||
54
no-rgb.nix
Normal file
54
no-rgb.nix
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
# mbedtls_2 is deprecated, override to fix build
|
||||
openrgb_package = pkgs.openrgb-with-all-plugins.override { mbedtls_2 = pkgs.mbedtls; };
|
||||
in
|
||||
|
||||
{
|
||||
systemd.services.no-rgb =
|
||||
let
|
||||
no-rgb = (
|
||||
pkgs.writeShellApplication {
|
||||
name = "no-rgb";
|
||||
runtimeInputs = with pkgs; [
|
||||
openrgb_package
|
||||
coreutils
|
||||
gnugrep
|
||||
];
|
||||
|
||||
text = ''
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
NUM_DEVICES=$(openrgb --noautoconnect --list-devices | grep -cE '^[0-9]+: ')
|
||||
|
||||
for i in $(seq 0 $((NUM_DEVICES - 1))); do
|
||||
openrgb --noautoconnect --device "$i" --mode direct --color 000000
|
||||
done
|
||||
'';
|
||||
}
|
||||
);
|
||||
in
|
||||
{
|
||||
description = "disable rgb";
|
||||
serviceConfig = {
|
||||
ExecStart = lib.getExe no-rgb;
|
||||
Type = "oneshot";
|
||||
};
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
};
|
||||
|
||||
services.hardware.openrgb = {
|
||||
enable = true;
|
||||
package = openrgb_package;
|
||||
motherboard = "amd";
|
||||
};
|
||||
|
||||
services.udev.packages = [ openrgb_package ];
|
||||
hardware.i2c.enable = true;
|
||||
}
|
||||
@@ -43,4 +43,16 @@ final: prev: {
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
list-usb-drives = prev.writeShellApplication {
|
||||
name = "list-usb-drives";
|
||||
runtimeInputs = with prev; [
|
||||
findutils
|
||||
coreutils
|
||||
];
|
||||
|
||||
text = ''
|
||||
find "/dev/disk/by-id" -name "usb*" -not -name "*-part[0-9]" -printf "%f\n" | sed 's/^usb\-//g' | sed 's/\-[0-9]*\:/ /g' | column -t --table-columns=DRIVE,BAY | sort -n -k 2
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DISK="${1:-}"
|
||||
FLAKE_DIR="$(dirname "$(realpath "$0")")"
|
||||
|
||||
if [[ -z "$DISK" ]]; then
|
||||
echo "Usage: $0 <disk_device>"
|
||||
echo "Example: $0 /dev/nvme0n1"
|
||||
echo " $0 /dev/sda"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -b "$DISK" ]]; then
|
||||
echo "Error: $DISK is not a block device"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing NixOS to $DISK using flake at $FLAKE_DIR"
|
||||
|
||||
# Create temporary directories
|
||||
mkdir -p /tmp/secureboot
|
||||
mkdir -p /tmp/persistent
|
||||
|
||||
# Function to cleanup on exit
|
||||
cleanup() {
|
||||
echo "Cleaning up..."
|
||||
rm -rf /tmp/secureboot 2>/dev/null || true
|
||||
rm -rf /tmp/persistent 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Decrypt secureboot keys using the key in the repo
|
||||
echo "Decrypting secureboot keys..."
|
||||
if [[ ! -f "$FLAKE_DIR/usb-secrets/usb-secrets-key" ]]; then
|
||||
echo "Error: usb-secrets-key not found at $FLAKE_DIR/usb-secrets/usb-secrets-key"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nix-shell -p age --run "age -d -i '$FLAKE_DIR/usb-secrets/usb-secrets-key' '$FLAKE_DIR/secrets/secureboot.tar.age'" | \
|
||||
tar -x -C /tmp/secureboot
|
||||
|
||||
echo "Secureboot keys extracted"
|
||||
|
||||
# Extract persistent partition secrets
|
||||
echo "Extracting persistent partition contents..."
|
||||
if [[ -f "$FLAKE_DIR/secrets/persistent.tar" ]]; then
|
||||
tar -xzf "$FLAKE_DIR/secrets/persistent.tar" -C /tmp/persistent
|
||||
echo "Persistent partition contents extracted"
|
||||
else
|
||||
echo "Warning: persistent.tar not found, skipping persistent secrets"
|
||||
fi
|
||||
|
||||
# Check if disko-install is available
|
||||
if ! command -v disko-install >/dev/null 2>&1; then
|
||||
echo "Running disko-install via nix..."
|
||||
DISKO_INSTALL="nix run github:nix-community/disko#disko-install --"
|
||||
else
|
||||
DISKO_INSTALL="disko-install"
|
||||
fi
|
||||
|
||||
echo "Running disko-install to partition, format, and install NixOS..."
|
||||
|
||||
# Build the extra-files arguments
|
||||
EXTRA_FILES_ARGS=(
|
||||
--extra-files /tmp/secureboot /etc/secureboot
|
||||
--extra-files "$FLAKE_DIR/usb-secrets/usb-secrets-key" /mnt/usb-secrets/usb-secrets-key
|
||||
)
|
||||
|
||||
# Add each top-level item from persistent separately to avoid nesting
|
||||
# cp -ar creates /dst/src when copying directories, so we need to copy each item
|
||||
#
|
||||
# Also disko-install actually copies the files from extra-files, so we are good here
|
||||
if [[ -d /tmp/persistent ]] && [[ -n "$(ls -A /tmp/persistent 2>/dev/null)" ]]; then
|
||||
for item in /tmp/persistent/*; do
|
||||
if [[ -e "$item" ]]; then
|
||||
basename=$(basename "$item")
|
||||
EXTRA_FILES_ARGS+=(--extra-files "$item" "/persistent/$basename")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Run disko-install with secureboot keys available
|
||||
sudo $DISKO_INSTALL \
|
||||
--mode format \
|
||||
--flake "$FLAKE_DIR#muffin" \
|
||||
--disk main "$DISK" \
|
||||
"${EXTRA_FILES_ARGS[@]}"
|
||||
22
secrets.nix
Normal file
22
secrets.nix
Normal file
@@ -0,0 +1,22 @@
|
||||
let
|
||||
# USB secrets key - for encrypting/decrypting all secrets
|
||||
usbSecretsKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8+eSX2LH5wEHVG9sSv97ceD5zdTarV0lRvoUso4A7p USB secrets decryption key";
|
||||
in
|
||||
{
|
||||
# ZFS encryption key
|
||||
"zfs-key.age".publicKeys = [ usbSecretsKey ];
|
||||
|
||||
# Secureboot keys archive
|
||||
"secureboot.tar.age".publicKeys = [ usbSecretsKey ];
|
||||
|
||||
# System passwords and auth
|
||||
"hashedPass.age".publicKeys = [ usbSecretsKey ];
|
||||
|
||||
# Service authentication
|
||||
"caddy_auth.age".publicKeys = [ usbSecretsKey ];
|
||||
"jellyfin-api-key.age".publicKeys = [ usbSecretsKey ];
|
||||
"slskd_env.age".publicKeys = [ usbSecretsKey ];
|
||||
|
||||
# Network configuration
|
||||
"wg0.conf.age".publicKeys = [ usbSecretsKey ];
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
33
secureboot.nix
Normal file
33
secureboot.nix
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
{
|
||||
boot = {
|
||||
loader.systemd-boot.enable = lib.mkForce false;
|
||||
|
||||
lanzaboote = {
|
||||
enable = true;
|
||||
# needed to be in `/etc/secureboot` for sbctl to work
|
||||
pkiBundle = "/etc/secureboot";
|
||||
};
|
||||
|
||||
};
|
||||
system.activationScripts = {
|
||||
# extract secureboot keys from agenix-decrypted tar
|
||||
"secureboot-keys" = {
|
||||
deps = [ "agenix" ];
|
||||
text = ''
|
||||
#!/bin/sh
|
||||
rm -fr ${config.boot.lanzaboote.pkiBundle} || true
|
||||
mkdir -p ${config.boot.lanzaboote.pkiBundle}
|
||||
${pkgs.gnutar}/bin/tar xf ${config.age.secrets.secureboot-tar.path} -C ${config.boot.lanzaboote.pkiBundle}
|
||||
chown -R root:wheel ${config.boot.lanzaboote.pkiBundle}
|
||||
chmod -R 500 ${config.boot.lanzaboote.pkiBundle}
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
config,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "bazarr" service_configs.zpool_ssds [
|
||||
service_configs.bazarr.dataDir
|
||||
])
|
||||
(lib.serviceMountWithZpool "bazarr" service_configs.zpool_hdds [
|
||||
service_configs.torrents_path
|
||||
])
|
||||
(lib.serviceFilePerms "bazarr" [
|
||||
"Z ${service_configs.bazarr.dataDir} 0700 ${config.services.bazarr.user} ${config.services.bazarr.group}"
|
||||
])
|
||||
];
|
||||
|
||||
services.bazarr = {
|
||||
enable = true;
|
||||
listenPort = service_configs.ports.bazarr;
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."bazarr.${service_configs.https.domain}".extraConfig = ''
|
||||
import ${config.age.secrets.caddy_auth.path}
|
||||
reverse_proxy :${builtins.toString service_configs.ports.bazarr}
|
||||
'';
|
||||
|
||||
users.users.${config.services.bazarr.user}.extraGroups = [
|
||||
service_configs.media_group
|
||||
];
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
{ config, service_configs, ... }:
|
||||
{
|
||||
services.arrInit = {
|
||||
prowlarr = {
|
||||
enable = true;
|
||||
serviceName = "prowlarr";
|
||||
port = service_configs.ports.prowlarr;
|
||||
dataDir = service_configs.prowlarr.dataDir;
|
||||
apiVersion = "v1";
|
||||
networkNamespacePath = "/run/netns/wg";
|
||||
syncedApps = [
|
||||
{
|
||||
name = "Sonarr";
|
||||
implementation = "Sonarr";
|
||||
configContract = "SonarrSettings";
|
||||
prowlarrUrl = "http://localhost:${builtins.toString service_configs.ports.prowlarr}";
|
||||
baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.sonarr}";
|
||||
apiKeyFrom = "${service_configs.sonarr.dataDir}/config.xml";
|
||||
syncCategories = [
|
||||
5000
|
||||
5010
|
||||
5020
|
||||
5030
|
||||
5040
|
||||
5045
|
||||
5050
|
||||
5090
|
||||
];
|
||||
serviceName = "sonarr";
|
||||
}
|
||||
{
|
||||
name = "Radarr";
|
||||
implementation = "Radarr";
|
||||
configContract = "RadarrSettings";
|
||||
prowlarrUrl = "http://localhost:${builtins.toString service_configs.ports.prowlarr}";
|
||||
baseUrl = "http://${config.vpnNamespaces.wg.bridgeAddress}:${builtins.toString service_configs.ports.radarr}";
|
||||
apiKeyFrom = "${service_configs.radarr.dataDir}/config.xml";
|
||||
syncCategories = [
|
||||
2000
|
||||
2010
|
||||
2020
|
||||
2030
|
||||
2040
|
||||
2045
|
||||
2050
|
||||
2060
|
||||
2070
|
||||
2080
|
||||
];
|
||||
serviceName = "radarr";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
sonarr = {
|
||||
enable = true;
|
||||
serviceName = "sonarr";
|
||||
port = service_configs.ports.sonarr;
|
||||
dataDir = service_configs.sonarr.dataDir;
|
||||
rootFolders = [ service_configs.media.tvDir ];
|
||||
downloadClients = [
|
||||
{
|
||||
name = "qBittorrent";
|
||||
implementation = "QBittorrent";
|
||||
configContract = "QBittorrentSettings";
|
||||
fields = {
|
||||
host = config.vpnNamespaces.wg.namespaceAddress;
|
||||
port = service_configs.ports.torrent;
|
||||
useSsl = false;
|
||||
tvCategory = "tvshows";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
radarr = {
|
||||
enable = true;
|
||||
serviceName = "radarr";
|
||||
port = service_configs.ports.radarr;
|
||||
dataDir = service_configs.radarr.dataDir;
|
||||
rootFolders = [ service_configs.media.moviesDir ];
|
||||
downloadClients = [
|
||||
{
|
||||
name = "qBittorrent";
|
||||
implementation = "QBittorrent";
|
||||
configContract = "QBittorrentSettings";
|
||||
fields = {
|
||||
host = config.vpnNamespaces.wg.namespaceAddress;
|
||||
port = service_configs.ports.torrent;
|
||||
useSsl = false;
|
||||
movieCategory = "movies";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
services.bazarrInit = {
|
||||
enable = true;
|
||||
dataDir = "/var/lib/bazarr";
|
||||
port = service_configs.ports.bazarr;
|
||||
sonarr = {
|
||||
enable = true;
|
||||
dataDir = service_configs.sonarr.dataDir;
|
||||
port = service_configs.ports.sonarr;
|
||||
serviceName = "sonarr";
|
||||
};
|
||||
radarr = {
|
||||
enable = true;
|
||||
dataDir = service_configs.radarr.dataDir;
|
||||
port = service_configs.ports.radarr;
|
||||
serviceName = "radarr";
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
config,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "jellyseerr" service_configs.zpool_ssds [
|
||||
service_configs.jellyseerr.configDir
|
||||
])
|
||||
(lib.serviceFilePerms "jellyseerr" [
|
||||
"Z ${service_configs.jellyseerr.configDir} 0700 jellyseerr jellyseerr"
|
||||
])
|
||||
];
|
||||
|
||||
services.jellyseerr = {
|
||||
enable = true;
|
||||
port = service_configs.ports.jellyseerr;
|
||||
configDir = service_configs.jellyseerr.configDir;
|
||||
};
|
||||
|
||||
systemd.services.jellyseerr.serviceConfig = {
|
||||
DynamicUser = lib.mkForce false;
|
||||
User = "jellyseerr";
|
||||
Group = "jellyseerr";
|
||||
ReadWritePaths = [ service_configs.jellyseerr.configDir ];
|
||||
};
|
||||
|
||||
users.users.jellyseerr = {
|
||||
isSystemUser = true;
|
||||
group = "jellyseerr";
|
||||
home = service_configs.jellyseerr.configDir;
|
||||
};
|
||||
|
||||
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.jellyseerr}
|
||||
'';
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
service_configs,
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "prowlarr" service_configs.zpool_ssds [
|
||||
service_configs.prowlarr.dataDir
|
||||
])
|
||||
(lib.vpnNamespaceOpenPort service_configs.ports.prowlarr "prowlarr")
|
||||
];
|
||||
|
||||
services.prowlarr = {
|
||||
enable = true;
|
||||
dataDir = service_configs.prowlarr.dataDir;
|
||||
settings.server.port = service_configs.ports.prowlarr;
|
||||
};
|
||||
|
||||
systemd.services.prowlarr.serviceConfig = {
|
||||
ExecStartPre = "+${pkgs.coreutils}/bin/chown -R prowlarr /var/lib/prowlarr";
|
||||
};
|
||||
|
||||
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.prowlarr}
|
||||
'';
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
config,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "radarr" service_configs.zpool_ssds [
|
||||
service_configs.radarr.dataDir
|
||||
])
|
||||
(lib.serviceMountWithZpool "radarr" service_configs.zpool_hdds [
|
||||
service_configs.torrents_path
|
||||
])
|
||||
(lib.serviceFilePerms "radarr" [
|
||||
"Z ${service_configs.radarr.dataDir} 0700 ${config.services.radarr.user} ${config.services.radarr.group}"
|
||||
])
|
||||
];
|
||||
|
||||
services.radarr = {
|
||||
enable = true;
|
||||
dataDir = service_configs.radarr.dataDir;
|
||||
settings.server.port = service_configs.ports.radarr;
|
||||
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.radarr}
|
||||
'';
|
||||
|
||||
users.users.${config.services.radarr.user}.extraGroups = [
|
||||
service_configs.media_group
|
||||
];
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
config,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
radarrConfig = "${service_configs.radarr.dataDir}/config.xml";
|
||||
sonarrConfig = "${service_configs.sonarr.dataDir}/config.xml";
|
||||
appDataDir = "${service_configs.recyclarr.dataDir}/data";
|
||||
|
||||
# Runs as root (via + prefix) to read API keys, writes secrets.yml for recyclarr
|
||||
generateSecrets = pkgs.writeShellScript "recyclarr-generate-secrets" ''
|
||||
RADARR_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<ApiKey>)[^<]+' ${radarrConfig})
|
||||
SONARR_KEY=$(${pkgs.gnugrep}/bin/grep -oP '(?<=<ApiKey>)[^<]+' ${sonarrConfig})
|
||||
cat > ${appDataDir}/secrets.yml <<EOF
|
||||
movies_api_key: $RADARR_KEY
|
||||
series_api_key: $SONARR_KEY
|
||||
EOF
|
||||
chown recyclarr:recyclarr ${appDataDir}/secrets.yml
|
||||
chmod 600 ${appDataDir}/secrets.yml
|
||||
'';
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "recyclarr" service_configs.zpool_ssds [
|
||||
service_configs.recyclarr.dataDir
|
||||
])
|
||||
];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${service_configs.recyclarr.dataDir} 0755 recyclarr recyclarr -"
|
||||
"d ${appDataDir} 0755 recyclarr recyclarr -"
|
||||
];
|
||||
|
||||
services.recyclarr = {
|
||||
enable = true;
|
||||
command = "sync";
|
||||
schedule = "daily";
|
||||
user = "recyclarr";
|
||||
group = "recyclarr";
|
||||
|
||||
configuration = {
|
||||
radarr.movies = {
|
||||
base_url = "http://localhost:${builtins.toString service_configs.ports.radarr}";
|
||||
|
||||
include = [
|
||||
{ template = "radarr-quality-definition-movie"; }
|
||||
{ template = "radarr-quality-profile-remux-web-2160p"; }
|
||||
{ template = "radarr-custom-formats-remux-web-2160p"; }
|
||||
];
|
||||
|
||||
quality_profiles = [
|
||||
{
|
||||
name = "Remux + WEB 2160p";
|
||||
upgrade = {
|
||||
allowed = true;
|
||||
until_quality = "Remux-2160p";
|
||||
};
|
||||
qualities = [
|
||||
{ name = "Remux-2160p"; }
|
||||
{
|
||||
name = "WEB 2160p";
|
||||
qualities = [
|
||||
"WEBDL-2160p"
|
||||
"WEBRip-2160p"
|
||||
];
|
||||
}
|
||||
{ name = "Remux-1080p"; }
|
||||
{ name = "Bluray-1080p"; }
|
||||
{
|
||||
name = "WEB 1080p";
|
||||
qualities = [
|
||||
"WEBDL-1080p"
|
||||
"WEBRip-1080p"
|
||||
];
|
||||
}
|
||||
{ name = "HDTV-1080p"; }
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
custom_formats = [
|
||||
# Upscaled
|
||||
{
|
||||
trash_ids = [ "bfd8eb01832d646a0a89c4deb46f8564" ];
|
||||
assign_scores_to = [
|
||||
{
|
||||
name = "Remux + WEB 2160p";
|
||||
score = -10000;
|
||||
}
|
||||
];
|
||||
}
|
||||
# x265 (HD) - override template -10000 penalty
|
||||
{
|
||||
trash_ids = [ "dc98083864ea246d05a42df0d05f81cc" ];
|
||||
assign_scores_to = [
|
||||
{
|
||||
name = "Remux + WEB 2160p";
|
||||
score = 0;
|
||||
}
|
||||
];
|
||||
}
|
||||
# x265 (no HDR/DV) - override template -10000 penalty
|
||||
{
|
||||
trash_ids = [ "839bea857ed2c0a8e084f3cbdbd65ecb" ];
|
||||
assign_scores_to = [
|
||||
{
|
||||
name = "Remux + WEB 2160p";
|
||||
score = 0;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
sonarr.series = {
|
||||
base_url = "http://localhost:${builtins.toString service_configs.ports.sonarr}";
|
||||
|
||||
include = [
|
||||
{ template = "sonarr-quality-definition-series"; }
|
||||
{ template = "sonarr-v4-quality-profile-web-2160p"; }
|
||||
{ template = "sonarr-v4-custom-formats-web-2160p"; }
|
||||
];
|
||||
|
||||
quality_profiles = [
|
||||
{
|
||||
name = "WEB-2160p";
|
||||
upgrade = {
|
||||
allowed = true;
|
||||
until_quality = "WEB 2160p";
|
||||
};
|
||||
qualities = [
|
||||
{
|
||||
name = "WEB 2160p";
|
||||
qualities = [
|
||||
"WEBDL-2160p"
|
||||
"WEBRip-2160p"
|
||||
];
|
||||
}
|
||||
{ name = "Bluray-1080p Remux"; }
|
||||
{ name = "Bluray-1080p"; }
|
||||
{
|
||||
name = "WEB 1080p";
|
||||
qualities = [
|
||||
"WEBDL-1080p"
|
||||
"WEBRip-1080p"
|
||||
];
|
||||
}
|
||||
{ name = "HDTV-1080p"; }
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
custom_formats = [
|
||||
# Upscaled
|
||||
{
|
||||
trash_ids = [ "23297a736ca77c0fc8e70f8edd7ee56c" ];
|
||||
assign_scores_to = [
|
||||
{
|
||||
name = "WEB-2160p";
|
||||
score = -10000;
|
||||
}
|
||||
];
|
||||
}
|
||||
# x265 (HD) - override template -10000 penalty
|
||||
{
|
||||
trash_ids = [ "47435ece6b99a0b477caf360e79ba0bb" ];
|
||||
assign_scores_to = [
|
||||
{
|
||||
name = "WEB-2160p";
|
||||
score = 0;
|
||||
}
|
||||
];
|
||||
}
|
||||
# x265 (no HDR/DV) - override template -10000 penalty
|
||||
{
|
||||
trash_ids = [ "9b64dff695c2115facf1b6ea59c9bd07" ];
|
||||
assign_scores_to = [
|
||||
{
|
||||
name = "WEB-2160p";
|
||||
score = 0;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Add secrets generation before recyclarr runs
|
||||
systemd.services.recyclarr = {
|
||||
after = [
|
||||
"network-online.target"
|
||||
"radarr.service"
|
||||
"sonarr.service"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
serviceConfig.ExecStartPre = "+${generateSecrets}";
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
config,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "sonarr" service_configs.zpool_ssds [
|
||||
service_configs.sonarr.dataDir
|
||||
])
|
||||
(lib.serviceMountWithZpool "sonarr" service_configs.zpool_hdds [
|
||||
service_configs.torrents_path
|
||||
])
|
||||
(lib.serviceFilePerms "sonarr" [
|
||||
"Z ${service_configs.sonarr.dataDir} 0700 ${config.services.sonarr.user} ${config.services.sonarr.group}"
|
||||
])
|
||||
];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /torrents/media 2775 root ${service_configs.media_group} -"
|
||||
"d ${service_configs.media.tvDir} 2775 root ${service_configs.media_group} -"
|
||||
"d ${service_configs.media.moviesDir} 2775 root ${service_configs.media_group} -"
|
||||
];
|
||||
|
||||
services.sonarr = {
|
||||
enable = true;
|
||||
dataDir = service_configs.sonarr.dataDir;
|
||||
settings.server.port = service_configs.ports.sonarr;
|
||||
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.sonarr}
|
||||
'';
|
||||
|
||||
users.users.${config.services.sonarr.user}.extraGroups = [
|
||||
service_configs.media_group
|
||||
];
|
||||
}
|
||||
@@ -26,6 +26,6 @@
|
||||
|
||||
services.caddy.virtualHosts."bitmagnet.${service_configs.https.domain}".extraConfig = ''
|
||||
import ${config.age.secrets.caddy_auth.path}
|
||||
reverse_proxy ${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.bitmagnet}
|
||||
reverse_proxy ${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.bitmagnet}
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -7,18 +7,16 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "vaultwarden" service_configs.zpool_ssds [
|
||||
(lib.serviceMountDeps "vaultwarden" [
|
||||
service_configs.vaultwarden.path
|
||||
config.services.vaultwarden.backupDir
|
||||
])
|
||||
(lib.serviceMountWithZpool "backup-vaultwarden" service_configs.zpool_ssds [
|
||||
(lib.serviceMountDeps "backup-vaultwarden" [
|
||||
service_configs.vaultwarden.path
|
||||
config.services.vaultwarden.backupDir
|
||||
])
|
||||
(lib.serviceFilePerms "vaultwarden" [
|
||||
"Z ${service_configs.vaultwarden.path} 0700 vaultwarden vaultwarden"
|
||||
"Z ${config.services.vaultwarden.backupDir} 0700 vaultwarden vaultwarden"
|
||||
])
|
||||
(lib.serviceDependZpool "vaultwarden" service_configs.zpool_ssds)
|
||||
(lib.serviceDependZpool "backup-vaultwarden" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
services.vaultwarden = {
|
||||
@@ -43,18 +41,8 @@
|
||||
}
|
||||
'';
|
||||
|
||||
# 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";
|
||||
};
|
||||
};
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z ${service_configs.vaultwarden.path} 0700 vaultwarden vaultwarden"
|
||||
"Z ${config.services.vaultwarden.backupDir} 0700 vaultwarden vaultwarden"
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,9 +44,10 @@ let
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "caddy" service_configs.zpool_ssds [
|
||||
(lib.serviceMountDeps "caddy" [
|
||||
config.services.caddy.dataDir
|
||||
])
|
||||
(lib.serviceDependZpool "caddy" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
services.caddy = {
|
||||
@@ -74,34 +75,10 @@ in
|
||||
service_configs.ports.https
|
||||
|
||||
# http (but really acmeCA challenges)
|
||||
service_configs.ports.http
|
||||
80
|
||||
];
|
||||
|
||||
networking.firewall.allowedUDPPorts = [
|
||||
service_configs.ports.https
|
||||
];
|
||||
|
||||
# Protect Caddy basic auth endpoints from brute force attacks
|
||||
services.fail2ban.jails.caddy-auth = {
|
||||
enabled = true;
|
||||
settings = {
|
||||
backend = "auto";
|
||||
port = "http,https";
|
||||
logpath = "/var/log/caddy/access-*.log";
|
||||
# defaults: maxretry=5, findtime=10m, bantime=10m
|
||||
|
||||
# Ignore local network IPs - NAT hairpinning causes all LAN traffic to
|
||||
# appear from the router IP (192.168.1.1). Banning it blocks all internal access.
|
||||
ignoreip = "127.0.0.1/8 ::1 192.168.1.0/24";
|
||||
};
|
||||
filter.Definition = {
|
||||
# Only match 401s where an Authorization header was actually sent.
|
||||
# Without this, the normal HTTP Basic Auth challenge-response flow
|
||||
# (browser probes without credentials, gets 401, then resends with
|
||||
# credentials) counts every page visit as a "failure."
|
||||
failregex = ''^.*"remote_ip":"<HOST>".*"Authorization":\["REDACTED"\].*"status":401.*$'';
|
||||
ignoreregex = "";
|
||||
datepattern = ''"ts":{Epoch}\.'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
services.coturn = {
|
||||
enable = true;
|
||||
realm = service_configs.https.domain;
|
||||
use-auth-secret = true;
|
||||
static-auth-secret = lib.strings.trim (builtins.readFile ../secrets/coturn_static_auth_secret);
|
||||
listening-port = service_configs.ports.coturn;
|
||||
tls-listening-port = service_configs.ports.coturn_tls;
|
||||
no-cli = true;
|
||||
|
||||
# recommended security settings from Synapse's coturn docs
|
||||
extraConfig = ''
|
||||
denied-peer-ip=10.0.0.0-10.255.255.255
|
||||
denied-peer-ip=192.168.0.0-192.168.255.255
|
||||
denied-peer-ip=172.16.0.0-172.31.255.255
|
||||
denied-peer-ip=0.0.0.0-0.255.255.255
|
||||
denied-peer-ip=100.64.0.0-100.127.255.255
|
||||
denied-peer-ip=169.254.0.0-169.254.255.255
|
||||
denied-peer-ip=192.0.0.0-192.0.0.255
|
||||
denied-peer-ip=198.18.0.0-198.19.255.255
|
||||
denied-peer-ip=198.51.100.0-198.51.100.255
|
||||
denied-peer-ip=203.0.113.0-203.0.113.255
|
||||
denied-peer-ip=240.0.0.0-255.255.255.255
|
||||
denied-peer-ip=::1
|
||||
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
|
||||
denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255
|
||||
denied-peer-ip=100::-100::ffff:ffff:ffff:ffff
|
||||
denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
|
||||
'';
|
||||
};
|
||||
|
||||
# coturn needs these ports open
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [
|
||||
service_configs.ports.coturn
|
||||
service_configs.ports.coturn_tls
|
||||
];
|
||||
allowedUDPPorts = [
|
||||
service_configs.ports.coturn
|
||||
service_configs.ports.coturn_tls
|
||||
];
|
||||
# relay port range
|
||||
allowedUDPPortRanges = [
|
||||
{
|
||||
from = config.services.coturn.min-port;
|
||||
to = config.services.coturn.max-port;
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,8 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "gitea" service_configs.zpool_ssds [ config.services.gitea.stateDir ])
|
||||
(lib.serviceFilePerms "gitea" [
|
||||
"Z ${config.services.gitea.stateDir} 0700 ${config.services.gitea.user} ${config.services.gitea.group}"
|
||||
])
|
||||
(lib.serviceMountDeps "gitea" [ config.services.gitea.stateDir ])
|
||||
(lib.serviceDependZpool "gitea" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
services.gitea = {
|
||||
@@ -44,6 +42,11 @@
|
||||
reverse_proxy :${builtins.toString config.services.gitea.settings.server.HTTP_PORT}
|
||||
'';
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
# 0700 for ssh permission reasons
|
||||
"Z ${config.services.gitea.stateDir} 0700 ${config.services.gitea.user} ${config.services.gitea.group}"
|
||||
];
|
||||
|
||||
services.postgresql = {
|
||||
ensureDatabases = [ config.services.gitea.user ];
|
||||
ensureUsers = [
|
||||
@@ -56,19 +59,4 @@
|
||||
};
|
||||
|
||||
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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
service_configs,
|
||||
inputs,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
graphing-calculator =
|
||||
inputs.ytbn-graphing-software.packages.${pkgs.stdenv.targetPlatform.system}.web;
|
||||
in
|
||||
{
|
||||
services.caddy.virtualHosts."graphing.${service_configs.https.domain}".extraConfig = ''
|
||||
root * ${graphing-calculator}
|
||||
file_server browse
|
||||
'';
|
||||
}
|
||||
@@ -7,15 +7,10 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "immich-server" service_configs.zpool_ssds [
|
||||
config.services.immich.mediaLocation
|
||||
])
|
||||
(lib.serviceMountWithZpool "immich-machine-learning" service_configs.zpool_ssds [
|
||||
config.services.immich.mediaLocation
|
||||
])
|
||||
(lib.serviceFilePerms "immich-server" [
|
||||
"Z ${config.services.immich.mediaLocation} 0770 ${config.services.immich.user} ${config.services.immich.group}"
|
||||
])
|
||||
(lib.serviceMountDeps "immich-server" [ config.services.immich.mediaLocation ])
|
||||
(lib.serviceMountDeps "immich-machine-learning" [ config.services.immich.mediaLocation ])
|
||||
(lib.serviceDependZpool "immich-server" service_configs.zpool_ssds)
|
||||
(lib.serviceDependZpool "immich-machine-learning" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
services.immich = {
|
||||
@@ -33,6 +28,10 @@
|
||||
reverse_proxy :${builtins.toString config.services.immich.port}
|
||||
'';
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z ${config.services.immich.mediaLocation} 0770 ${config.services.immich.user} ${config.services.immich.group}"
|
||||
];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
immich-go
|
||||
];
|
||||
@@ -41,19 +40,4 @@
|
||||
"video"
|
||||
"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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
pkgs,
|
||||
service_configs,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
systemd.services."jellyfin-qbittorrent-monitor" = {
|
||||
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
||||
after = [
|
||||
"network.target"
|
||||
"jellyfin.service"
|
||||
"qbittorrent.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" ''
|
||||
export JELLYFIN_API_KEY=$(cat $CREDENTIALS_DIRECTORY/jellyfin-api-key)
|
||||
exec ${
|
||||
pkgs.python3.withPackages (ps: with ps; [ requests ])
|
||||
}/bin/python ${./jellyfin-qbittorrent-monitor.py}
|
||||
'';
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
|
||||
# Security hardening
|
||||
DynamicUser = true;
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
RemoveIPC = true;
|
||||
|
||||
# Load credentials from agenix secrets
|
||||
LoadCredential = "jellyfin-api-key:${config.age.secrets.jellyfin-api-key.path}";
|
||||
};
|
||||
|
||||
environment = {
|
||||
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
|
||||
QBITTORRENT_URL = "http://${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString service_configs.ports.torrent}";
|
||||
CHECK_INTERVAL = "30";
|
||||
# Bandwidth budget configuration
|
||||
TOTAL_BANDWIDTH_BUDGET = "30000000"; # 30 Mbps in bits per second
|
||||
SERVICE_BUFFER = "5000000"; # 5 Mbps reserved for other services (bps)
|
||||
DEFAULT_STREAM_BITRATE = "10000000"; # 10 Mbps fallback when bitrate unknown (bps)
|
||||
MIN_TORRENT_SPEED = "100"; # KB/s - below this, pause torrents instead
|
||||
STREAM_BITRATE_HEADROOM = "1.1"; # multiplier per stream for bitrate fluctuations
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -14,12 +14,6 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServiceUnavailable(Exception):
|
||||
"""Raised when a monitored service is temporarily unavailable."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinQBittorrentMonitor:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -27,34 +21,20 @@ class JellyfinQBittorrentMonitor:
|
||||
qbittorrent_url="http://localhost:8080",
|
||||
check_interval=30,
|
||||
jellyfin_api_key=None,
|
||||
streaming_start_delay=10,
|
||||
streaming_stop_delay=60,
|
||||
total_bandwidth_budget=30000000,
|
||||
service_buffer=5000000,
|
||||
default_stream_bitrate=10000000,
|
||||
min_torrent_speed=100,
|
||||
stream_bitrate_headroom=1.1,
|
||||
):
|
||||
self.jellyfin_url = jellyfin_url
|
||||
self.qbittorrent_url = qbittorrent_url
|
||||
self.check_interval = check_interval
|
||||
self.jellyfin_api_key = jellyfin_api_key
|
||||
self.total_bandwidth_budget = total_bandwidth_budget
|
||||
self.service_buffer = service_buffer
|
||||
self.default_stream_bitrate = default_stream_bitrate
|
||||
self.min_torrent_speed = min_torrent_speed
|
||||
self.stream_bitrate_headroom = stream_bitrate_headroom
|
||||
self.last_streaming_state = None
|
||||
self.current_state = "unlimited"
|
||||
self.torrents_paused = False
|
||||
self.last_alt_limits = None
|
||||
self.throttle_active = False
|
||||
self.running = True
|
||||
self.session = requests.Session() # Use session for cookies
|
||||
self.last_active_streams = []
|
||||
|
||||
# Hysteresis settings to prevent rapid switching
|
||||
self.streaming_start_delay = streaming_start_delay
|
||||
self.streaming_stop_delay = streaming_stop_delay
|
||||
self.streaming_start_delay = 10
|
||||
self.streaming_stop_delay = 60
|
||||
self.last_state_change = 0
|
||||
|
||||
# Local network ranges (RFC 1918 private networks + localhost)
|
||||
@@ -82,57 +62,51 @@ class JellyfinQBittorrentMonitor:
|
||||
self.restore_normal_limits()
|
||||
sys.exit(0)
|
||||
|
||||
def check_jellyfin_sessions(self) -> list[dict]:
|
||||
headers = (
|
||||
{"X-Emby-Token": self.jellyfin_api_key} if self.jellyfin_api_key else {}
|
||||
)
|
||||
|
||||
def check_jellyfin_sessions(self) -> list[str]:
|
||||
"""Check if anyone is actively streaming from Jellyfin (external networks only)"""
|
||||
try:
|
||||
headers = {}
|
||||
if self.jellyfin_api_key:
|
||||
headers["X-Emby-Token"] = self.jellyfin_api_key
|
||||
|
||||
response = requests.get(
|
||||
f"{self.jellyfin_url}/Sessions", headers=headers, timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
sessions = response.json()
|
||||
|
||||
# Count active streaming sessions (video only, external networks only)
|
||||
active_streams = []
|
||||
for session in sessions:
|
||||
if (
|
||||
"NowPlayingItem" in session
|
||||
and session.get("PlayState", {}).get("IsPaused", True) == False
|
||||
):
|
||||
# Check if session is from external network
|
||||
remote_endpoint = session.get("RemoteEndPoint", "")
|
||||
if remote_endpoint and self.is_local_ip(remote_endpoint):
|
||||
logger.debug(f"Skipping local session from {remote_endpoint}")
|
||||
continue
|
||||
|
||||
item = session["NowPlayingItem"]
|
||||
# Only count video streams (Movies, Episodes, etc.)
|
||||
item_type = item.get("Type", "").lower()
|
||||
if item_type in ["movie", "episode", "video"]:
|
||||
user = session.get("UserName", "Unknown")
|
||||
client_info = f" (from {remote_endpoint})" if remote_endpoint else ""
|
||||
active_streams.append(f"{user}: {item.get('Name', 'Unknown')}{client_info}")
|
||||
|
||||
return active_streams
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to check Jellyfin sessions: {e}")
|
||||
raise ServiceUnavailable(f"Jellyfin unavailable: {e}") from e
|
||||
|
||||
try:
|
||||
sessions = response.json()
|
||||
return []
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse Jellyfin response: {e}")
|
||||
raise ServiceUnavailable(f"Jellyfin returned invalid JSON: {e}") from e
|
||||
return []
|
||||
|
||||
active_streams = []
|
||||
for session in sessions:
|
||||
if (
|
||||
"NowPlayingItem" in session
|
||||
and not session.get("PlayState", {}).get("IsPaused", True)
|
||||
and not self.is_local_ip(session.get("RemoteEndPoint", ""))
|
||||
):
|
||||
item = session["NowPlayingItem"]
|
||||
item_type = item.get("Type", "").lower()
|
||||
if item_type in ["movie", "episode", "video"]:
|
||||
user = session.get("UserName", "Unknown")
|
||||
stream_name = f"{user}: {item.get('Name', 'Unknown')}"
|
||||
if session.get("TranscodingInfo") and session[
|
||||
"TranscodingInfo"
|
||||
].get("Bitrate"):
|
||||
bitrate = session["TranscodingInfo"]["Bitrate"]
|
||||
elif item.get("Bitrate"):
|
||||
bitrate = item["Bitrate"]
|
||||
elif item.get("MediaSources", [{}])[0].get("Bitrate"):
|
||||
bitrate = item["MediaSources"][0]["Bitrate"]
|
||||
else:
|
||||
bitrate = self.default_stream_bitrate
|
||||
|
||||
bitrate = min(int(bitrate), 100_000_000)
|
||||
# Add headroom to account for bitrate fluctuations
|
||||
bitrate = int(bitrate * self.stream_bitrate_headroom)
|
||||
active_streams.append({"name": stream_name, "bitrate_bps": bitrate})
|
||||
|
||||
return active_streams
|
||||
|
||||
def check_qbittorrent_alternate_limits(self) -> bool:
|
||||
def check_qbittorrent_alternate_limits(self):
|
||||
"""Check if alternate speed limits are currently enabled"""
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{self.qbittorrent_url}/api/v2/transfer/speedLimitsMode", timeout=10
|
||||
@@ -143,20 +117,21 @@ class JellyfinQBittorrentMonitor:
|
||||
logger.warning(
|
||||
f"SpeedLimitsMode endpoint returned HTTP {response.status_code}"
|
||||
)
|
||||
raise ServiceUnavailable(
|
||||
f"qBittorrent returned HTTP {response.status_code}"
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"SpeedLimitsMode endpoint failed: {e}")
|
||||
raise ServiceUnavailable(f"qBittorrent unavailable: {e}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse speedLimitsMode response: {e}")
|
||||
return self.throttle_active
|
||||
|
||||
def use_alt_limits(self, enable: bool) -> None:
|
||||
"""Toggle qBittorrent alternate speed limits"""
|
||||
action = "enabled" if enable else "disabled"
|
||||
try:
|
||||
current_throttle = self.check_qbittorrent_alternate_limits()
|
||||
|
||||
if current_throttle == enable:
|
||||
logger.debug(
|
||||
logger.info(
|
||||
f"Alternate speed limits already {action}, no action needed"
|
||||
)
|
||||
return
|
||||
@@ -166,93 +141,28 @@ class JellyfinQBittorrentMonitor:
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
self.throttle_active = enable
|
||||
|
||||
# Verify the change took effect
|
||||
new_state = self.check_qbittorrent_alternate_limits()
|
||||
if new_state == enable:
|
||||
logger.info(f"Alternate speed limits {action}")
|
||||
logger.info(f"Activated {action} alternate speed limits")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Toggle may have failed: expected {enable}, got {new_state}"
|
||||
)
|
||||
|
||||
except ServiceUnavailable:
|
||||
logger.warning(
|
||||
f"qBittorrent unavailable, cannot {action} alternate speed limits"
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to {action} alternate speed limits: {e}")
|
||||
|
||||
def pause_all_torrents(self) -> None:
|
||||
try:
|
||||
response = self.session.post(
|
||||
f"{self.qbittorrent_url}/api/v2/torrents/stop",
|
||||
data={"hashes": "all"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to pause torrents: {e}")
|
||||
|
||||
def resume_all_torrents(self) -> None:
|
||||
try:
|
||||
response = self.session.post(
|
||||
f"{self.qbittorrent_url}/api/v2/torrents/start",
|
||||
data={"hashes": "all"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to resume torrents: {e}")
|
||||
|
||||
def set_alt_speed_limits(self, dl_kbs: float, ul_kbs: float) -> None:
|
||||
try:
|
||||
payload = {
|
||||
"alt_dl_limit": int(dl_kbs * 1024),
|
||||
"alt_up_limit": int(ul_kbs * 1024),
|
||||
}
|
||||
response = self.session.post(
|
||||
f"{self.qbittorrent_url}/api/v2/app/setPreferences",
|
||||
data={"json": json.dumps(payload)},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
self.last_alt_limits = (dl_kbs, ul_kbs)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to set alternate speed limits: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle qBittorrent limits: {e}")
|
||||
|
||||
def restore_normal_limits(self) -> None:
|
||||
if self.torrents_paused:
|
||||
logger.info("Resuming all torrents before shutdown...")
|
||||
self.resume_all_torrents()
|
||||
self.torrents_paused = False
|
||||
|
||||
if self.current_state != "unlimited":
|
||||
"""Ensure normal speed limits are restored on shutdown"""
|
||||
if self.throttle_active:
|
||||
logger.info("Restoring normal speed limits before shutdown...")
|
||||
self.use_alt_limits(False)
|
||||
self.current_state = "unlimited"
|
||||
|
||||
def sync_qbittorrent_state(self) -> None:
|
||||
try:
|
||||
if self.current_state == "unlimited":
|
||||
actual_state = self.check_qbittorrent_alternate_limits()
|
||||
if actual_state:
|
||||
logger.warning(
|
||||
"qBittorrent state mismatch detected: expected alt speed OFF, got ON. Re-syncing..."
|
||||
)
|
||||
self.use_alt_limits(False)
|
||||
elif self.current_state == "throttled":
|
||||
if self.last_alt_limits:
|
||||
self.set_alt_speed_limits(*self.last_alt_limits)
|
||||
actual_state = self.check_qbittorrent_alternate_limits()
|
||||
if not actual_state:
|
||||
logger.warning(
|
||||
"qBittorrent state mismatch detected: expected alt speed ON, got OFF. Re-syncing..."
|
||||
)
|
||||
self.use_alt_limits(True)
|
||||
elif self.current_state == "paused":
|
||||
self.pause_all_torrents()
|
||||
self.torrents_paused = True
|
||||
except ServiceUnavailable:
|
||||
pass
|
||||
self.use_alt_limits(False)
|
||||
|
||||
def should_change_state(self, new_streaming_state: bool) -> bool:
|
||||
"""Apply hysteresis to prevent rapid state changes"""
|
||||
@@ -263,6 +173,7 @@ class JellyfinQBittorrentMonitor:
|
||||
|
||||
time_since_change = now - self.last_state_change
|
||||
|
||||
# Start throttling (streaming started)
|
||||
if new_streaming_state and not self.last_streaming_state:
|
||||
if time_since_change >= self.streaming_start_delay:
|
||||
self.last_state_change = now
|
||||
@@ -270,9 +181,10 @@ class JellyfinQBittorrentMonitor:
|
||||
else:
|
||||
remaining = self.streaming_start_delay - time_since_change
|
||||
logger.info(
|
||||
f"Streaming started - waiting {remaining:.1f}s before enforcing limits"
|
||||
f"Streaming started - waiting {remaining:.1f}s before enabling throttling"
|
||||
)
|
||||
|
||||
# Stop throttling (streaming stopped)
|
||||
elif not new_streaming_state and self.last_streaming_state:
|
||||
if time_since_change >= self.streaming_stop_delay:
|
||||
self.last_state_change = now
|
||||
@@ -280,119 +192,42 @@ class JellyfinQBittorrentMonitor:
|
||||
else:
|
||||
remaining = self.streaming_stop_delay - time_since_change
|
||||
logger.info(
|
||||
f"Streaming stopped - waiting {remaining:.1f}s before restoring unlimited mode"
|
||||
f"Streaming stopped - waiting {remaining:.1f}s before disabling throttling"
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
def run(self):
|
||||
"""Main monitoring loop"""
|
||||
logger.info("Starting Jellyfin-qBittorrent monitor")
|
||||
logger.info(f"Jellyfin URL: {self.jellyfin_url}")
|
||||
logger.info(f"qBittorrent URL: {self.qbittorrent_url}")
|
||||
logger.info(f"Check interval: {self.check_interval}s")
|
||||
logger.info(f"Streaming start delay: {self.streaming_start_delay}s")
|
||||
logger.info(f"Streaming stop delay: {self.streaming_stop_delay}s")
|
||||
logger.info(f"Total bandwidth budget: {self.total_bandwidth_budget} bps")
|
||||
logger.info(f"Service buffer: {self.service_buffer} 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"Stream bitrate headroom: {self.stream_bitrate_headroom}x")
|
||||
|
||||
# Set up signal handlers
|
||||
signal.signal(signal.SIGINT, self.signal_handler)
|
||||
signal.signal(signal.SIGTERM, self.signal_handler)
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
self.sync_qbittorrent_state()
|
||||
|
||||
try:
|
||||
active_streams = self.check_jellyfin_sessions()
|
||||
except ServiceUnavailable:
|
||||
logger.warning("Jellyfin unavailable, maintaining current state")
|
||||
time.sleep(self.check_interval)
|
||||
continue
|
||||
|
||||
# Check for active streaming
|
||||
active_streams = self.check_jellyfin_sessions()
|
||||
streaming_active = len(active_streams) > 0
|
||||
|
||||
if active_streams:
|
||||
for stream in active_streams:
|
||||
logger.debug(
|
||||
f"Active stream: {stream['name']} ({stream['bitrate_bps']} bps)"
|
||||
)
|
||||
|
||||
if active_streams != self.last_active_streams:
|
||||
# Log current status
|
||||
if streaming_active:
|
||||
stream_names = ", ".join(
|
||||
stream["name"] for stream in active_streams
|
||||
)
|
||||
logger.info(
|
||||
f"Active streams ({len(active_streams)}): {stream_names}"
|
||||
f"Active streams ({len(active_streams)}): {', '.join(active_streams)}"
|
||||
)
|
||||
elif len(active_streams) == 0 and self.last_streaming_state:
|
||||
logger.info("No active streaming sessions")
|
||||
|
||||
# Apply hysteresis and change state if needed
|
||||
if self.should_change_state(streaming_active):
|
||||
self.last_streaming_state = streaming_active
|
||||
self.use_alt_limits(streaming_active)
|
||||
|
||||
streaming_state = bool(self.last_streaming_state)
|
||||
total_streaming_bps = sum(
|
||||
stream["bitrate_bps"] for stream in active_streams
|
||||
)
|
||||
remaining_bps = (
|
||||
self.total_bandwidth_budget
|
||||
- self.service_buffer
|
||||
- total_streaming_bps
|
||||
)
|
||||
remaining_kbs = max(0, remaining_bps) / 8 / 1024
|
||||
|
||||
if not streaming_state:
|
||||
desired_state = "unlimited"
|
||||
elif streaming_active:
|
||||
if remaining_kbs >= self.min_torrent_speed:
|
||||
desired_state = "throttled"
|
||||
else:
|
||||
desired_state = "paused"
|
||||
else:
|
||||
desired_state = self.current_state
|
||||
|
||||
if desired_state != self.current_state:
|
||||
if desired_state == "unlimited":
|
||||
action = "resume torrents, disable alt speed"
|
||||
elif desired_state == "throttled":
|
||||
action = (
|
||||
"set alt limits "
|
||||
f"dl={int(remaining_kbs)}KB/s ul={int(remaining_kbs)}KB/s, enable alt speed"
|
||||
)
|
||||
else:
|
||||
action = "pause torrents"
|
||||
|
||||
logger.info(
|
||||
"State change %s -> %s | streams=%d total_bps=%d remaining_bps=%d action=%s",
|
||||
self.current_state,
|
||||
desired_state,
|
||||
len(active_streams),
|
||||
total_streaming_bps,
|
||||
remaining_bps,
|
||||
action,
|
||||
)
|
||||
|
||||
if desired_state == "unlimited":
|
||||
if self.torrents_paused:
|
||||
self.resume_all_torrents()
|
||||
self.torrents_paused = False
|
||||
self.use_alt_limits(False)
|
||||
elif desired_state == "throttled":
|
||||
if self.torrents_paused:
|
||||
self.resume_all_torrents()
|
||||
self.torrents_paused = False
|
||||
self.set_alt_speed_limits(remaining_kbs, remaining_kbs)
|
||||
self.use_alt_limits(True)
|
||||
else:
|
||||
if not self.torrents_paused:
|
||||
self.pause_all_torrents()
|
||||
self.torrents_paused = True
|
||||
|
||||
self.current_state = desired_state
|
||||
self.last_active_streams = active_streams
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
@@ -414,26 +249,12 @@ if __name__ == "__main__":
|
||||
qbittorrent_url = os.getenv("QBITTORRENT_URL", "http://localhost:8080")
|
||||
check_interval = int(os.getenv("CHECK_INTERVAL", "30"))
|
||||
jellyfin_api_key = os.getenv("JELLYFIN_API_KEY")
|
||||
streaming_start_delay = int(os.getenv("STREAMING_START_DELAY", "10"))
|
||||
streaming_stop_delay = int(os.getenv("STREAMING_STOP_DELAY", "60"))
|
||||
total_bandwidth_budget = int(os.getenv("TOTAL_BANDWIDTH_BUDGET", "30000000"))
|
||||
service_buffer = int(os.getenv("SERVICE_BUFFER", "5000000"))
|
||||
default_stream_bitrate = int(os.getenv("DEFAULT_STREAM_BITRATE", "10000000"))
|
||||
min_torrent_speed = int(os.getenv("MIN_TORRENT_SPEED", "100"))
|
||||
stream_bitrate_headroom = float(os.getenv("STREAM_BITRATE_HEADROOM", "1.1"))
|
||||
|
||||
monitor = JellyfinQBittorrentMonitor(
|
||||
jellyfin_url=jellyfin_url,
|
||||
qbittorrent_url=qbittorrent_url,
|
||||
check_interval=check_interval,
|
||||
jellyfin_api_key=jellyfin_api_key,
|
||||
streaming_start_delay=streaming_start_delay,
|
||||
streaming_stop_delay=streaming_stop_delay,
|
||||
total_bandwidth_budget=total_bandwidth_budget,
|
||||
service_buffer=service_buffer,
|
||||
default_stream_bitrate=default_stream_bitrate,
|
||||
min_torrent_speed=min_torrent_speed,
|
||||
stream_bitrate_headroom=stream_bitrate_headroom,
|
||||
)
|
||||
|
||||
monitor.run()
|
||||
|
||||
@@ -7,14 +7,11 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "jellyfin" service_configs.zpool_ssds [
|
||||
(lib.serviceMountDeps "jellyfin" [
|
||||
config.services.jellyfin.dataDir
|
||||
config.services.jellyfin.cacheDir
|
||||
])
|
||||
(lib.serviceFilePerms "jellyfin" [
|
||||
"Z ${config.services.jellyfin.dataDir} 0700 ${config.services.jellyfin.user} ${config.services.jellyfin.group}"
|
||||
"Z ${config.services.jellyfin.cacheDir} 0700 ${config.services.jellyfin.user} ${config.services.jellyfin.group}"
|
||||
])
|
||||
(lib.serviceDependZpool "jellyfin" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
services.jellyfin = {
|
||||
@@ -27,34 +24,20 @@
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."jellyfin.${service_configs.https.domain}".extraConfig = ''
|
||||
reverse_proxy :${builtins.toString service_configs.ports.jellyfin} {
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
}
|
||||
reverse_proxy :${builtins.toString service_configs.ports.jellyfin}
|
||||
request_body {
|
||||
max_size 4096MB
|
||||
}
|
||||
'';
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z ${config.services.jellyfin.dataDir} 0700 ${config.services.jellyfin.user} ${config.services.jellyfin.group}"
|
||||
"Z ${config.services.jellyfin.cacheDir} 0700 ${config.services.jellyfin.user} ${config.services.jellyfin.group}"
|
||||
];
|
||||
|
||||
users.users.${config.services.jellyfin.user}.extraGroups = [
|
||||
"video"
|
||||
"render"
|
||||
service_configs.media_group
|
||||
];
|
||||
|
||||
# Protect Jellyfin login from brute force attacks
|
||||
services.fail2ban.jails.jellyfin = {
|
||||
enabled = true;
|
||||
settings = {
|
||||
backend = "auto";
|
||||
port = "http,https";
|
||||
logpath = "${config.services.jellyfin.dataDir}/log/log_*.log";
|
||||
# defaults: maxretry=5, findtime=10m, bantime=10m
|
||||
};
|
||||
filter.Definition = {
|
||||
failregex = ''^.*Authentication request for .* has been denied \(IP: "<ADDR>"\)\..*$'';
|
||||
ignoreregex = "";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
keyFile = ../secrets/livekit_keys;
|
||||
|
||||
ports = service_configs.ports;
|
||||
in
|
||||
{
|
||||
services.livekit = {
|
||||
enable = true;
|
||||
inherit keyFile;
|
||||
openFirewall = true;
|
||||
|
||||
settings = {
|
||||
port = ports.livekit;
|
||||
bind_addresses = [ "127.0.0.1" ];
|
||||
|
||||
rtc = {
|
||||
port_range_start = 50100;
|
||||
port_range_end = 50200;
|
||||
use_external_ip = true;
|
||||
};
|
||||
|
||||
# Disable LiveKit's built-in TURN; coturn is already running
|
||||
turn = {
|
||||
enabled = false;
|
||||
};
|
||||
|
||||
logging = {
|
||||
level = "info";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.lk-jwt-service = {
|
||||
enable = true;
|
||||
inherit keyFile;
|
||||
livekitUrl = "wss://${service_configs.livekit.domain}";
|
||||
port = ports.lk_jwt;
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."${service_configs.livekit.domain}".extraConfig = ''
|
||||
@jwt path /sfu/get /healthz
|
||||
handle @jwt {
|
||||
reverse_proxy :${builtins.toString ports.lk_jwt}
|
||||
}
|
||||
handle {
|
||||
reverse_proxy :${builtins.toString ports.livekit}
|
||||
}
|
||||
'';
|
||||
}
|
||||
43
services/llama-cpp.nix
Normal file
43
services/llama-cpp.nix
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
pkgs,
|
||||
service_configs,
|
||||
config,
|
||||
inputs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
services.llama-cpp = {
|
||||
enable = true;
|
||||
model = builtins.toString (
|
||||
pkgs.fetchurl {
|
||||
url = "https://huggingface.co/unsloth/Apriel-1.5-15b-Thinker-GGUF/resolve/main/Apriel-1.5-15b-Thinker-Q4_0.gguf";
|
||||
sha256 = "4d9439b76b6f4380ab5205617c1ef3d10b0e8897146a0a7ccb7155bca1771df7";
|
||||
}
|
||||
);
|
||||
port = service_configs.ports.llama_cpp;
|
||||
host = "0.0.0.0";
|
||||
# vulkan broken: https://github.com/ggml-org/llama.cpp/issues/13801
|
||||
package = (
|
||||
lib.optimizePackage (
|
||||
inputs.llamacpp.packages.${pkgs.system}.vulkan.overrideAttrs (old: {
|
||||
postPatch = "";
|
||||
})
|
||||
)
|
||||
);
|
||||
extraFlags = [
|
||||
"-ngl"
|
||||
"12"
|
||||
"-c"
|
||||
"16384"
|
||||
];
|
||||
};
|
||||
|
||||
# have to do this in order to get vulkan to work
|
||||
systemd.services.llama-cpp.serviceConfig.DynamicUser = lib.mkForce false;
|
||||
|
||||
services.caddy.virtualHosts."llm.${service_configs.https.domain}".extraConfig = ''
|
||||
import ${config.age.secrets.caddy_auth.path}
|
||||
reverse_proxy :${builtins.toString config.services.llama-cpp.port}
|
||||
'';
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
package =
|
||||
let
|
||||
src = pkgs.fetchFromGitea {
|
||||
domain = "forgejo.ellis.link";
|
||||
owner = "continuwuation";
|
||||
repo = "continuwuity";
|
||||
rev = "052c4dfa2165fdc4839fed95b71446120273cf23";
|
||||
hash = "sha256-kQV4glRrKczoJpn9QIMgB5ac+saZQjSZPel+9K9Ykcs=";
|
||||
};
|
||||
in
|
||||
pkgs.matrix-continuwuity.overrideAttrs (old: {
|
||||
inherit src;
|
||||
cargoDeps = pkgs.rustPlatform.fetchCargoVendor {
|
||||
inherit src;
|
||||
name = "${old.pname}-vendor";
|
||||
hash = "sha256-vlOXQL8wwEGFX+w0G/eIeHW3J1UDzhJ501kYhAghDV8=";
|
||||
};
|
||||
|
||||
patches = (old.patches or [ ]) ++ [
|
||||
|
||||
];
|
||||
});
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "continuwuity" service_configs.zpool_ssds [
|
||||
"/var/lib/private/continuwuity"
|
||||
])
|
||||
(lib.serviceFilePerms "continuwuity" [
|
||||
"Z /var/lib/private/continuwuity 0770 ${config.services.matrix-continuwuity.user} ${config.services.matrix-continuwuity.group}"
|
||||
])
|
||||
];
|
||||
|
||||
services.matrix-continuwuity = {
|
||||
enable = true;
|
||||
inherit package;
|
||||
|
||||
settings.global = {
|
||||
port = [ service_configs.ports.matrix ];
|
||||
server_name = service_configs.https.domain;
|
||||
allow_registration = true;
|
||||
registration_token = lib.strings.trim (builtins.readFile ../secrets/matrix_reg_token);
|
||||
|
||||
new_user_displayname_suffix = "";
|
||||
|
||||
trusted_servers = [
|
||||
"matrix.org"
|
||||
"constellatory.net"
|
||||
"tchncs.de"
|
||||
"envs.net"
|
||||
];
|
||||
|
||||
address = [
|
||||
"0.0.0.0"
|
||||
];
|
||||
|
||||
# TURN server config (coturn)
|
||||
turn_secret = config.services.coturn.static-auth-secret;
|
||||
turn_uris = [
|
||||
"turn:${service_configs.https.domain}?transport=udp"
|
||||
"turn:${service_configs.https.domain}?transport=tcp"
|
||||
];
|
||||
turn_ttl = 86400;
|
||||
};
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts.${service_configs.https.domain}.extraConfig = lib.mkBefore ''
|
||||
header /.well-known/matrix/* Content-Type application/json
|
||||
header /.well-known/matrix/* Access-Control-Allow-Origin *
|
||||
respond /.well-known/matrix/server `{"m.server": "${service_configs.matrix.domain}:${builtins.toString service_configs.ports.https}"}`
|
||||
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.matrix}
|
||||
'';
|
||||
|
||||
# Exact duplicate for federation port
|
||||
services.caddy.virtualHosts."${service_configs.matrix.domain}:${builtins.toString service_configs.ports.matrix_federation}".extraConfig =
|
||||
config.services.caddy.virtualHosts."${service_configs.matrix.domain}".extraConfig;
|
||||
|
||||
# for federation
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
service_configs.ports.matrix_federation
|
||||
];
|
||||
|
||||
# for federation
|
||||
networking.firewall.allowedUDPPorts = [
|
||||
service_configs.ports.matrix_federation
|
||||
];
|
||||
}
|
||||
@@ -3,24 +3,29 @@
|
||||
service_configs,
|
||||
lib,
|
||||
config,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "minecraft-server-${service_configs.minecraft.server_name}"
|
||||
service_configs.zpool_ssds
|
||||
[
|
||||
"${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}"
|
||||
]
|
||||
)
|
||||
inputs.nix-minecraft.nixosModules.minecraft-servers
|
||||
(lib.serviceFilePerms "minecraft-server-${service_configs.minecraft.server_name}" [
|
||||
"Z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name} 700 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
"Z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web 750 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
(lib.serviceMountDeps "minecraft-server-${service_configs.minecraft.server_name}" [
|
||||
"${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}"
|
||||
])
|
||||
(lib.serviceDependZpool "minecraft-server-${service_configs.minecraft.server_name}" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
environment.systemPackages = [
|
||||
(pkgs.writeScriptBin "mc-console" ''
|
||||
#!/bin/sh
|
||||
${pkgs.tmux}/bin/tmux -S /run/minecraft/${service_configs.minecraft.server_name}.sock attach
|
||||
'')
|
||||
];
|
||||
|
||||
nixpkgs.config.allowUnfreePredicate =
|
||||
pkg:
|
||||
builtins.elem (lib.getName pkg) [
|
||||
"minecraft-server"
|
||||
];
|
||||
|
||||
services.minecraft-servers = {
|
||||
enable = true;
|
||||
eula = true;
|
||||
@@ -29,47 +34,16 @@
|
||||
|
||||
servers.${service_configs.minecraft.server_name} = {
|
||||
enable = true;
|
||||
package = pkgs.fabricServers.fabric-1_21_11;
|
||||
package = pkgs.fabricServers.fabric-1_21_10;
|
||||
|
||||
jvmOpts =
|
||||
let
|
||||
heap_size = "4000M";
|
||||
in
|
||||
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"
|
||||
];
|
||||
"-Xmx${heap_size} -Xms${heap_size} -XX:+UseZGC -XX:+ZGenerational";
|
||||
|
||||
serverProperties = {
|
||||
server-port = service_configs.ports.minecraft;
|
||||
server-port = 25565;
|
||||
enforce-whitelist = true;
|
||||
gamemode = "survival";
|
||||
white-list = true;
|
||||
@@ -88,38 +62,40 @@
|
||||
with pkgs;
|
||||
builtins.attrValues {
|
||||
FabricApi = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/i5tSkVBH/fabric-api-0.141.3%2B1.21.11.jar";
|
||||
sha512 = "c20c017e23d6d2774690d0dd774cec84c16bfac5461da2d9345a1cd95eee495b1954333c421e3d1c66186284d24a433f6b0cced8021f62e0bfa617d2384d0471";
|
||||
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/qNm2IWMn/fabric-api-0.135.0%2B1.21.10.jar";
|
||||
sha512 = "7a3e442ab69be2ce8413580a198e13c87f1135699b5549a12a31b82dc9ddfa9b6731d774ebc36c752387010c51d018ac6aa85fcadfcb6254df8144d7e71235aa";
|
||||
};
|
||||
|
||||
FerriteCore = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/uXXizFIs/versions/Ii0gP3D8/ferritecore-8.2.0-fabric.jar";
|
||||
sha512 = "3210926a82eb32efd9bcebabe2f6c053daf5c4337eebc6d5bacba96d283510afbde646e7e195751de795ec70a2ea44fef77cb54bf22c8e57bb832d6217418869";
|
||||
url = "https://cdn.modrinth.com/data/uXXizFIs/versions/CtMpt7Jr/ferritecore-8.0.0-fabric.jar";
|
||||
sha512 = "131b82d1d366f0966435bfcb38c362d604d68ecf30c106d31a6261bfc868ca3a82425bb3faebaa2e5ea17d8eed5c92843810eb2df4790f2f8b1e6c1bdc9b7745";
|
||||
};
|
||||
|
||||
Lithium = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/gvQqBUqZ/versions/qvNsoO3l/lithium-fabric-0.21.3%2Bmc1.21.11.jar";
|
||||
sha512 = "2883739303f0bb602d3797cc601ed86ce6833e5ec313ddce675f3d6af3ee6a40b9b0a06dafe39d308d919669325e95c0aafd08d78c97acd976efde899c7810fd";
|
||||
url = "https://cdn.modrinth.com/data/gvQqBUqZ/versions/oGKQMdyZ/lithium-fabric-0.20.0%2Bmc1.21.10.jar";
|
||||
sha512 = "755c0e0fc7f6f38ac4d936cc6023d1dce6ecfd8d6bdc2c544c2a3c3d6d04f0d85db53722a089fa8be72ae32fc127e87f5946793ba6e8b4f2c2962ed30d333ed2";
|
||||
};
|
||||
|
||||
NoChatReports = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/qQyHxfxd/versions/rhykGstm/NoChatReports-FABRIC-1.21.11-v2.18.0.jar";
|
||||
sha512 = "d2c35cc8d624616f441665aff67c0e366e4101dba243bad25ed3518170942c1a3c1a477b28805cd1a36c44513693b1c55e76bea627d3fced13927a3d67022ccc";
|
||||
url = "https://cdn.modrinth.com/data/qQyHxfxd/versions/LhwpK0O6/NoChatReports-FABRIC-1.21.7-v2.14.0.jar";
|
||||
sha512 = "6e93c822e606ad12cb650801be1b3f39fcd2fef64a9bb905f357eb01a28451afddb3a6cadb39c112463519df0a07b9ff374d39223e9bf189aee7e7182077a7ae";
|
||||
};
|
||||
|
||||
squaremap = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/PFb7ZqK6/versions/BW8lMXBi/squaremap-fabric-mc1.21.11-1.3.12.jar";
|
||||
sha512 = "f62eb791a3f5812eb174565d318f2e6925353f846ef8ac56b4e595f481494e0c281f26b9e9fcfdefa855093c96b735b12f67ee17c07c2477aa7a3439238670d9";
|
||||
url = "https://cdn.modrinth.com/data/PFb7ZqK6/versions/f4ZOYenB/squaremap-fabric-mc1.21.10-1.3.9.jar";
|
||||
sha512 = "ddc2105568cb935eb269cfbb82b601189aa0c6c5ac98240188a3196e3e8454c23af86e95765153bd72285e9728dd8d0e587b4bcc3967f2636c5139f363e05f97";
|
||||
};
|
||||
|
||||
scalablelux = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/Ps1zyz6x/versions/PV9KcrYQ/ScalableLux-0.1.6%2Bfabric.c25518a-all.jar";
|
||||
sha512 = "729515c1e75cf8d9cd704f12b3487ddb9664cf9928e7b85b12289c8fbbc7ed82d0211e1851375cbd5b385820b4fedbc3f617038fff5e30b302047b0937042ae7";
|
||||
};
|
||||
/*
|
||||
scalablelux = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/Ps1zyz6x/versions/PQLHDg2Q/ScalableLux-0.1.5%2Bfabric.e4acdcb-all.jar";
|
||||
sha512 = "ec8fabc3bf991fbcbe064c1e97ded3e70f145a87e436056241cbb1e14c57ea9f59ef312f24c205160ccbda43f693e05d652b7f19aa71f730caec3bb5f7f7820a";
|
||||
};
|
||||
*/
|
||||
|
||||
c2me = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/VSNURh3q/versions/QdLiMUjx/c2me-fabric-mc1.21.11-0.3.7%2Balpha.0.7.jar";
|
||||
sha512 = "f9543febe2d649a82acd6d5b66189b6a3d820cf24aa503ba493fdb3bbd4e52e30912c4c763fe50006f9a46947ae8cd737d420838c61b93429542573ed67f958e";
|
||||
url = "https://cdn.modrinth.com/data/VSNURh3q/versions/Bl0VOr1e/c2me-fabric-mc1.21.10-0.3.5%2Bbeta.1.0.jar";
|
||||
sha512 = "8ad90725b9cb79d567f8985e330c2da0a28f4cf9a3556ac078aa2bc4d0b68ca057417ec1f1fd42f92a04f76cdfe72ceab4e5cdec2bbaf443498884a5759d6ee4";
|
||||
};
|
||||
|
||||
krypton = fetchurl {
|
||||
@@ -128,8 +104,8 @@
|
||||
};
|
||||
|
||||
better-fabric-console = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/Y8o1j1Sf/versions/6aIKl5wy/better-fabric-console-mc1.21.11-1.2.9.jar";
|
||||
sha512 = "427247dafd99df202ee10b4bf60ffcbbecbabfadb01c167097ffb5b85670edb811f4d061c2551be816295cbbc6b8ec5ec464c14a6ff41912ef1f6c57b038d320";
|
||||
url = "https://cdn.modrinth.com/data/Y8o1j1Sf/versions/laxelUM5/better-fabric-console-mc1.21.10-1.2.6.jar";
|
||||
sha512 = "b458699a2f555db6fb150e7663188b54bc931e1e03c0dd35823aea570e55d0fa844b334ab9d9f605e038eeb65a71350ef2058cd105cca7c154920a8adec17d47";
|
||||
};
|
||||
|
||||
disconnect-packet-fix = fetchurl {
|
||||
@@ -138,35 +114,15 @@
|
||||
};
|
||||
|
||||
packet-fixer = fetchurl {
|
||||
url = "https://cdn.modrinth.com/data/c7m1mi73/versions/CUh1DWeO/packetfixer-fabric-3.3.4-1.21.11.jar";
|
||||
sha512 = "33331b16cb40c5e6fbaade3cacc26f3a0e8fa5805a7186f94d7366a0e14dbeee9de2d2e8c76fa71f5e9dd24eb1c261667c35447e32570ea965ca0f154fdfba0a";
|
||||
url = "https://cdn.modrinth.com/data/c7m1mi73/versions/V05RgbEn/packetfixer-3.3.0-1.20.5-1.21.X-merged.jar";
|
||||
sha512 = "17fd46a5edd2ecefb67f346fa1ddd8beebd119f9c1598e91211c5d8a4691ae118d81d6628cb39705c0cf1a4d1f09299b76f72c5d286ca5b707c2508633654c12";
|
||||
};
|
||||
|
||||
# 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) {
|
||||
"map.${service_configs.https.domain}".extraConfig = ''
|
||||
root * ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web
|
||||
@@ -182,8 +138,7 @@
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
# Allow caddy (in minecraft group) to traverse to squaremap/web for map.gardling.com
|
||||
"z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name} 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
"z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap 710 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
"Z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name} 700 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
"Z ${service_configs.minecraft.parent_dir}/${service_configs.minecraft.server_name}/squaremap/web 750 ${config.services.minecraft-servers.user} ${config.services.minecraft-servers.group}"
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "monero" service_configs.zpool_hdds [
|
||||
service_configs.monero.dataDir
|
||||
])
|
||||
(lib.serviceFilePerms "monero" [
|
||||
"Z ${service_configs.monero.dataDir} 0700 monero monero"
|
||||
])
|
||||
];
|
||||
|
||||
services.monero = {
|
||||
enable = true;
|
||||
dataDir = service_configs.monero.dataDir;
|
||||
rpc = {
|
||||
restricted = true;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{ 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;
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
config,
|
||||
service_configs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "ntfy-sh" service_configs.zpool_ssds [
|
||||
"/var/lib/private/ntfy-sh"
|
||||
])
|
||||
(lib.serviceFilePerms "ntfy-sh" [
|
||||
"Z /var/lib/private/ntfy-sh 0700 ${config.services.ntfy-sh.user} ${config.services.ntfy-sh.group}"
|
||||
])
|
||||
];
|
||||
|
||||
services.ntfy-sh = {
|
||||
enable = true;
|
||||
|
||||
settings = {
|
||||
base-url = "https://${service_configs.ntfy.domain}";
|
||||
listen-http = "127.0.0.1:${builtins.toString service_configs.ports.ntfy}";
|
||||
behind-proxy = true;
|
||||
auth-default-access = "deny-all";
|
||||
enable-login = true;
|
||||
enable-signup = false;
|
||||
};
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."${service_configs.ntfy.domain}".extraConfig = ''
|
||||
reverse_proxy :${builtins.toString service_configs.ports.ntfy}
|
||||
'';
|
||||
|
||||
}
|
||||
@@ -7,12 +7,8 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "postgresql" service_configs.zpool_ssds [
|
||||
config.services.postgresql.dataDir
|
||||
])
|
||||
(lib.serviceFilePerms "postgresql" [
|
||||
"Z ${config.services.postgresql.dataDir} 0700 postgres postgres"
|
||||
])
|
||||
(lib.serviceMountDeps "postgresql" [ config.services.postgresql.dataDir ])
|
||||
(lib.serviceDependZpool "postgresql" service_configs.zpool_ssds)
|
||||
];
|
||||
|
||||
services.postgresql = {
|
||||
@@ -21,4 +17,8 @@
|
||||
dataDir = service_configs.postgres.dataDir;
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
# postgresql requires 0700
|
||||
"Z ${config.services.postgresql.dataDir} 0700 postgresql postgresql"
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,31 +8,19 @@
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "qbittorrent" service_configs.zpool_hdds [
|
||||
(lib.serviceMountDeps "qbittorrent" [
|
||||
service_configs.torrents_path
|
||||
])
|
||||
(lib.serviceMountWithZpool "qbittorrent" service_configs.zpool_ssds [
|
||||
config.services.qbittorrent.serverConfig.Preferences.Downloads.TempPath
|
||||
"${config.services.qbittorrent.profileDir}/qBittorrent"
|
||||
])
|
||||
(lib.vpnNamespaceOpenPort config.services.qbittorrent.webuiPort "qbittorrent")
|
||||
(lib.serviceFilePerms "qbittorrent" [
|
||||
# 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.profileDir} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}"
|
||||
])
|
||||
(lib.serviceDependZpool "qbittorrent" service_configs.zpool_hdds)
|
||||
];
|
||||
|
||||
services.qbittorrent = {
|
||||
enable = true;
|
||||
webuiPort = service_configs.ports.torrent;
|
||||
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;
|
||||
|
||||
@@ -53,29 +41,28 @@
|
||||
|
||||
serverConfig.BitTorrent = {
|
||||
Session = {
|
||||
MaxConnectionsPerTorrent = 100;
|
||||
MaxUploadsPerTorrent = 15;
|
||||
MaxConnectionsPerTorrent = 10;
|
||||
MaxUploadsPerTorrent = 10;
|
||||
MaxConnections = -1;
|
||||
MaxUploads = -1;
|
||||
|
||||
MaxActiveCheckingTorrents = 2; # reduce disk pressure from concurrent hash checks
|
||||
MaxActiveCheckingTorrents = 5;
|
||||
|
||||
# queueing
|
||||
QueueingSystemEnabled = true;
|
||||
MaxActiveDownloads = 15;
|
||||
MaxActiveUploads = -1;
|
||||
MaxActiveTorrents = -1;
|
||||
QueueingSystemEnabled = false;
|
||||
MaxActiveDownloads = 2; # num of torrents that can download at the same time
|
||||
MaxActiveUploads = 20;
|
||||
IgnoreSlowTorrentsForQueueing = true;
|
||||
|
||||
GlobalUPSpeedLimit = 0;
|
||||
GlobalDLSpeedLimit = 10000;
|
||||
GlobalDLSpeedLimit = 0;
|
||||
|
||||
# Alternate speed limits for when Jellyfin is streaming
|
||||
AlternativeGlobalUPSpeedLimit = 500; # 500 KB/s when throttled
|
||||
AlternativeGlobalDLSpeedLimit = 800; # 800 KB/s when throttled
|
||||
IncludeOverheadInLimits = true;
|
||||
|
||||
GlobalMaxRatio = 7.0;
|
||||
GlobalMaxRatio = 6.0;
|
||||
|
||||
AddTrackersEnabled = true;
|
||||
AdditionalTrackers = lib.concatStringsSep "\\n" (
|
||||
@@ -89,36 +76,12 @@
|
||||
inherit (config.services.qbittorrent.serverConfig.Preferences.Downloads) TempPath;
|
||||
TempPathEnabled = true;
|
||||
|
||||
ConnectionSpeed = 100;
|
||||
|
||||
SaveResumeDataInterval = 300; # save resume data every 5 min (default 60s)
|
||||
ResumeDataStorageType = "SQLite"; # SQLite is more efficient than legacy per-file .fastresume storage
|
||||
|
||||
# Automatic Torrent Management: use category save paths for new torrents
|
||||
DisableAutoTMMByDefault = false;
|
||||
DisableAutoTMMTriggers.CategorySavePathChanged = false;
|
||||
DisableAutoTMMTriggers.DefaultSavePathChanged = false;
|
||||
# how many connections per sec
|
||||
ConnectionSpeed = 300;
|
||||
|
||||
ChokingAlgorithm = "RateBased";
|
||||
PieceExtentAffinity = true;
|
||||
SuggestMode = true;
|
||||
CoalesceReadWrite = true;
|
||||
|
||||
# max_queued_disk_bytes: the max bytes waiting in the disk I/O queue.
|
||||
# When this limit is reached, peer connections stop reading from their
|
||||
# sockets until the disk thread catches up -- causing the spike-then-zero
|
||||
# pattern. Default is 1MB; high_performance_seed() uses 7MB.
|
||||
# 64MB is above the preset but justified for slow raidz1 HDD random writes
|
||||
# where ZFS txg commits cause periodic I/O stalls.
|
||||
DiskQueueSize = 67108864; # 64MB
|
||||
|
||||
# === Network buffer tuning (from libtorrent high_performance_seed preset) ===
|
||||
# "always stuff at least 1 MiB down each peer pipe, to quickly ramp up send rates"
|
||||
SendBufferLowWatermark = 1024; # 1MB (KiB) -- matches high_performance_seed
|
||||
# "of 500 ms, and a send rate of 4 MB/s, the upper limit should be 2 MB"
|
||||
SendBufferWatermark = 3072; # 3MB (KiB) -- matches high_performance_seed
|
||||
# "put 1.5 seconds worth of data in the send buffer"
|
||||
SendBufferWatermarkFactor = 150; # percent -- matches high_performance_seed
|
||||
};
|
||||
|
||||
Network = {
|
||||
@@ -126,16 +89,20 @@
|
||||
# port forwarding
|
||||
PortForwardingEnabled = false;
|
||||
};
|
||||
|
||||
Session.UseUPnP = false;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.qbittorrent.serviceConfig.TimeoutStopSec = lib.mkForce 10;
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z ${config.services.qbittorrent.serverConfig.Preferences.Downloads.SavePath} 0750 ${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.profileDir} 0700 ${config.services.qbittorrent.user} ${config.services.qbittorrent.group}"
|
||||
];
|
||||
|
||||
services.caddy.virtualHosts."torrent.${service_configs.https.domain}".extraConfig = ''
|
||||
import ${config.age.secrets.caddy_auth.path}
|
||||
reverse_proxy ${config.vpnNamespaces.wg.namespaceAddress}:${builtins.toString config.services.qbittorrent.webuiPort}
|
||||
reverse_proxy ${service_configs.https.wg_ip}:${builtins.toString config.services.qbittorrent.webuiPort}
|
||||
'';
|
||||
|
||||
users.users.${config.services.qbittorrent.user}.extraGroups = [
|
||||
|
||||
@@ -11,17 +11,13 @@ let
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "slskd" "" [
|
||||
(lib.serviceMountDeps "slskd" [
|
||||
service_configs.slskd.base
|
||||
service_configs.slskd.downloads
|
||||
service_configs.slskd.incomplete
|
||||
])
|
||||
(lib.serviceFilePerms "slskd" [
|
||||
"Z ${service_configs.music_dir} 0750 ${username} music"
|
||||
"Z ${service_configs.slskd.base} 0750 ${config.services.slskd.user} ${config.services.slskd.group}"
|
||||
"Z ${service_configs.slskd.downloads} 0750 ${config.services.slskd.user} music"
|
||||
"Z ${service_configs.slskd.incomplete} 0750 ${config.services.slskd.user} music"
|
||||
])
|
||||
(lib.serviceDependZpool "slskd" service_configs.zpool_ssds)
|
||||
(lib.serviceDependZpool "slskd" service_configs.zpool_hdds)
|
||||
];
|
||||
|
||||
users.groups."music" = { };
|
||||
@@ -71,6 +67,13 @@ in
|
||||
users.users.${config.services.jellyfin.user}.extraGroups = [ "music" ];
|
||||
users.users.${username}.extraGroups = [ "music" ];
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z ${service_configs.music_dir} 0750 ${username} music"
|
||||
"Z ${service_configs.slskd.base} 0750 ${config.services.slskd.user} ${config.services.slskd.group}"
|
||||
"Z ${service_configs.slskd.downloads} 0750 ${config.services.slskd.user} music"
|
||||
"Z ${service_configs.slskd.incomplete} 0750 ${config.services.slskd.user} 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}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
username,
|
||||
...
|
||||
}:
|
||||
{
|
||||
# Enable the OpenSSH daemon.
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
AllowUsers = [
|
||||
username
|
||||
"root"
|
||||
];
|
||||
PasswordAuthentication = false;
|
||||
PermitRootLogin = "yes"; # for deploying configs
|
||||
};
|
||||
};
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z /etc/ssh 755 root root"
|
||||
"Z /etc/ssh/ssh_host_* 600 root root"
|
||||
];
|
||||
|
||||
users.users.${username}.openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIO4jL6gYOunUlUtPvGdML0cpbKSsPNqQ1jit4E7U1RyH" # laptop
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBJjT5QZ3zRDb+V6Em20EYpSEgPW5e/U+06uQGJdraxi" # desktop
|
||||
];
|
||||
|
||||
# used for deploying configs to server
|
||||
users.users.root.openssh.authorizedKeys.keys =
|
||||
config.users.users.${username}.openssh.authorizedKeys.keys;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
service_configs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceMountWithZpool "syncthing" service_configs.zpool_ssds [
|
||||
service_configs.syncthing.dataDir
|
||||
service_configs.syncthing.signalBackupDir
|
||||
service_configs.syncthing.grayjayBackupDir
|
||||
])
|
||||
(lib.serviceFilePerms "syncthing" [
|
||||
"Z ${service_configs.syncthing.dataDir} 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}"
|
||||
])
|
||||
];
|
||||
|
||||
services.syncthing = {
|
||||
enable = true;
|
||||
|
||||
dataDir = service_configs.syncthing.dataDir;
|
||||
|
||||
guiAddress = "127.0.0.1:${toString service_configs.ports.syncthing_gui}";
|
||||
|
||||
overrideDevices = false;
|
||||
overrideFolders = false;
|
||||
|
||||
settings = {
|
||||
gui = {
|
||||
insecureSkipHostcheck = true; # Allow access via reverse proxy
|
||||
};
|
||||
options = {
|
||||
urAccepted = 1; # enable usage reporting
|
||||
relaysEnabled = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
# Open firewall ports for syncthing protocol
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [ service_configs.ports.syncthing_protocol ];
|
||||
allowedUDPPorts = [ service_configs.ports.syncthing_discovery ];
|
||||
};
|
||||
|
||||
services.caddy.virtualHosts."syncthing.${service_configs.https.domain}".extraConfig = ''
|
||||
import ${config.age.secrets.caddy_auth.path}
|
||||
reverse_proxy :${toString service_configs.ports.syncthing_gui}
|
||||
'';
|
||||
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
{
|
||||
pkgs,
|
||||
service_configs,
|
||||
eth_interface,
|
||||
config,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
inputs.vpn-confinement.nixosModules.default
|
||||
];
|
||||
|
||||
# network namespace that is proxied through mullvad
|
||||
vpnNamespaces.wg = {
|
||||
enable = true;
|
||||
@@ -17,4 +14,45 @@
|
||||
# "192.168.0.0/24"
|
||||
];
|
||||
};
|
||||
|
||||
systemd.services."jellyfin-qbittorrent-monitor" = {
|
||||
description = "Monitor Jellyfin streaming and control qBittorrent rate limits";
|
||||
after = [
|
||||
"network.target"
|
||||
"jellyfin.service"
|
||||
"qbittorrent.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
ExecStart = pkgs.writeShellScript "jellyfin-monitor-start" ''
|
||||
export JELLYFIN_API_KEY=$(cat ${config.age.secrets.jellyfin-api-key.path})
|
||||
exec ${
|
||||
pkgs.python3.withPackages (ps: with ps; [ requests ])
|
||||
}/bin/python ${./jellyfin-qbittorrent-monitor.py}
|
||||
'';
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
|
||||
# Security hardening
|
||||
DynamicUser = true;
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
RemoveIPC = true;
|
||||
};
|
||||
|
||||
environment = {
|
||||
JELLYFIN_URL = "http://localhost:${builtins.toString service_configs.ports.jellyfin}";
|
||||
QBITTORRENT_URL = "http://${service_configs.https.wg_ip}:${builtins.toString service_configs.ports.torrent}";
|
||||
CHECK_INTERVAL = "30";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
hostname,
|
||||
...
|
||||
}:
|
||||
let
|
||||
walletAddress = lib.strings.trim (builtins.readFile ../secrets/xmrig-wallet);
|
||||
threadCount = 12;
|
||||
in
|
||||
{
|
||||
services.xmrig = {
|
||||
enable = true;
|
||||
package = pkgs.xmrig;
|
||||
|
||||
settings = {
|
||||
autosave = true;
|
||||
|
||||
cpu = {
|
||||
enabled = true;
|
||||
huge-pages = true;
|
||||
hw-aes = true;
|
||||
rx = lib.range 0 (threadCount - 1);
|
||||
};
|
||||
|
||||
randomx = {
|
||||
"1gb-pages" = true;
|
||||
};
|
||||
|
||||
opencl = false;
|
||||
cuda = false;
|
||||
|
||||
pools = [
|
||||
{
|
||||
url = "gulf.moneroocean.stream:20128";
|
||||
user = walletAddress;
|
||||
pass = hostname + "~rx/0";
|
||||
keepalive = true;
|
||||
tls = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.xmrig.serviceConfig = {
|
||||
Nice = 19;
|
||||
CPUSchedulingPolicy = "idle";
|
||||
IOSchedulingClass = "idle";
|
||||
};
|
||||
|
||||
# Stop mining on UPS battery to conserve power
|
||||
services.apcupsd.hooks = lib.mkIf config.services.apcupsd.enable {
|
||||
onbattery = "systemctl stop xmrig";
|
||||
offbattery = "systemctl start xmrig";
|
||||
};
|
||||
|
||||
# Reserve 1GB huge pages for RandomX (dataset is ~2GB)
|
||||
boot.kernelParams = [
|
||||
"hugepagesz=1G"
|
||||
"hugepages=3"
|
||||
];
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "fail2ban-caddy";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../modules/security.nix
|
||||
];
|
||||
|
||||
# Set up Caddy with basic auth (minimal config, no production stuff)
|
||||
# Using bcrypt hash generated with: caddy hash-password --plaintext testpass
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
virtualHosts.":80".extraConfig = ''
|
||||
log {
|
||||
output file /var/log/caddy/access-server.log
|
||||
format json
|
||||
}
|
||||
basic_auth {
|
||||
testuser $2a$14$XqaQlGTdmofswciqrLlMz.rv0/jiGQq8aU.fP6mh6gCGiLf6Cl3.a
|
||||
}
|
||||
respond "Authenticated!" 200
|
||||
'';
|
||||
};
|
||||
|
||||
# Add the fail2ban jail for caddy-auth (same as in services/caddy.nix)
|
||||
services.fail2ban.jails.caddy-auth = {
|
||||
enabled = true;
|
||||
settings = {
|
||||
backend = "auto";
|
||||
port = "http,https";
|
||||
logpath = "/var/log/caddy/access-*.log";
|
||||
maxretry = 3; # Lower for testing
|
||||
};
|
||||
filter.Definition = {
|
||||
# Only match 401s where an Authorization header was actually sent
|
||||
failregex = ''^.*"remote_ip":"<HOST>".*"Authorization":\["REDACTED"\].*"status":401.*$'';
|
||||
ignoreregex = "";
|
||||
datepattern = ''"ts":{Epoch}\.'';
|
||||
};
|
||||
};
|
||||
|
||||
# Create log directory and initial log file so fail2ban can start
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/log/caddy 755 caddy caddy"
|
||||
"f /var/log/caddy/access-server.log 644 caddy caddy"
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 80 ];
|
||||
};
|
||||
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
import re
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("caddy.service")
|
||||
server.wait_for_unit("fail2ban.service")
|
||||
server.wait_for_open_port(80)
|
||||
time.sleep(2)
|
||||
|
||||
with subtest("Verify caddy-auth jail is active"):
|
||||
status = server.succeed("fail2ban-client status")
|
||||
assert "caddy-auth" in status, f"caddy-auth jail not found in: {status}"
|
||||
|
||||
with subtest("Verify correct password works"):
|
||||
# Use -4 to force IPv4 for consistency
|
||||
result = client.succeed("curl -4 -s -u testuser:testpass http://server/")
|
||||
print(f"Curl result: {result}")
|
||||
assert "Authenticated" in result, f"Auth should succeed: {result}"
|
||||
|
||||
with subtest("Unauthenticated requests (browser probes) should not trigger ban"):
|
||||
# Simulate browser probe requests - no Authorization header sent
|
||||
# This is the normal HTTP Basic Auth challenge-response flow:
|
||||
# browser sends request without credentials, gets 401, then resends with credentials
|
||||
for i in range(5):
|
||||
client.execute("curl -4 -s http://server/ || true")
|
||||
time.sleep(0.5)
|
||||
time.sleep(3)
|
||||
status = server.succeed("fail2ban-client status caddy-auth")
|
||||
print(f"caddy-auth jail status after unauthenticated requests: {status}")
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
banned = int(match.group(1)) if match else 0
|
||||
assert banned == 0, f"Unauthenticated 401s should NOT trigger ban, but {banned} IPs were banned: {status}"
|
||||
|
||||
with subtest("Generate failed basic auth attempts (wrong password)"):
|
||||
# Use -4 to force IPv4 for consistent IP tracking
|
||||
# These send an Authorization header with wrong credentials
|
||||
for i in range(4):
|
||||
client.execute("curl -4 -s -u testuser:wrongpass http://server/ || true")
|
||||
time.sleep(1)
|
||||
|
||||
with subtest("Verify IP is banned after wrong password attempts"):
|
||||
time.sleep(5)
|
||||
status = server.succeed("fail2ban-client status caddy-auth")
|
||||
print(f"caddy-auth jail status: {status}")
|
||||
# Check that at least 1 IP is banned
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}"
|
||||
|
||||
with subtest("Verify banned client cannot connect"):
|
||||
# Use -4 to test with same IP that was banned
|
||||
exit_code = client.execute("curl -4 -s --max-time 3 http://server/ 2>&1")[0]
|
||||
assert exit_code != 0, "Connection should be blocked"
|
||||
'';
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
testServiceConfigs = {
|
||||
zpool_ssds = "";
|
||||
gitea = {
|
||||
dir = "/var/lib/gitea";
|
||||
domain = "git.test.local";
|
||||
};
|
||||
postgres = {
|
||||
socket = "/run/postgresql";
|
||||
};
|
||||
ports = {
|
||||
gitea = 3000;
|
||||
};
|
||||
};
|
||||
|
||||
testLib = lib.extend (
|
||||
final: prev: {
|
||||
serviceMountWithZpool =
|
||||
serviceName: zpool: dirs:
|
||||
{ ... }:
|
||||
{ };
|
||||
serviceFilePerms = serviceName: tmpfilesRules: { ... }: { };
|
||||
}
|
||||
);
|
||||
|
||||
giteaModule =
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../services/gitea.nix {
|
||||
inherit config pkgs;
|
||||
lib = testLib;
|
||||
service_configs = testServiceConfigs;
|
||||
})
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "fail2ban-gitea";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../modules/security.nix
|
||||
giteaModule
|
||||
];
|
||||
|
||||
# Enable postgres for gitea
|
||||
services.postgresql.enable = true;
|
||||
|
||||
# Disable ZFS mount dependency
|
||||
systemd.services."gitea-mounts".enable = lib.mkForce false;
|
||||
systemd.services.gitea = {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ "postgresql.service" ];
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
# Override for faster testing and correct port
|
||||
services.fail2ban.jails.gitea.settings = {
|
||||
maxretry = lib.mkForce 3;
|
||||
# In test, we connect directly to Gitea port, not via Caddy
|
||||
port = lib.mkForce "3000";
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 3000 ];
|
||||
};
|
||||
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
import re
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("postgresql.service")
|
||||
server.wait_for_unit("gitea.service")
|
||||
server.wait_for_unit("fail2ban.service")
|
||||
server.wait_for_open_port(3000)
|
||||
time.sleep(3)
|
||||
|
||||
with subtest("Verify gitea jail is active"):
|
||||
status = server.succeed("fail2ban-client status")
|
||||
assert "gitea" in status, f"gitea jail not found in: {status}"
|
||||
|
||||
with subtest("Generate failed login attempts"):
|
||||
# Use -4 to force IPv4 for consistent IP tracking
|
||||
for i in range(4):
|
||||
client.execute(
|
||||
"curl -4 -s -X POST http://server:3000/user/login -d 'user_name=baduser&password=badpass' || true"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
with subtest("Verify IP is banned"):
|
||||
time.sleep(3)
|
||||
status = server.succeed("fail2ban-client status gitea")
|
||||
print(f"gitea jail status: {status}")
|
||||
# Check that at least 1 IP is banned
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}"
|
||||
|
||||
with subtest("Verify banned client cannot connect"):
|
||||
# Use -4 to test with same IP that was banned
|
||||
exit_code = client.execute("curl -4 -s --max-time 3 http://server:3000/ 2>&1")[0]
|
||||
assert exit_code != 0, "Connection should be blocked"
|
||||
'';
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
testServiceConfigs = {
|
||||
zpool_ssds = "";
|
||||
https = {
|
||||
domain = "test.local";
|
||||
};
|
||||
ports = {
|
||||
immich = 2283;
|
||||
};
|
||||
immich = {
|
||||
dir = "/var/lib/immich";
|
||||
};
|
||||
};
|
||||
|
||||
testLib = lib.extend (
|
||||
final: prev: {
|
||||
serviceMountWithZpool =
|
||||
serviceName: zpool: dirs:
|
||||
{ ... }:
|
||||
{ };
|
||||
serviceFilePerms = serviceName: tmpfilesRules: { ... }: { };
|
||||
}
|
||||
);
|
||||
|
||||
immichModule =
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../services/immich.nix {
|
||||
inherit config pkgs;
|
||||
lib = testLib;
|
||||
service_configs = testServiceConfigs;
|
||||
})
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "fail2ban-immich";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../modules/security.nix
|
||||
immichModule
|
||||
];
|
||||
|
||||
# Immich needs postgres
|
||||
services.postgresql.enable = true;
|
||||
|
||||
# Let immich create its own DB for testing
|
||||
services.immich.database.createDB = lib.mkForce true;
|
||||
|
||||
# Disable ZFS mount dependencies
|
||||
systemd.services."immich-server-mounts".enable = lib.mkForce false;
|
||||
systemd.services."immich-machine-learning-mounts".enable = lib.mkForce false;
|
||||
systemd.services.immich-server = {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ "postgresql.service" ];
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
systemd.services.immich-machine-learning = {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ ];
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
# Override for faster testing and correct port
|
||||
services.fail2ban.jails.immich.settings = {
|
||||
maxretry = lib.mkForce 3;
|
||||
# In test, we connect directly to Immich port, not via Caddy
|
||||
port = lib.mkForce "2283";
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 2283 ];
|
||||
|
||||
# Immich needs more resources
|
||||
virtualisation.diskSize = 4 * 1024;
|
||||
virtualisation.memorySize = 4 * 1024; # 4GB RAM for Immich
|
||||
};
|
||||
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
import re
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("postgresql.service")
|
||||
server.wait_for_unit("immich-server.service", timeout=120)
|
||||
server.wait_for_unit("fail2ban.service")
|
||||
server.wait_for_open_port(2283, timeout=60)
|
||||
time.sleep(3)
|
||||
|
||||
with subtest("Verify immich jail is active"):
|
||||
status = server.succeed("fail2ban-client status")
|
||||
assert "immich" in status, f"immich jail not found in: {status}"
|
||||
|
||||
with subtest("Generate failed login attempts"):
|
||||
# Use -4 to force IPv4 for consistent IP tracking
|
||||
for i in range(4):
|
||||
client.execute(
|
||||
"curl -4 -s -X POST http://server:2283/api/auth/login -H 'Content-Type: application/json' -d '{\"email\":\"bad@user.com\",\"password\":\"badpass\"}' || true"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
with subtest("Verify IP is banned"):
|
||||
time.sleep(3)
|
||||
status = server.succeed("fail2ban-client status immich")
|
||||
print(f"immich jail status: {status}")
|
||||
# Check that at least 1 IP is banned
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}"
|
||||
|
||||
with subtest("Verify banned client cannot connect"):
|
||||
# Use -4 to test with same IP that was banned
|
||||
exit_code = client.execute("curl -4 -s --max-time 3 http://server:2283/ 2>&1")[0]
|
||||
assert exit_code != 0, "Connection should be blocked"
|
||||
'';
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
testServiceConfigs = {
|
||||
zpool_ssds = "";
|
||||
https = {
|
||||
domain = "test.local";
|
||||
};
|
||||
ports = {
|
||||
jellyfin = 8096;
|
||||
};
|
||||
jellyfin = {
|
||||
dataDir = "/var/lib/jellyfin";
|
||||
cacheDir = "/var/cache/jellyfin";
|
||||
};
|
||||
media_group = "media";
|
||||
};
|
||||
|
||||
testLib = lib.extend (
|
||||
final: prev: {
|
||||
serviceMountWithZpool =
|
||||
serviceName: zpool: dirs:
|
||||
{ ... }:
|
||||
{ };
|
||||
serviceFilePerms = serviceName: tmpfilesRules: { ... }: { };
|
||||
optimizePackage = pkg: pkg; # No-op for testing
|
||||
}
|
||||
);
|
||||
|
||||
jellyfinModule =
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../services/jellyfin.nix {
|
||||
inherit config pkgs;
|
||||
lib = testLib;
|
||||
service_configs = testServiceConfigs;
|
||||
})
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "fail2ban-jellyfin";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../modules/security.nix
|
||||
jellyfinModule
|
||||
];
|
||||
|
||||
# Create the media group
|
||||
users.groups.media = { };
|
||||
|
||||
# Disable ZFS mount dependency
|
||||
systemd.services."jellyfin-mounts".enable = lib.mkForce false;
|
||||
systemd.services.jellyfin = {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ ];
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
# Override for faster testing and correct port
|
||||
services.fail2ban.jails.jellyfin.settings = {
|
||||
maxretry = lib.mkForce 3;
|
||||
# In test, we connect directly to Jellyfin port, not via Caddy
|
||||
port = lib.mkForce "8096";
|
||||
};
|
||||
|
||||
# Create log directory and placeholder log file for fail2ban
|
||||
# Jellyfin logs to files, not systemd journal
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/jellyfin/log 0755 jellyfin jellyfin"
|
||||
"f /var/lib/jellyfin/log/log_placeholder.log 0644 jellyfin jellyfin"
|
||||
];
|
||||
|
||||
# Make fail2ban start after Jellyfin
|
||||
systemd.services.fail2ban = {
|
||||
wants = [ "jellyfin.service" ];
|
||||
after = [ "jellyfin.service" ];
|
||||
};
|
||||
|
||||
# Give jellyfin more disk space and memory
|
||||
virtualisation.diskSize = 3 * 1024;
|
||||
virtualisation.memorySize = 2 * 1024;
|
||||
};
|
||||
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
import re
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_unit("fail2ban.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
time.sleep(2)
|
||||
|
||||
# Wait for Jellyfin to create real log files and reload fail2ban
|
||||
server.wait_until_succeeds("ls /var/lib/jellyfin/log/log_2*.log", timeout=30)
|
||||
server.succeed("fail2ban-client reload jellyfin")
|
||||
|
||||
with subtest("Verify jellyfin jail is active"):
|
||||
status = server.succeed("fail2ban-client status")
|
||||
assert "jellyfin" in status, f"jellyfin jail not found in: {status}"
|
||||
|
||||
with subtest("Generate failed login attempts"):
|
||||
# Use -4 to force IPv4 for consistent IP tracking
|
||||
for i in range(4):
|
||||
client.execute("""
|
||||
curl -4 -s -X POST http://server:8096/Users/authenticatebyname \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Emby-Authorization: MediaBrowser Client="test", Device="test", DeviceId="test", Version="1.0"' \
|
||||
-d '{"Username":"baduser","Pw":"badpass"}' || true
|
||||
""")
|
||||
time.sleep(0.5)
|
||||
|
||||
with subtest("Verify IP is banned"):
|
||||
time.sleep(3)
|
||||
status = server.succeed("fail2ban-client status jellyfin")
|
||||
print(f"jellyfin jail status: {status}")
|
||||
# Check that at least 1 IP is banned
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}"
|
||||
|
||||
with subtest("Verify banned client cannot connect"):
|
||||
# Use -4 to test with same IP that was banned
|
||||
exit_code = client.execute("curl -4 -s --max-time 3 http://server:8096/ 2>&1")[0]
|
||||
assert exit_code != 0, "Connection should be blocked"
|
||||
'';
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
testServiceConfigs = {
|
||||
zpool_ssds = "";
|
||||
zpool_hdds = "";
|
||||
};
|
||||
|
||||
securityModule = import ../modules/security.nix;
|
||||
|
||||
sshModule =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(import ../services/ssh.nix {
|
||||
inherit config lib pkgs;
|
||||
username = "testuser";
|
||||
})
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "fail2ban-ssh";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
securityModule
|
||||
sshModule
|
||||
];
|
||||
|
||||
# Override for testing - enable password auth
|
||||
services.openssh.settings.PasswordAuthentication = lib.mkForce true;
|
||||
|
||||
users.users.testuser = {
|
||||
isNormalUser = true;
|
||||
password = "correctpassword";
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 22 ];
|
||||
};
|
||||
|
||||
client = {
|
||||
environment.systemPackages = with pkgs; [
|
||||
sshpass
|
||||
openssh
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("sshd.service")
|
||||
server.wait_for_unit("fail2ban.service")
|
||||
server.wait_for_open_port(22)
|
||||
time.sleep(2)
|
||||
|
||||
with subtest("Verify sshd jail is active"):
|
||||
status = server.succeed("fail2ban-client status")
|
||||
assert "sshd" in status, f"sshd jail not found in: {status}"
|
||||
|
||||
with subtest("Generate failed SSH login attempts"):
|
||||
# Use -4 to force IPv4, timeout and NumberOfPasswordPrompts=1 to ensure quick failure
|
||||
# maxRetry is 3 in our config, so 4 attempts should trigger a ban
|
||||
for i in range(4):
|
||||
client.execute(
|
||||
"timeout 5 sshpass -p 'wrongpassword' ssh -4 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 -o NumberOfPasswordPrompts=1 testuser@server echo test 2>/dev/null || true"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
with subtest("Verify IP is banned"):
|
||||
# Wait for fail2ban to process the logs and apply the ban
|
||||
time.sleep(5)
|
||||
status = server.succeed("fail2ban-client status sshd")
|
||||
print(f"sshd jail status: {status}")
|
||||
# Check that at least 1 IP is banned
|
||||
import re
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}"
|
||||
|
||||
with subtest("Verify banned client cannot connect"):
|
||||
# Use -4 to test with same IP that was banned
|
||||
exit_code = client.execute("timeout 3 nc -4 -z -w 2 server 22")[0]
|
||||
assert exit_code != 0, "Connection should be blocked for banned IP"
|
||||
'';
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
testServiceConfigs = {
|
||||
zpool_ssds = "";
|
||||
https = {
|
||||
domain = "test.local";
|
||||
};
|
||||
ports = {
|
||||
vaultwarden = 8222;
|
||||
};
|
||||
vaultwarden = {
|
||||
path = "/var/lib/vaultwarden";
|
||||
};
|
||||
};
|
||||
|
||||
testLib = lib.extend (
|
||||
final: prev: {
|
||||
serviceMountWithZpool =
|
||||
serviceName: zpool: dirs:
|
||||
{ ... }:
|
||||
{ };
|
||||
serviceFilePerms = serviceName: tmpfilesRules: { ... }: { };
|
||||
}
|
||||
);
|
||||
|
||||
vaultwardenModule =
|
||||
{ config, pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../services/bitwarden.nix {
|
||||
inherit config pkgs;
|
||||
lib = testLib;
|
||||
service_configs = testServiceConfigs;
|
||||
})
|
||||
];
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "fail2ban-vaultwarden";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
../modules/security.nix
|
||||
vaultwardenModule
|
||||
];
|
||||
|
||||
# Disable ZFS mount dependencies
|
||||
systemd.services."vaultwarden-mounts".enable = lib.mkForce false;
|
||||
systemd.services."backup-vaultwarden-mounts".enable = lib.mkForce false;
|
||||
systemd.services.vaultwarden = {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ ];
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
systemd.services.backup-vaultwarden = {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ ];
|
||||
requires = lib.mkForce [ ];
|
||||
};
|
||||
|
||||
# Override Vaultwarden settings for testing
|
||||
# - Listen on all interfaces (not just localhost)
|
||||
# - Enable logging at info level to capture failed login attempts
|
||||
services.vaultwarden.config = {
|
||||
ROCKET_ADDRESS = lib.mkForce "0.0.0.0";
|
||||
ROCKET_LOG = lib.mkForce "info";
|
||||
};
|
||||
|
||||
# Override for faster testing and correct port
|
||||
services.fail2ban.jails.vaultwarden.settings = {
|
||||
maxretry = lib.mkForce 3;
|
||||
# In test, we connect directly to Vaultwarden port, not via Caddy
|
||||
port = lib.mkForce "8222";
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 8222 ];
|
||||
};
|
||||
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import time
|
||||
import re
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("vaultwarden.service")
|
||||
server.wait_for_unit("fail2ban.service")
|
||||
server.wait_for_open_port(8222)
|
||||
time.sleep(2)
|
||||
|
||||
with subtest("Verify vaultwarden jail is active"):
|
||||
status = server.succeed("fail2ban-client status")
|
||||
assert "vaultwarden" in status, f"vaultwarden jail not found in: {status}"
|
||||
|
||||
with subtest("Generate failed login attempts"):
|
||||
# Use -4 to force IPv4 for consistent IP tracking
|
||||
for i in range(4):
|
||||
client.execute("""
|
||||
curl -4 -s -X POST 'http://server:8222/identity/connect/token' \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
-H 'Bitwarden-Client-Name: web' \
|
||||
-H 'Bitwarden-Client-Version: 2024.1.0' \
|
||||
-d 'grant_type=password&username=bad@user.com&password=badpass&scope=api+offline_access&client_id=web&deviceType=10&deviceIdentifier=test&deviceName=test' \
|
||||
|| true
|
||||
""")
|
||||
time.sleep(0.5)
|
||||
|
||||
with subtest("Verify IP is banned"):
|
||||
time.sleep(3)
|
||||
status = server.succeed("fail2ban-client status vaultwarden")
|
||||
print(f"vaultwarden jail status: {status}")
|
||||
# Check that at least 1 IP is banned
|
||||
match = re.search(r"Currently banned:\s*(\d+)", status)
|
||||
assert match and int(match.group(1)) >= 1, f"Expected at least 1 banned IP, got: {status}"
|
||||
|
||||
with subtest("Verify banned client cannot connect"):
|
||||
# Use -4 to test with same IP that was banned
|
||||
exit_code = client.execute("curl -4 -s --max-time 3 http://server:8222/ 2>&1")[0]
|
||||
assert exit_code != 0, "Connection should be blocked"
|
||||
'';
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
testPkgs = pkgs.appendOverlays [ (import ../modules/overlays.nix) ];
|
||||
in
|
||||
testPkgs.testers.runNixOSTest {
|
||||
name = "file-perms test";
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
(lib.serviceFilePerms "test-service" [
|
||||
"Z /tmp/test-perms-dir 0750 nobody nogroup"
|
||||
])
|
||||
];
|
||||
|
||||
systemd.services."test-service" = {
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = lib.getExe pkgs.bash;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
# Create test directory with wrong permissions
|
||||
machine.succeed("mkdir -p /tmp/test-perms-dir")
|
||||
machine.succeed("chown root:root /tmp/test-perms-dir")
|
||||
machine.succeed("chmod 700 /tmp/test-perms-dir")
|
||||
|
||||
# Start service -- this should pull in test-service-file-perms
|
||||
machine.succeed("systemctl start test-service")
|
||||
|
||||
# Verify file-perms service ran and is active
|
||||
machine.succeed("systemctl is-active test-service-file-perms.service")
|
||||
|
||||
# Verify permissions were fixed by tmpfiles
|
||||
result = machine.succeed("stat -c '%U:%G' /tmp/test-perms-dir").strip()
|
||||
assert result == "nobody:nogroup", f"Expected nobody:nogroup, got {result}"
|
||||
|
||||
result = machine.succeed("stat -c '%a' /tmp/test-perms-dir").strip()
|
||||
assert result == "750", f"Expected 750, got {result}"
|
||||
'';
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
payloads = {
|
||||
auth = pkgs.writeText "auth.json" (builtins.toJSON { Username = "jellyfin"; });
|
||||
empty = pkgs.writeText "empty.json" (builtins.toJSON { });
|
||||
};
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "jellyfin-qbittorrent-monitor";
|
||||
|
||||
nodes = {
|
||||
server =
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
inputs.vpn-confinement.nixosModules.default
|
||||
];
|
||||
|
||||
services.jellyfin.enable = true;
|
||||
|
||||
# Real qBittorrent service
|
||||
services.qbittorrent = {
|
||||
enable = true;
|
||||
webuiPort = 8080;
|
||||
openFirewall = true;
|
||||
|
||||
serverConfig.LegalNotice.Accepted = true;
|
||||
|
||||
serverConfig.Preferences = {
|
||||
WebUI = {
|
||||
# Disable authentication for testing
|
||||
AuthSubnetWhitelist = "0.0.0.0/0,::/0";
|
||||
AuthSubnetWhitelistEnabled = true;
|
||||
LocalHostAuth = false;
|
||||
};
|
||||
|
||||
Downloads = {
|
||||
SavePath = "/var/lib/qbittorrent/downloads";
|
||||
TempPath = "/var/lib/qbittorrent/incomplete";
|
||||
};
|
||||
};
|
||||
|
||||
serverConfig.BitTorrent.Session = {
|
||||
# Normal speed - unlimited
|
||||
GlobalUPSpeedLimit = 0;
|
||||
GlobalDLSpeedLimit = 0;
|
||||
|
||||
# Alternate speed limits for when Jellyfin is streaming
|
||||
AlternativeGlobalUPSpeedLimit = 100;
|
||||
AlternativeGlobalDLSpeedLimit = 100;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
curl
|
||||
ffmpeg
|
||||
];
|
||||
virtualisation.diskSize = 3 * 1024;
|
||||
networking.firewall.allowedTCPPorts = [
|
||||
8096
|
||||
8080
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||
{
|
||||
address = "192.168.1.1";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.routes = [
|
||||
{
|
||||
address = "203.0.113.0";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
|
||||
# Create directories for qBittorrent
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/qbittorrent/downloads 0755 qbittorrent qbittorrent"
|
||||
"d /var/lib/qbittorrent/incomplete 0755 qbittorrent qbittorrent"
|
||||
];
|
||||
};
|
||||
|
||||
# Public test IP (RFC 5737 TEST-NET-3) so Jellyfin sees it as external
|
||||
client = {
|
||||
environment.systemPackages = [ pkgs.curl ];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [
|
||||
{
|
||||
address = "203.0.113.10";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.routes = [
|
||||
{
|
||||
address = "192.168.1.0";
|
||||
prefixLength = 24;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
import json
|
||||
import time
|
||||
from urllib.parse import urlencode
|
||||
|
||||
auth_header = 'MediaBrowser Client="NixOS Test", DeviceId="test-1337", Device="TestDevice", Version="1.0"'
|
||||
|
||||
def api_get(path, token=None):
|
||||
header = auth_header + (f", Token={token}" if token else "")
|
||||
return f"curl -sf 'http://server:8096{path}' -H 'X-Emby-Authorization:{header}'"
|
||||
|
||||
def api_post(path, json_file=None, token=None):
|
||||
header = auth_header + (f", Token={token}" if token else "")
|
||||
if json_file:
|
||||
return f"curl -sf -X POST 'http://server:8096{path}' -d '@{json_file}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{header}'"
|
||||
return f"curl -sf -X POST 'http://server:8096{path}' -H 'X-Emby-Authorization:{header}'"
|
||||
|
||||
def is_throttled():
|
||||
return server.succeed("curl -s http://localhost:8080/api/v2/transfer/speedLimitsMode").strip() == "1"
|
||||
|
||||
def get_alt_dl_limit():
|
||||
prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences"))
|
||||
return prefs["alt_dl_limit"]
|
||||
|
||||
def get_alt_up_limit():
|
||||
prefs = json.loads(server.succeed("curl -s http://localhost:8080/api/v2/app/preferences"))
|
||||
return prefs["alt_up_limit"]
|
||||
|
||||
def are_torrents_paused():
|
||||
torrents = json.loads(server.succeed("curl -s 'http://localhost:8080/api/v2/torrents/info'"))
|
||||
if not torrents:
|
||||
return False
|
||||
return all(t["state"].startswith("stopped") for t in torrents)
|
||||
|
||||
movie_id: str = ""
|
||||
media_source_id: str = ""
|
||||
|
||||
start_all()
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
server.wait_for_unit("qbittorrent.service")
|
||||
server.wait_for_open_port(8080)
|
||||
|
||||
# Wait for qBittorrent WebUI to be responsive
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30)
|
||||
|
||||
with subtest("Complete Jellyfin setup wizard"):
|
||||
server.wait_until_succeeds(api_get("/Startup/Configuration"))
|
||||
server.succeed(api_get("/Startup/FirstUser"))
|
||||
server.succeed(api_post("/Startup/Complete"))
|
||||
|
||||
with subtest("Authenticate and get token"):
|
||||
auth_result = json.loads(server.succeed(api_post("/Users/AuthenticateByName", "${payloads.auth}")))
|
||||
token = auth_result["AccessToken"]
|
||||
user_id = auth_result["User"]["Id"]
|
||||
|
||||
with subtest("Create test video library"):
|
||||
tempdir = server.succeed("mktemp -d -p /var/lib/jellyfin").strip()
|
||||
server.succeed(f"chmod 755 '{tempdir}'")
|
||||
server.succeed(f"ffmpeg -f lavfi -i testsrc2=duration=5 '{tempdir}/Test Movie (2024) [1080p].mkv'")
|
||||
|
||||
add_folder_query = urlencode({
|
||||
"name": "Test Library",
|
||||
"collectionType": "Movies",
|
||||
"paths": tempdir,
|
||||
"refreshLibrary": "true",
|
||||
})
|
||||
server.succeed(api_post(f"/Library/VirtualFolders?{add_folder_query}", "${payloads.empty}", token))
|
||||
|
||||
def is_library_ready(_):
|
||||
folders = json.loads(server.succeed(api_get("/Library/VirtualFolders", token)))
|
||||
return all(f.get("RefreshStatus") == "Idle" for f in folders)
|
||||
retry(is_library_ready, timeout=60)
|
||||
|
||||
def get_movie(_):
|
||||
global movie_id, media_source_id
|
||||
items = json.loads(server.succeed(api_get(f"/Users/{user_id}/Items?IncludeItemTypes=Movie&Recursive=true", token)))
|
||||
if items["TotalRecordCount"] > 0:
|
||||
movie_id = items["Items"][0]["Id"]
|
||||
item_info = json.loads(server.succeed(api_get(f"/Users/{user_id}/Items/{movie_id}", token)))
|
||||
media_source_id = item_info["MediaSources"][0]["Id"]
|
||||
return True
|
||||
return False
|
||||
retry(get_movie, timeout=60)
|
||||
|
||||
with subtest("Start monitor service"):
|
||||
python = "${pkgs.python3.withPackages (ps: [ ps.requests ])}/bin/python"
|
||||
monitor = "${../services/jellyfin-qbittorrent-monitor.py}"
|
||||
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)
|
||||
assert not is_throttled(), "Should start unthrottled"
|
||||
|
||||
client_auth = 'MediaBrowser Client="External Client", DeviceId="external-9999", Device="ExternalDevice", Version="1.0"'
|
||||
client_auth2 = 'MediaBrowser Client="External Client 2", DeviceId="external-8888", Device="ExternalDevice2", Version="1.0"'
|
||||
server_ip = "192.168.1.1"
|
||||
|
||||
with subtest("Client authenticates from external network"):
|
||||
auth_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'"
|
||||
client_auth_result = json.loads(client.succeed(auth_cmd))
|
||||
client_token = client_auth_result["AccessToken"]
|
||||
|
||||
with subtest("Second client authenticates from external network"):
|
||||
auth_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'"
|
||||
client_auth_result2 = json.loads(client.succeed(auth_cmd2))
|
||||
client_token2 = client_auth_result2["AccessToken"]
|
||||
|
||||
with subtest("External video playback triggers throttling"):
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-1",
|
||||
"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)
|
||||
time.sleep(2)
|
||||
assert is_throttled(), "Should throttle for external video playback"
|
||||
|
||||
with subtest("Pausing disables throttling"):
|
||||
playback_progress = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-1",
|
||||
"IsPaused": True,
|
||||
"PositionTicks": 10000000,
|
||||
}
|
||||
progress_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Progress' -d '{json.dumps(playback_progress)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(progress_cmd)
|
||||
time.sleep(2)
|
||||
|
||||
assert not is_throttled(), "Should unthrottle when paused"
|
||||
|
||||
with subtest("Resuming re-enables throttling"):
|
||||
playback_progress["IsPaused"] = False
|
||||
playback_progress["PositionTicks"] = 20000000
|
||||
progress_cmd = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Progress' -d '{json.dumps(playback_progress)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(progress_cmd)
|
||||
time.sleep(2)
|
||||
|
||||
assert is_throttled(), "Should re-throttle when resumed"
|
||||
|
||||
with subtest("Stopping playback disables throttling"):
|
||||
playback_stop = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-1",
|
||||
"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)
|
||||
time.sleep(2)
|
||||
|
||||
assert not is_throttled(), "Should unthrottle when playback stops"
|
||||
|
||||
with subtest("Single stream sets proportional alt speed limits"):
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-proportional",
|
||||
"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)
|
||||
time.sleep(3)
|
||||
|
||||
assert is_throttled(), "Should be in alt speed mode during streaming"
|
||||
dl_limit = get_alt_dl_limit()
|
||||
ul_limit = get_alt_up_limit()
|
||||
# Both upload and download should get remaining bandwidth (proportional)
|
||||
assert dl_limit > 0, f"Download limit should be > 0, got {dl_limit}"
|
||||
assert ul_limit == dl_limit, f"Upload limit ({ul_limit}) should equal download limit ({dl_limit})"
|
||||
|
||||
# Stop playback
|
||||
playback_stop = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-proportional",
|
||||
"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)
|
||||
time.sleep(3)
|
||||
|
||||
with subtest("Multiple streams reduce available bandwidth"):
|
||||
# Start first stream
|
||||
playback1 = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-multi-1",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
start_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(start_cmd1)
|
||||
time.sleep(3)
|
||||
|
||||
single_dl_limit = get_alt_dl_limit()
|
||||
|
||||
# Start second stream with different client identity
|
||||
playback2 = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-multi-2",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
start_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing' -d '{json.dumps(playback2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'"
|
||||
client.succeed(start_cmd2)
|
||||
time.sleep(3)
|
||||
|
||||
dual_dl_limit = get_alt_dl_limit()
|
||||
# Two streams should leave less bandwidth than one stream
|
||||
assert dual_dl_limit < single_dl_limit, f"Two streams ({dual_dl_limit}) should have lower limit than one ({single_dl_limit})"
|
||||
|
||||
# Stop both streams
|
||||
stop1 = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-multi-1",
|
||||
"PositionTicks": 50000000,
|
||||
}
|
||||
stop_cmd1 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop1)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}, Token={client_token}'"
|
||||
client.succeed(stop_cmd1)
|
||||
|
||||
stop2 = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-multi-2",
|
||||
"PositionTicks": 50000000,
|
||||
}
|
||||
stop_cmd2 = f"curl -sf -X POST 'http://{server_ip}:8096/Sessions/Playing/Stopped' -d '{json.dumps(stop2)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}, Token={client_token2}'"
|
||||
client.succeed(stop_cmd2)
|
||||
time.sleep(3)
|
||||
|
||||
with subtest("Budget exhaustion pauses all torrents"):
|
||||
# Stop current monitor
|
||||
server.succeed("systemctl stop monitor-test || true")
|
||||
time.sleep(1)
|
||||
|
||||
# Add a dummy torrent so we can check pause state
|
||||
server.succeed("curl -sf -X POST 'http://localhost:8080/api/v2/torrents/add' -d 'urls=magnet:?xt=urn:btih:0000000000000000000000000000000000000001%26dn=test-torrent'")
|
||||
time.sleep(2)
|
||||
|
||||
# Start monitor with impossibly low budget
|
||||
server.succeed(f"""
|
||||
systemd-run --unit=monitor-exhaust \
|
||||
--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=1000 \
|
||||
--setenv=SERVICE_BUFFER=500 \
|
||||
--setenv=DEFAULT_STREAM_BITRATE=10000000 \
|
||||
--setenv=MIN_TORRENT_SPEED=100 \
|
||||
{python} {monitor}
|
||||
""")
|
||||
time.sleep(2)
|
||||
|
||||
# Start a stream - this will exceed the tiny budget
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-exhaust",
|
||||
"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)
|
||||
time.sleep(3)
|
||||
|
||||
assert are_torrents_paused(), "Torrents should be paused when budget is exhausted"
|
||||
|
||||
with subtest("Recovery from pause restores unlimited"):
|
||||
# Stop the stream
|
||||
playback_stop = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-exhaust",
|
||||
"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)
|
||||
time.sleep(3)
|
||||
|
||||
assert not is_throttled(), "Should return to unlimited after streams stop"
|
||||
assert not are_torrents_paused(), "Torrents should be resumed after streams stop"
|
||||
|
||||
# Clean up: stop exhaust monitor, restart normal monitor
|
||||
server.succeed("systemctl stop monitor-exhaust || 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)
|
||||
|
||||
with subtest("Local playback does NOT trigger throttling"):
|
||||
local_auth = 'MediaBrowser Client="Local Client", DeviceId="local-1111", Device="LocalDevice", Version="1.0"'
|
||||
local_auth_result = json.loads(server.succeed(
|
||||
f"curl -sf -X POST 'http://localhost:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}'"
|
||||
))
|
||||
local_token = local_auth_result["AccessToken"]
|
||||
|
||||
local_playback = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-local",
|
||||
"CanSeek": True,
|
||||
"IsPaused": False,
|
||||
}
|
||||
server.succeed(f"curl -sf -X POST 'http://localhost:8096/Sessions/Playing' -d '{json.dumps(local_playback)}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{local_auth}, Token={local_token}'")
|
||||
time.sleep(2)
|
||||
assert not is_throttled(), "Should NOT throttle for local playback"
|
||||
|
||||
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}'")
|
||||
|
||||
# === SERVICE RESTART TESTS ===
|
||||
|
||||
with subtest("qBittorrent restart during throttled state re-applies throttling"):
|
||||
# Start external playback to trigger throttling
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-restart-1",
|
||||
"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)
|
||||
time.sleep(2)
|
||||
assert is_throttled(), "Should be throttled before qBittorrent restart"
|
||||
|
||||
# Restart qBittorrent (this resets alt_speed to its config default - disabled)
|
||||
server.succeed("systemctl restart qbittorrent.service")
|
||||
server.wait_for_unit("qbittorrent.service")
|
||||
server.wait_for_open_port(8080)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30)
|
||||
|
||||
# qBittorrent restarted - alt_speed is now False (default on startup)
|
||||
# The monitor should detect this and re-apply throttling
|
||||
time.sleep(3) # Give monitor time to detect and re-apply
|
||||
assert is_throttled(), "Monitor should re-apply throttling after qBittorrent restart"
|
||||
|
||||
# Stop playback to clean up
|
||||
playback_stop = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-restart-1",
|
||||
"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)
|
||||
time.sleep(2)
|
||||
|
||||
with subtest("qBittorrent restart during unthrottled state stays unthrottled"):
|
||||
# Verify we're unthrottled (no active streams)
|
||||
assert not is_throttled(), "Should be unthrottled before test"
|
||||
|
||||
# Restart qBittorrent
|
||||
server.succeed("systemctl restart qbittorrent.service")
|
||||
server.wait_for_unit("qbittorrent.service")
|
||||
server.wait_for_open_port(8080)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8080/api/v2/app/version", timeout=30)
|
||||
|
||||
# Give monitor time to check state
|
||||
time.sleep(3)
|
||||
assert not is_throttled(), "Should remain unthrottled after qBittorrent restart with no streams"
|
||||
|
||||
with subtest("Jellyfin restart during throttled state maintains throttling"):
|
||||
# Start external playback to trigger throttling
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-restart-2",
|
||||
"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)
|
||||
time.sleep(2)
|
||||
assert is_throttled(), "Should be throttled before Jellyfin restart"
|
||||
|
||||
# Restart Jellyfin
|
||||
server.succeed("systemctl restart jellyfin.service")
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
|
||||
# During Jellyfin restart, monitor can't reach Jellyfin
|
||||
# After restart, sessions are cleared - monitor should eventually unthrottle
|
||||
# But during the unavailability window, throttling should be maintained (fail-safe)
|
||||
time.sleep(3)
|
||||
|
||||
# Re-authenticate (old token invalid after restart)
|
||||
client_auth_result = json.loads(client.succeed(
|
||||
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'"
|
||||
))
|
||||
client_token = client_auth_result["AccessToken"]
|
||||
client_auth_result2 = json.loads(client.succeed(
|
||||
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'"
|
||||
))
|
||||
client_token2 = client_auth_result2["AccessToken"]
|
||||
|
||||
# No active streams after Jellyfin restart, should eventually unthrottle
|
||||
time.sleep(3)
|
||||
assert not is_throttled(), "Should unthrottle after Jellyfin restart clears sessions"
|
||||
|
||||
with subtest("Monitor recovers after Jellyfin temporary unavailability"):
|
||||
# Re-authenticate with fresh token
|
||||
client_auth_result = json.loads(client.succeed(
|
||||
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth}'"
|
||||
))
|
||||
client_token = client_auth_result["AccessToken"]
|
||||
client_auth_result2 = json.loads(client.succeed(
|
||||
f"curl -sf -X POST 'http://{server_ip}:8096/Users/AuthenticateByName' -d '@${payloads.auth}' -H 'Content-Type:application/json' -H 'X-Emby-Authorization:{client_auth2}'"
|
||||
))
|
||||
client_token2 = client_auth_result2["AccessToken"]
|
||||
|
||||
# Start playback
|
||||
playback_start = {
|
||||
"ItemId": movie_id,
|
||||
"MediaSourceId": media_source_id,
|
||||
"PlaySessionId": "test-play-session-restart-3",
|
||||
"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)
|
||||
time.sleep(2)
|
||||
assert is_throttled(), "Should be throttled"
|
||||
|
||||
# Stop Jellyfin briefly (simulating temporary unavailability)
|
||||
server.succeed("systemctl stop jellyfin.service")
|
||||
time.sleep(2)
|
||||
|
||||
# During unavailability, throttle state should be maintained (fail-safe)
|
||||
assert is_throttled(), "Should maintain throttle during Jellyfin unavailability"
|
||||
|
||||
# Bring Jellyfin back
|
||||
server.succeed("systemctl start jellyfin.service")
|
||||
server.wait_for_unit("jellyfin.service")
|
||||
server.wait_for_open_port(8096)
|
||||
server.wait_until_succeeds("curl -sf http://localhost:8096/health | grep -q Healthy", timeout=60)
|
||||
|
||||
# After Jellyfin comes back, sessions are gone - should unthrottle
|
||||
time.sleep(3)
|
||||
assert not is_throttled(), "Should unthrottle after Jellyfin returns with no sessions"
|
||||
'';
|
||||
}
|
||||
@@ -6,52 +6,57 @@
|
||||
...
|
||||
}:
|
||||
let
|
||||
testServiceConfigs = {
|
||||
minecraft = {
|
||||
server_name = "main";
|
||||
parent_dir = "/var/lib/minecraft";
|
||||
};
|
||||
https = {
|
||||
domain = "test.local";
|
||||
};
|
||||
ports = {
|
||||
minecraft = 25565;
|
||||
};
|
||||
zpool_ssds = "";
|
||||
};
|
||||
|
||||
# Create pkgs with nix-minecraft overlay and unfree packages allowed
|
||||
testPkgs = import inputs.nixpkgs {
|
||||
system = pkgs.stdenv.targetPlatform.system;
|
||||
system = pkgs.system;
|
||||
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "minecraft-server" ];
|
||||
overlays = [
|
||||
inputs.nix-minecraft.overlay
|
||||
(import ../modules/overlays.nix)
|
||||
(import ../overlays.nix)
|
||||
];
|
||||
};
|
||||
|
||||
# Create a wrapper module that imports the actual minecraft service
|
||||
minecraftService =
|
||||
{ config, ... }:
|
||||
{
|
||||
imports = [
|
||||
(import ../services/minecraft.nix {
|
||||
inherit lib config;
|
||||
pkgs = testPkgs;
|
||||
service_configs = {
|
||||
minecraft = {
|
||||
server_name = "main";
|
||||
parent_dir = "/var/lib/minecraft";
|
||||
};
|
||||
https = {
|
||||
domain = "test.local";
|
||||
};
|
||||
zpool_ssds = "";
|
||||
};
|
||||
username = "testuser";
|
||||
})
|
||||
];
|
||||
# Override nixpkgs config to prevent conflicts in test environment
|
||||
nixpkgs.config = lib.mkForce {
|
||||
allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "minecraft-server" ];
|
||||
};
|
||||
};
|
||||
in
|
||||
testPkgs.testers.runNixOSTest {
|
||||
name = "minecraft server startup test";
|
||||
|
||||
node.specialArgs = {
|
||||
inherit inputs lib;
|
||||
service_configs = testServiceConfigs;
|
||||
username = "testuser";
|
||||
};
|
||||
|
||||
nodes.machine =
|
||||
{ lib, ... }:
|
||||
{ ... }:
|
||||
{
|
||||
imports = [
|
||||
../services/minecraft.nix
|
||||
inputs.nix-minecraft.nixosModules.minecraft-servers
|
||||
minecraftService
|
||||
];
|
||||
|
||||
# Enable caddy service (required by minecraft service)
|
||||
services.caddy.enable = true;
|
||||
|
||||
# Enable networking for the test (needed for minecraft mods to download mappings)
|
||||
networking.dhcpcd.enable = true;
|
||||
|
||||
# Disable the ZFS mount dependency service in test environment
|
||||
systemd.services."minecraft-server-main_mounts".enable = lib.mkForce false;
|
||||
|
||||
@@ -60,10 +65,6 @@ testPkgs.testers.runNixOSTest {
|
||||
wants = lib.mkForce [ ];
|
||||
after = lib.mkForce [ ];
|
||||
requires = lib.mkForce [ ];
|
||||
serviceConfig = {
|
||||
Nice = lib.mkForce 0;
|
||||
LimitMEMLOCK = lib.mkForce "infinity";
|
||||
};
|
||||
};
|
||||
|
||||
# Test-specific overrides only - reduce memory for testing
|
||||
@@ -84,15 +85,9 @@ testPkgs.testers.runNixOSTest {
|
||||
# Wait for minecraft service to be available
|
||||
machine.wait_for_unit("minecraft-server-main.service")
|
||||
|
||||
# Wait up to 60 seconds for the server to complete startup
|
||||
with machine.nested("Waiting for minecraft server startup completion"):
|
||||
try:
|
||||
machine.wait_until_succeeds(
|
||||
"grep -Eq '\\[[0-9]+:[0-9]+:[0-9]+\\] \\[Server thread/INFO\\]: Done \\([0-9]+\\.[0-9]+s\\)! For help, type \"help\"' /var/lib/minecraft/main/logs/latest.log",
|
||||
timeout=120
|
||||
)
|
||||
except Exception:
|
||||
print(machine.succeed("cat /var/lib/minecraft/main/logs/latest.log"))
|
||||
raise
|
||||
machine.sleep(20)
|
||||
|
||||
# Check that the service is active and not failed
|
||||
machine.succeed("systemctl is-active minecraft-server-main.service")
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
{
|
||||
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!")
|
||||
'';
|
||||
}
|
||||
@@ -11,17 +11,4 @@ in
|
||||
zfsTest = handleTest ./zfs.nix;
|
||||
testTest = handleTest ./testTest.nix;
|
||||
minecraftTest = handleTest ./minecraft.nix;
|
||||
jellyfinQbittorrentMonitorTest = handleTest ./jellyfin-qbittorrent-monitor.nix;
|
||||
filePermsTest = handleTest ./file-perms.nix;
|
||||
|
||||
# fail2ban tests
|
||||
fail2banSshTest = handleTest ./fail2ban-ssh.nix;
|
||||
fail2banCaddyTest = handleTest ./fail2ban-caddy.nix;
|
||||
fail2banGiteaTest = handleTest ./fail2ban-gitea.nix;
|
||||
fail2banVaultwardenTest = handleTest ./fail2ban-vaultwarden.nix;
|
||||
fail2banImmichTest = handleTest ./fail2ban-immich.nix;
|
||||
fail2banJellyfinTest = handleTest ./fail2ban-jellyfin.nix;
|
||||
|
||||
# ntfy alerts test
|
||||
ntfyAlertsTest = handleTest ./ntfy-alerts.nix;
|
||||
}
|
||||
|
||||
127
tests/zfs.nix
127
tests/zfs.nix
@@ -7,78 +7,58 @@
|
||||
}:
|
||||
let
|
||||
# Create pkgs with ensureZfsMounts overlay
|
||||
testPkgs = pkgs.appendOverlays [ (import ../modules/overlays.nix) ];
|
||||
testPkgs = import inputs.nixpkgs {
|
||||
system = pkgs.system;
|
||||
overlays = [ (import ../overlays.nix) ];
|
||||
};
|
||||
in
|
||||
testPkgs.testers.runNixOSTest {
|
||||
name = "zfs test";
|
||||
name = "zfs folder dependency and mounting test";
|
||||
|
||||
nodes.machine =
|
||||
{ pkgs, ... }:
|
||||
{
|
||||
imports = [
|
||||
# Test valid paths within zpool
|
||||
(lib.serviceMountWithZpool "test-service" "rpool" [ "/mnt/rpool_data" ])
|
||||
(lib.serviceMountDeps "foobar" [ "/mnt/foobar_data" ])
|
||||
(lib.serviceMountDeps "foobarSadge" [
|
||||
"/mnt/foobar_data"
|
||||
"/mnt/does_not_exist_lol"
|
||||
])
|
||||
|
||||
# Test service with paths outside zpool (should fail assertion)
|
||||
(lib.serviceMountWithZpool "invalid-service" "rpool2" [ "/mnt/rpool_data" ])
|
||||
|
||||
# Test multi-command logic: service with multiple serviceMountWithZpool calls
|
||||
(lib.serviceMountWithZpool "multi-service" "rpool" [ "/mnt/rpool_data" ])
|
||||
(lib.serviceMountWithZpool "multi-service" "rpool2" [ "/mnt/rpool2_data" ])
|
||||
|
||||
# Test multi-command logic: service with multiple serviceMountWithZpool calls
|
||||
# BUT this one should fail as `/mnt/rpool_moar_data` is not on rpool2
|
||||
(lib.serviceMountWithZpool "multi-service-fail" "rpool" [ "/mnt/rpool_data" ])
|
||||
(lib.serviceMountWithZpool "multi-service-fail" "rpool2" [ "/mnt/rpool_moar_data" ])
|
||||
];
|
||||
|
||||
virtualisation = {
|
||||
emptyDiskImages = [
|
||||
4096
|
||||
4096
|
||||
];
|
||||
# Add this to avoid ZFS hanging issues
|
||||
additionalPaths = [ pkgs.zfs ];
|
||||
useBootLoader = true;
|
||||
useEFIBoot = true;
|
||||
};
|
||||
boot.loader.systemd-boot.enable = true;
|
||||
boot.loader.timeout = 0;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
networking.hostId = "deadbeef";
|
||||
boot.kernelPackages = config.boot.kernelPackages;
|
||||
boot.zfs.package = config.boot.zfs.package;
|
||||
boot.supportedFilesystems = [ "zfs" ];
|
||||
|
||||
environment.systemPackages = with pkgs; [
|
||||
parted
|
||||
ensureZfsMounts
|
||||
environment.systemPackages = [
|
||||
pkgs.parted
|
||||
pkgs.ensureZfsMounts
|
||||
];
|
||||
|
||||
systemd.services."test-service" = {
|
||||
systemd.services.foobar = {
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = lib.getExe pkgs.bash;
|
||||
ExecStart = "${lib.getExe pkgs.bash} -c \"true\"";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services."invalid-service" = {
|
||||
systemd.services.foobarSadge = {
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = lib.getExe pkgs.bash;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services."multi-service" = {
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = lib.getExe pkgs.bash;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services."multi-service-fail" = {
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = lib.getExe pkgs.bash;
|
||||
ExecStart = "${lib.getExe pkgs.bash} -c \"true\"";
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -86,68 +66,37 @@ testPkgs.testers.runNixOSTest {
|
||||
testScript = ''
|
||||
start_all()
|
||||
machine.wait_for_unit("multi-user.target")
|
||||
|
||||
# Setup ZFS pool
|
||||
machine.succeed(
|
||||
"parted --script /dev/vdb mklabel msdos",
|
||||
"parted --script /dev/vdb -- mkpart primary 1024M -1s",
|
||||
"zpool create rpool /dev/vdb1"
|
||||
)
|
||||
|
||||
# Setup ZFS pool 2
|
||||
machine.succeed(
|
||||
"parted --script /dev/vdc mklabel msdos",
|
||||
"parted --script /dev/vdc -- mkpart primary 1024M -1s",
|
||||
"zpool create rpool2 /dev/vdc1"
|
||||
)
|
||||
machine.fail("zfsEnsureMounted")
|
||||
machine.fail("zfsEnsureMounted /mnt/test_mountpoint")
|
||||
|
||||
machine.succeed("zfs create -o mountpoint=/mnt/rpool_data rpool/data")
|
||||
machine.succeed("zpool create rpool /dev/vdb1")
|
||||
machine.succeed("zfs create -o mountpoint=/mnt/test_mountpoint rpool/test")
|
||||
|
||||
machine.succeed("zfs create -o mountpoint=/mnt/rpool2_data rpool2/data")
|
||||
machine.succeed("zfsEnsureMounted /mnt/test_mountpoint")
|
||||
|
||||
machine.succeed("zfs create -o mountpoint=/mnt/rpool_moar_data rpool/moar_data")
|
||||
machine.fail("zfsEnsureMounted /mnt/does_not_exist_lol")
|
||||
machine.fail("zfsEnsureMounted /mnt/test_mountpoint /mnt/does_not_exist_lol")
|
||||
|
||||
# Test that valid service starts successfully
|
||||
machine.succeed("systemctl start test-service")
|
||||
machine.succeed("zfs create -o mountpoint=/mnt/test_mountpoint_dos rpool/test2")
|
||||
|
||||
# Manually test our validation logic by checking the debug output
|
||||
zfs_output = machine.succeed("zfs list -H -o name,mountpoint")
|
||||
print("ZFS LIST OUTPUT:")
|
||||
print(zfs_output)
|
||||
machine.succeed("zfsEnsureMounted /mnt/test_mountpoint /mnt/test_mountpoint_dos")
|
||||
|
||||
dataset = machine.succeed("zfs list -H -o name,mountpoint | awk '/\\/mnt\\/rpool_data/ { print $1 }'")
|
||||
print("DATASET FOR /mnt/rpool_data:")
|
||||
print(dataset)
|
||||
machine.succeed("zfs create -o mountpoint='/mnt/test path with spaces' rpool/test3")
|
||||
|
||||
# Test that invalid-service mount service fails validation
|
||||
machine.fail("systemctl start invalid-service.service")
|
||||
machine.succeed("zfsEnsureMounted '/mnt/test path with spaces'")
|
||||
|
||||
# Check the journal for our detailed validation error message
|
||||
journal_output = machine.succeed("journalctl -u invalid-service-mounts.service --no-pager")
|
||||
print("JOURNAL OUTPUT:")
|
||||
print(journal_output)
|
||||
# machine.succeed("zfsEnsureMounted /mnt/test\\ escaped\\ spaces") # TODO! fix escaped spaces
|
||||
|
||||
# Verify our validation error is in the journal using Python string matching
|
||||
assert "ERROR: ZFS pool mismatch for /mnt/rpool_data" in journal_output
|
||||
assert "Expected pool: rpool2" in journal_output
|
||||
assert "Actual pool: rpool" in journal_output
|
||||
machine.succeed("zfsEnsureMounted /mnt/test_mountpoint '/mnt/test path with spaces' /mnt/test_mountpoint_dos")
|
||||
|
||||
machine.succeed("zfs create -o mountpoint=/mnt/foobar_data rpool/foobar")
|
||||
machine.succeed("systemctl start foobar")
|
||||
|
||||
# Test that invalid-service mount service fails validation
|
||||
machine.fail("systemctl start multi-service-fail.service")
|
||||
|
||||
# Check the journal for our detailed validation error message
|
||||
journal_output = machine.succeed("journalctl -u multi-service-fail-mounts.service --no-pager")
|
||||
print("JOURNAL OUTPUT:")
|
||||
print(journal_output)
|
||||
|
||||
# Verify our validation error is in the journal using Python string matching
|
||||
assert "ERROR: ZFS pool mismatch for /mnt/rpool_moar_data" in journal_output, "no zfs pool mismatch found (1)"
|
||||
assert "Expected pool: rpool2" in journal_output, "no zfs pool mismatch found (2)"
|
||||
assert "Actual pool: rpool" in journal_output, "no zfs pool mismatch found (3)"
|
||||
|
||||
|
||||
machine.succeed("systemctl start multi-service")
|
||||
machine.succeed("systemctl is-active multi-service-mounts.service")
|
||||
machine.fail("systemctl start foobarSadge")
|
||||
'';
|
||||
}
|
||||
|
||||
1
usb-secrets/usb-secrets/usb-secrets-key.pub
Normal file
1
usb-secrets/usb-secrets/usb-secrets-key.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN8+eSX2LH5wEHVG9sSv97ceD5zdTarV0lRvoUso4A7p USB secrets decryption key
|
||||
@@ -27,17 +27,13 @@ in
|
||||
|
||||
boot.kernelParams =
|
||||
let
|
||||
gb = 32;
|
||||
gb = 20;
|
||||
mb = gb * 1000;
|
||||
kb = mb * 1000;
|
||||
b = kb * 1000;
|
||||
in
|
||||
[
|
||||
"zfs.zfs_arc_max=${builtins.toString b}"
|
||||
"zfs.zfs_txg_timeout=30" # longer TXG open time = larger sequential writes = better HDD throughput
|
||||
"zfs.zfs_dirty_data_max=8589934592" # 8GB dirty data buffer (default 4GB) for USB HDD write smoothing
|
||||
"zfs.zfs_delay_min_dirty_percent=80" # delay write throttling until 80% dirty (default 60%)
|
||||
"zfs.zfs_vdev_async_write_max_active=30" # more concurrent async writes to vdevs (default 10)
|
||||
];
|
||||
|
||||
boot.supportedFilesystems = [ "zfs" ];
|
||||
@@ -68,7 +64,7 @@ in
|
||||
yearly = 0;
|
||||
};
|
||||
|
||||
datasets."${service_configs.zpool_ssds}/services/jellyfin/cache" = {
|
||||
datasets."${service_configs.zpool_ssds}/services/jellyfin_cache" = {
|
||||
recursive = true;
|
||||
autoprune = true;
|
||||
autosnap = true;
|
||||
Reference in New Issue
Block a user