Merge branch 'main' into eap/api-key-extractor

This commit is contained in:
Rasmus Kirk
2025-10-23 12:29:06 +00:00
committed by GitHub
15 changed files with 1512 additions and 29 deletions
@@ -1,16 +1,16 @@
name: Formatting
name: Lint
on:
# Triggers the workflow on push or pull request events but only for the main branch
push:
branches: [main, release**]
pull_request:
branches: [main, release**]
branches: [main, dev, release**]
# Allows us to run the workflow manually from the Actions tab
workflow_dispatch:
jobs:
check-formatting:
fmt:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v3
@@ -21,3 +21,15 @@ jobs:
uses: DeterminateSystems/nix-installer-action@main
- run: nix fmt -- --check .
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- run: nix flake check
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## Unreleased
Added:
- `whisparr` service
- `komgarr` service
## 2025-06-03
Added:
Generated
+6 -6
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1748662220,
"narHash": "sha256-7gGa49iB9nCnFk4h/g9zwjlQAyjtpgcFkODjcOQS0Es=",
"lastModified": 1750183894,
"narHash": "sha256-ZtOgEt70keBVB4YJc+z7m0h7J1BOlv/GjHE1YC6KxeA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "59138c7667b7970d205d6a05a8bfa2d78caa3643",
"rev": "f45e75fc63fc8a7ffc3da382b2f6b681c5b71875",
"type": "github"
},
"original": {
@@ -25,11 +25,11 @@
},
"vpnconfinement": {
"locked": {
"lastModified": 1743810720,
"narHash": "sha256-kbv/W4gizUSa6qH2rUQdgPj9AJaeN9k2XSWUYqj7IMU=",
"lastModified": 1749672087,
"narHash": "sha256-j8LG0s0QcvNkZZLcItl78lvTZemvsScir0dG3Ii4B1c=",
"owner": "Maroka-chan",
"repo": "VPN-Confinement",
"rev": "74ae51e6d18b972ecc918ab43e8bde60c21a65d8",
"rev": "880b3bd2c864dce4f6afc79f6580ca699294c011",
"type": "github"
},
"original": {
+16 -1
View File
@@ -29,11 +29,26 @@
forAllSystems = f:
nixpkgs.lib.genAttrs supportedSystems (system:
f {
pkgs = import nixpkgs {inherit system;};
pkgs = import nixpkgs { inherit system; config.allowUnfree = true; };
});
in {
nixosModules.default.imports = [./nixarr vpnconfinement.nixosModules.default];
# Add tests attribute to the flake outputs
# To run interactively run:
# > nix build .#checks.x86_64-linux.monitoring-test.driver -L
checks = forAllSystems ({pkgs}: {
permissions-test = pkgs.callPackage ./tests/permissions-test.nix {
inherit (self) nixosModules;
};
simple-test = pkgs.callPackage ./tests/simple-test.nix {
inherit (self) nixosModules;
};
# vpn-confinement-test = pkgs.callPackage ./tests/vpn-confinement-test.nix {
# inherit (self) nixosModules;
# };
});
devShells = forAllSystems ({pkgs}: {
default = pkgs.mkShell {
packages = with pkgs; [
+2
View File
@@ -100,6 +100,8 @@ in {
'';
KillSignal = "SIGINT";
Restart = "on-failure";
KillSignal = "SIGINT";
SuccessExitStatus = "0 156";
};
};
+2
View File
@@ -16,6 +16,7 @@ in {
./jellyfin
./jellyseerr
./lib/api-keys.nix
./komga
./lidarr
./nixarr-command
./openssh
@@ -28,6 +29,7 @@ in {
./sabnzbd
./sonarr
./transmission
./whisparr
../util
];
+237
View File
@@ -0,0 +1,237 @@
{
config,
lib,
...
}:
with lib; let
cfg = config.nixarr.komga;
globals = config.util-nixarr.globals;
defaultPort = 25600;
nixarr = config.nixarr;
in {
options.nixarr.komga = {
enable = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Whether or not to enable the Komga service.
**Conflicting options:** [`nixarr.plex.enable`](#nixarr.plex.enable)
'';
};
stateDir = mkOption {
type = types.path;
default = "${nixarr.stateDir}/komga";
defaultText = literalExpression ''"''${nixarr.stateDir}/komga"'';
example = "/nixarr/.state/komga";
description = ''
The location of the state directory for the Komga service.
> **Warning:** Setting this to any path, where the subpath is not
> owned by root, will fail! For example:
>
> ```nix
> stateDir = /home/user/nixarr/.state/komga
> ```
>
> Is not supported, because `/home/user` is owned by `user`.
'';
};
openFirewall = mkOption {
type = types.bool;
defaultText = literalExpression ''!nixarr.komga.vpn.enable'';
default = !cfg.vpn.enable;
example = true;
description = "Open firewall for Komga";
};
vpn.enable = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
**Required options:** [`nixarr.vpn.enable`](#nixarr.vpn.enable)
**Conflicting options:** [`nixarr.komga.expose.https.enable`](#nixarr.komga.expose.https.enable)
Route Komga traffic through the VPN.
'';
};
expose = {
https = {
enable = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
**Required options:**
- [`nixarr.komga.expose.https.acmeMail`](#nixarr.komga.expose.https.acmemail)
- [`nixarr.komga.expose.https.domainName`](#nixarr.komga.expose.https.domainname)
**Conflicting options:** [`nixarr.komga.vpn.enable`](#nixarr.komga.vpn.enable)
Expose the Komga web service to the internet with https support,
allowing anyone to access it.
> **Warning:** Do _not_ enable this without setting up Komga
> authentication through localhost first!
'';
};
upnp.enable = mkEnableOption "UPNP to try to open ports 80 and 443 on your router.";
domainName = mkOption {
type = types.nullOr types.str;
default = null;
example = "komga.example.com";
description = "The domain name to host Komga on.";
};
acmeMail = mkOption {
type = types.nullOr types.str;
default = null;
example = "mail@example.com";
description = "The ACME mail required for the letsencrypt bot.";
};
};
};
};
config = mkIf (nixarr.enable && cfg.enable) {
assertions = [
{
assertion = cfg.vpn.enable -> nixarr.vpn.enable;
message = ''
The nixarr.komga.vpn.enable option requires the
nixarr.vpn.enable option to be set, but it was not.
'';
}
{
assertion = !(cfg.vpn.enable && cfg.expose.https.enable);
message = ''
The nixarr.komga.vpn.enable option conflicts with the
nixarr.komga.expose.https.enable option. You cannot set both.
'';
}
{
assertion =
cfg.expose.https.enable
-> ((cfg.expose.https.domainName != null) && (cfg.expose.https.acmeMail != null));
message = ''
The nixarr.komga.expose.https.enable option requires the
following options to be set, but one of them were not:
- nixarr.komga.expose.domainName
- nixarr.komga.expose.acmeMail
'';
}
];
users = {
groups.${globals.komga.group}.gid = globals.gids.${globals.komga.group};
users.${globals.komga.user} = {
isSystemUser = true;
group = globals.komga.group;
uid = globals.uids.${globals.komga.user};
};
};
systemd.tmpfiles.rules = [
"d '${cfg.stateDir}' 0700 ${globals.komga.user} root - -"
"d '${cfg.stateDir}/log' 0700 ${globals.komga.user} root - -"
"d '${cfg.stateDir}/cache' 0700 ${globals.komga.user} root - -"
"d '${cfg.stateDir}/data' 0700 ${globals.komga.user} root - -"
"d '${cfg.stateDir}/config' 0700 ${globals.komga.user} root - -"
# Media Dirs
"d '${nixarr.mediaDir}/library' 0775 ${globals.libraryOwner.user} ${globals.libraryOwner.group} - -"
"d '${nixarr.mediaDir}/library/books' 0775 ${globals.libraryOwner.user} ${globals.libraryOwner.group} - -"
];
services.komga = {
enable = cfg.enable;
user = globals.komga.user;
group = globals.komga.group;
openFirewall = cfg.openFirewall;
stateDir = cfg.stateDir;
settings.server.port = defaultPort;
};
networking.firewall = mkIf cfg.expose.https.enable {
allowedTCPPorts = [
80
443
];
};
util-nixarr.upnp = mkIf cfg.expose.https.upnp.enable {
enable = true;
openTcpPorts = [
80
443
];
};
services.nginx = mkMerge [
(mkIf (cfg.expose.https.enable || cfg.vpn.enable) {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
})
(mkIf cfg.expose.https.enable {
virtualHosts."${builtins.replaceStrings ["\n"] [""] cfg.expose.https.domainName}" = {
enableACME = true;
forceSSL = true;
locations."/" = {
recommendedProxySettings = true;
proxyWebsockets = true;
proxyPass = "http://127.0.0.1:${builtins.toString defaultPort}";
};
};
})
(mkIf cfg.vpn.enable {
virtualHosts."127.0.0.1:${builtins.toString defaultPort}" = mkIf cfg.vpn.enable {
listen = [
{
addr = "0.0.0.0";
port = defaultPort;
}
];
locations."/" = {
recommendedProxySettings = true;
proxyWebsockets = true;
proxyPass = "http://192.168.15.1:${builtins.toString defaultPort}";
};
};
})
];
security.acme = mkIf cfg.expose.https.enable {
acceptTerms = true;
defaults.email = cfg.expose.https.acmeMail;
};
# Enable and specify VPN namespace to confine service in.
systemd.services.komga.vpnConfinement = mkIf cfg.vpn.enable {
enable = true;
vpnNamespace = "wg";
};
# Port mappings
vpnNamespaces.wg = mkIf cfg.vpn.enable {
portMappings = [
{
from = defaultPort;
to = defaultPort;
}
];
};
};
}
+17 -6
View File
@@ -41,18 +41,18 @@ with lib; let
fi
find "${nixarr.mediaDir}" \( -type d -exec chmod 0775 {} + -true \) -o \( -exec chmod 0664 {} + \)
${strings.optionalString nixarr.jellyfin.enable ''
mkdir -p "${nixarr.mediaDir}/library"
chown -R ${globals.libraryOwner.user}:${globals.libraryOwner.group} "${nixarr.mediaDir}/library"
${strings.optionalString nixarr.jellyfin.enable ''
chown -R ${globals.jellyfin.user}:root "${nixarr.jellyfin.stateDir}"
find "${nixarr.jellyfin.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
${strings.optionalString nixarr.plex.enable ''
chown -R ${globals.libraryOwner.user}:${globals.libraryOwner.group} "${nixarr.mediaDir}/library"
chown -R ${globals.plex.user}:root "${nixarr.plex.stateDir}"
find "${nixarr.plex.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
${strings.optionalString nixarr.audiobookshelf.enable ''
chown -R ${globals.libraryOwner.user}:${globals.libraryOwner.group} "${nixarr.mediaDir}/library"
chown -R ${globals.audiobookshelf.user}:root "${nixarr.audiobookshelf.stateDir}"
find "${nixarr.audiobookshelf.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
@@ -95,7 +95,7 @@ with lib; let
find "${nixarr.readarr.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
${strings.optionalString nixarr.readarr-audiobook.enable ''
chown -R ${globals.readarr.user}:root "${nixarr.readarr-audiobook.stateDir}"
chown -R ${globals.readarr-audiobook.user}:root "${nixarr.readarr-audiobook.stateDir}"
find "${nixarr.readarr-audiobook.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
${strings.optionalString nixarr.jellyseerr.enable ''
@@ -109,6 +109,14 @@ with lib; let
${strings.optionalString nixarr.recyclarr.enable ''
chown -R ${globals.recyclarr.user}:root "${nixarr.recyclarr.stateDir}"
find "${nixarr.recyclarr.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
${strings.optionalString nixarr.whisparr.enable ''
chown -R ${globals.whisparr.user}:root "${nixarr.whisparr.stateDir}"
find "${nixarr.whisparr.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
${strings.optionalString nixarr.komga.enable ''
chown -R ${globals.komga.user}:root "${nixarr.komga.stateDir}"
find "${nixarr.komga.stateDir}" \( -type d -exec chmod 0700 {} + -true \) -o \( -exec chmod 0600 {} + \)
''}
}
@@ -116,7 +124,6 @@ with lib; let
if [ "$#" -ne 1 ]; then
echo "Illegal number of parameters. Usage: nixarr list-unlinked <path>"
fi
find "$1" -type f -links 1 -exec du -h {} + | sort -h
}
@@ -161,6 +168,10 @@ with lib; let
${strings.optionalString nixarr.sonarr.enable ''
SONARR=$(xq '.Config.ApiKey' "${nixarr.sonarr.stateDir}/config.xml")
echo "Sonarr api-key: $SONARR"
''}
${strings.optionalString nixarr.whisparr.enable ''
WHISPARR=$(xq '.Config.ApiKey' "${nixarr.whisparr.stateDir}/config.xml")
echo "Whisparr api-key: $WHISPARR"
''}
${strings.optionalString nixarr.sonarr.enable ''
TRANSMISSION_RPC_USER=$(yq '.["rpc-username"]' "${nixarr.transmission.stateDir}/.config/transmission-daemon/settings.json")
@@ -184,7 +195,7 @@ with lib; let
echo "Wiping all nixarr users and groups from /etc/passwd and /etc/group..."
sed -i -E '/^(audiobookshelf|autobrr|bazarr|cross-seed|jellyfin|jellyseerr|lidarr|plex|prowlarr|radarr|readarr|recyclarr|sabnzbd|sonarr|streamer|torrenter|transmission|usenet)/d' /etc/passwd
sed -i -E '/^(audiobookshelf|autobrr|bazarr|cross-seed|jellyfin|jellyseerr|lidarr|plex|prowlarr|radarr|readarr|recyclarr|sabnzbd|sonarr|streamer|torrenter|transmission|usenet|whisparr|komgarr)/d' /etc/passwd
sed -i -E '/^(autobrr|cross-seed|jellyseerr|media|prowlarr|recyclarr|sabnzbd|streamer|torrenter|transmission|usenet)/d' /etc/group
echo ""
+13 -12
View File
@@ -84,19 +84,20 @@ in {
"d '${cfg.stateDir}' 0700 ${globals.prowlarr.user} root - -"
];
systemd.services.prowlarr = {
description = "prowlarr";
after = ["network.target"];
wantedBy = ["multi-user.target"];
environment.PROWLARR__SERVER__PORT = builtins.toString cfg.port;
services.prowlarr = {
enable = cfg.enable;
package = cfg.package;
settings.server.port = cfg.port;
openFirewall = cfg.openFirewall;
};
serviceConfig = {
Type = "simple";
User = globals.prowlarr.user;
Group = globals.prowlarr.group;
ExecStart = "${lib.getExe cfg.package} -nobrowser -data=${cfg.stateDir}";
Restart = "on-failure";
};
systemd.services.prowlarr.serviceConfig = {
# `User` and `Group` override `DynamicUser = true` from the NixOS Prowlarr
# module (because a user and group with those names exists).
User = globals.prowlarr.user;
Group = globals.prowlarr.group;
ExecStart = mkForce "${lib.getExe cfg.package} -nobrowser -data=${cfg.stateDir}";
ReadWritePaths = [cfg.stateDir];
};
networking.firewall = mkIf cfg.openFirewall {
+5 -1
View File
@@ -85,7 +85,6 @@ in {
systemd.tmpfiles.rules =
[
"L+ '${cfg.dataDir}'/config.js - - - - ${configJs}"
"d '${cfg.dataDir}' 0700 ${cfg.user} root - -"
]
++ (
@@ -109,6 +108,11 @@ in {
+ pkgs.writeShellScript "transmission-prestart" ''
${pkgs.jq}/bin/jq --slurp add ${settingsFile} '${cfg.credentialsFile}' |
install -D -m 600 -o '${cfg.user}' /dev/stdin '${cfg.dataDir}/config.json'
rm -f "${cfg.dataDir}/config.js"
cp "${configJs}" "${cfg.dataDir}/config.js"
chmod 600 "${cfg.dataDir}/config.js"
chown "${cfg.user}:${cfg.group}" "${cfg.dataDir}/config.js"
''
)
];
+136
View File
@@ -0,0 +1,136 @@
{
config,
lib,
pkgs,
...
}:
with lib; let
cfg = config.nixarr.whisparr;
globals = config.util-nixarr.globals;
defaultPort = 6969;
nixarr = config.nixarr;
in {
options.nixarr.whisparr = {
enable = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
Whether or not to enable the whisparr service.
'';
};
package = mkPackageOption pkgs "whisparr" {};
stateDir = mkOption {
type = types.path;
default = "${nixarr.stateDir}/whisparr";
defaultText = literalExpression ''"''${nixarr.stateDir}/whisparr"'';
example = "/nixarr/.state/whisparr";
description = ''
The location of the state directory for the whisparr service.
> **Warning:** Setting this to any path, where the subpath is not
> owned by root, will fail! For example:
>
> ```nix
> stateDir = /home/user/nixarr/.state/whisparr
> ```
>
> Is not supported, because `/home/user` is owned by `user`.
'';
};
openFirewall = mkOption {
type = types.bool;
defaultText = literalExpression ''!nixarr.whisparr.vpn.enable'';
default = !cfg.vpn.enable;
example = true;
description = "Open firewall for whisparr";
};
vpn.enable = mkOption {
type = types.bool;
default = false;
example = true;
description = ''
**Required options:** [`nixarr.vpn.enable`](#nixarr.vpn.enable)
Route whisparr traffic through the VPN.
'';
};
};
config = mkIf (nixarr.enable && cfg.enable) {
assertions = [
{
assertion = cfg.vpn.enable -> nixarr.vpn.enable;
message = ''
The nixarr.whisparr.vpn.enable option requires the
nixarr.vpn.enable option to be set, but it was not.
'';
}
];
users = {
groups.${globals.whisparr.group}.gid = globals.gids.${globals.whisparr.group};
users.${globals.whisparr.user} = {
isSystemUser = true;
group = globals.whisparr.group;
uid = globals.uids.${globals.whisparr.user};
};
};
systemd.tmpfiles.rules = [
"d '${nixarr.mediaDir}/library' 0775 ${globals.libraryOwner.user} ${globals.libraryOwner.group} - -"
"d '${nixarr.mediaDir}/library/xxx' 0775 ${globals.libraryOwner.user} ${globals.libraryOwner.group} - -"
];
services.whisparr = {
enable = cfg.enable;
package = cfg.package;
user = globals.whisparr.user;
group = globals.whisparr.group;
openFirewall = cfg.openFirewall;
dataDir = cfg.stateDir;
};
# Enable and specify VPN namespace to confine service in.
systemd.services.whisparr.vpnConfinement = mkIf cfg.vpn.enable {
enable = true;
vpnNamespace = "wg";
};
# Port mappings
vpnNamespaces.wg = mkIf cfg.vpn.enable {
portMappings = [
{
from = defaultPort;
to = defaultPort;
}
];
};
services.nginx = mkIf cfg.vpn.enable {
enable = true;
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
virtualHosts."127.0.0.1:${builtins.toString defaultPort}" = {
listen = [
{
addr = "0.0.0.0";
port = defaultPort;
}
];
locations."/" = {
recommendedProxySettings = true;
proxyWebsockets = true;
proxyPass = "http://192.168.15.1:${builtins.toString defaultPort}";
};
};
};
};
}
+160
View File
@@ -0,0 +1,160 @@
# Comprehensive test for Nixarr file permissions, user/group creation, and directory structure
{
pkgs,
nixosModules,
lib ? pkgs.lib,
}:
pkgs.nixosTest {
name = "nixarr-permissions-test";
nodes.machine = {
config,
pkgs,
...
}: {
imports = [nixosModules.default];
networking.firewall.enable = false;
nixarr = {
enable = true;
stateDir = "/data/.state/nixarr";
mediaDir = "/data/media";
mediaUsers = ["testuser"];
# Enable key services to trigger tmpfiles directory creation
jellyfin.enable = true;
transmission = {
enable = true;
vpn.enable = false;
privateTrackers.cross-seed.enable = true;
};
sonarr = {
enable = true;
vpn.enable = false;
};
radarr = {
enable = true;
vpn.enable = false;
};
lidarr = {
enable = true;
vpn.enable = false;
};
prowlarr = {
enable = true;
vpn.enable = false;
};
};
# Create a test user to verify mediaUsers functionality
users.users.testuser = {
isNormalUser = true;
home = "/home/testuser";
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
print("Starting Nixarr permissions test...")
# Test 1: Verify key users and groups exist
print("\n=== Testing User/Group Creation ===")
# Check essential users exist
key_users = ["jellyfin", "transmission", "cross-seed", "sonarr", "radarr", "testuser"]
for user in key_users:
machine.succeed(f"id {user}")
print(f" User {user} exists")
# Check media group exists and has correct members
media_members = machine.succeed("getent group media | cut -d: -f4").strip()
expected_members = ["jellyfin", "transmission", "sonarr", "radarr", "lidarr", "testuser"]
for member in expected_members:
if member in media_members:
print(f" {member} is in media group")
else:
machine.fail(f"{member} not in media group")
# Check cross-seed group membership
cross_seed_members = machine.succeed("getent group cross-seed | cut -d: -f4").strip()
if "transmission" in cross_seed_members:
print(" transmission is in cross-seed group")
# Test 2: Verify directory structure and ownership
print("\n=== Testing Directory Permissions ===")
def check_dir(path, expected_user, expected_group, description):
stat_output = machine.succeed(f"stat -c '%U:%G' '{path}'").strip()
user, group = stat_output.split(":")
if user == expected_user and group == expected_group:
print(f" {description}: {user}:{group}")
else:
machine.fail(f"{description} has wrong ownership: {user}:{group}, expected {expected_user}:{expected_group}")
# Check key directories exist with correct ownership
check_dir("/data/media", "root", "media", "Media root directory")
check_dir("/data/media/library/movies", "root", "media", "Movies directory")
check_dir("/data/media/library/shows", "root", "media", "Shows directory")
check_dir("/data/media/library/music", "root", "media", "Music directory")
check_dir("/data/media/torrents", "transmission", "media", "Torrents directory")
if machine.succeed("test -d '/data/media/torrents/.cross-seed' && echo 'exists' || echo 'missing'").strip() == "exists":
check_dir("/data/media/torrents/.cross-seed", "cross-seed", "cross-seed", "Cross-seed directory")
# Test 3: Verify service file access
print("\n=== Testing Service File Access ===")
# Test Jellyfin can write to media directories
test_dirs = ["/data/media/library/movies", "/data/media/library/shows"]
for test_dir in test_dirs:
if machine.succeed(f"test -d '{test_dir}' && echo 'exists' || echo 'missing'").strip() == "exists":
test_file = f"{test_dir}/jellyfin-test.txt"
machine.succeed(f"sudo -u jellyfin touch '{test_file}'")
machine.succeed(f"sudo -u jellyfin sh -c 'echo test > {test_file}'")
content = machine.succeed(f"cat '{test_file}'").strip()
if content != "test":
machine.fail(f"Expected 'test' but got '{content}'")
machine.succeed(f"sudo -u jellyfin rm '{test_file}'")
print(f" Jellyfin can write/read/delete in {test_dir}")
# Test cross-seed can access transmission state
trans_state = "/data/.state/nixarr/transmission"
if machine.succeed(f"test -d '{trans_state}' && echo 'exists' || echo 'missing'").strip() == "exists":
result = machine.succeed(f"sudo -u cross-seed test -r '{trans_state}' && echo 'readable' || echo 'not-readable'").strip()
if result == "readable":
print(" cross-seed can read transmission state directory")
else:
machine.fail("cross-seed cannot read transmission state directory")
# Test 4: Verify fix-permissions command
print("\n=== Testing fix-permissions Command ===")
# Create file with wrong permissions
test_file = "/data/media/library/movies/test-wrong-perms.txt"
if machine.succeed("test -d '/data/media/library/movies' && echo 'exists' || echo 'missing'").strip() == "exists":
machine.succeed(f"umask 077 && touch '{test_file}'")
# Verify initial permissions are wrong
initial_perms = machine.succeed(f"stat -c '%a' '{test_file}'").strip()
if initial_perms != "600":
machine.fail(f"Expected 600 permissions, got {initial_perms}")
machine.succeed("nixarr fix-permissions")
# Verify permissions were fixed
fixed_perms = machine.succeed(f"stat -c '%a' '{test_file}'").strip()
if fixed_perms not in ["644", "664"]:
machine.fail(f"fix-permissions failed: permissions are {fixed_perms}, expected 644 or 664")
machine.succeed(f"rm '{test_file}'")
print(f" fix-permissions corrected file permissions from 600 to {fixed_perms}")
print("\n=== All Permission Tests Completed ===")
'';
}
+120
View File
@@ -0,0 +1,120 @@
{
pkgs,
nixosModules,
lib ? pkgs.lib,
}:
pkgs.nixosTest {
name = "simple-test";
nodes.machine = {
config,
pkgs,
...
}: {
imports = [nixosModules.default];
networking.firewall.enable = false;
nixarr = {
enable = true;
jellyfin.enable = true;
jellyseerr.enable = true;
audiobookshelf.enable = true;
plex.enable = true;
transmission = {
enable = true;
privateTrackers.cross-seed.enable = true;
};
autobrr.enable = true;
bazarr.enable = true;
sonarr.enable = true;
radarr.enable = true;
readarr.enable = true;
readarr-audiobook.enable = true;
sabnzbd.enable = true;
lidarr.enable = true;
prowlarr.enable = true;
recyclarr = {
enable = true;
configuration = {
sonarr.series = {
base_url = "http://localhost:8989";
api_key = "!env_var SONARR_API_KEY";
quality_definition.type = "series";
delete_old_custom_formats = true;
custom_formats = [
{
trash_ids = [
"85c61753df5da1fb2aab6f2a47426b09" # BR-DISK
"9c11cd3f07101cdba90a2d81cf0e56b4" # LQ
];
assign_scores_to = [
{
name = "WEB-DL (1080p)";
score = -10000;
}
];
}
];
};
radarr.movies = {
base_url = "http://localhost:7878";
api_key = "!env_var RADARR_API_KEY";
quality_definition.type = "movie";
delete_old_custom_formats = true;
custom_formats = [
{
trash_ids = [
"570bc9ebecd92723d2d21500f4be314c" # Remaster
"eca37840c13c6ef2dd0262b141a5482f" # 4K Remaster
];
assign_scores_to = [
{
name = "HD Bluray + WEB";
score = 25;
}
];
}
];
};
};
};
};
# Create a test user to verify mediaUsers functionality
users.users.testuser = {
isNormalUser = true;
home = "/home/testuser";
};
};
testScript = ''
machine.wait_for_unit("multi-user.target")
# Check that all services are operational
machine.succeed("systemctl is-active jellyfin")
machine.succeed("systemctl is-active jellyseerr")
machine.succeed("systemctl is-active audiobookshelf")
machine.succeed("systemctl is-active plex")
machine.succeed("systemctl is-active transmission")
machine.succeed("systemctl is-active autobrr")
machine.succeed("systemctl is-active bazarr")
machine.succeed("systemctl is-active sonarr")
machine.succeed("systemctl is-active radarr")
machine.succeed("systemctl is-active readarr")
machine.succeed("systemctl is-active readarr-audiobook")
machine.succeed("systemctl is-active sabnzbd")
machine.succeed("systemctl is-active lidarr")
machine.succeed("systemctl is-active prowlarr")
machine.succeed("systemctl is-active recyclarr")
machine.succeed("nixarr list-api-keys")
machine.succeed("nixarr fix-permissions")
machine.succeed("nixarr wipe-uids-gids")
print("\n=== Nixarr Simple Test Completed ===")
'';
}
+767
View File
@@ -0,0 +1,767 @@
/*
VPN Confinement Integration Test
This test validates that Nixarr services are properly confined to a VPN namespace
and cannot leak traffic when the VPN connection fails. It uses a 3-VM topology
to simulate real-world network conditions.
Network Topology:
VLAN 2 VLAN 1
internetClient gateway nixarrHost
10.0.1.2 10.0.1.1 192.168.1.2
fd00:2::2 192.168.1.1 fd00:1::2
fd00:2::1
fd00:1::1
WireGuard tunnel
10.100.0.1
fd00:100::1 VPN namespace
(10.100.0.2, fd00:100::2)
Test Coverage:
- VPN namespace isolation (transmission confined to wg namespace)
- IPv4 and IPv6 traffic routing through VPN tunnel
- Traffic leak prevention when VPN is down
- Port forwarding from external clients through gateway to VPN services
- DNS configuration in VPN namespace
- Service recovery after VPN reconnection
The test ensures that:
1. All transmission traffic goes through the VPN tunnel
2. Source IP is preserved (shows VPN client IP: 10.100.0.2/fd00:100::2)
3. No traffic leaks to host network when VPN fails
4. External port forwarding works correctly
5. Both IPv4 and IPv6 work identically through the tunnel
*/
{
pkgs,
nixosModules,
lib ? pkgs.lib,
}: let
# WireGuard configuration for the VPN gateway
wgGatewayPort = 51820;
# Generate real WireGuard keys
wgGatewayPrivateKey = pkgs.runCommand "wg-gateway-private" {buildInputs = [pkgs.wireguard-tools];} ''
wg genkey > $out
'';
wgGatewayPublicKey = pkgs.runCommand "wg-gateway-public" {buildInputs = [pkgs.wireguard-tools];} ''
cat ${wgGatewayPrivateKey} | wg pubkey > $out
'';
wgClientPrivateKey = pkgs.runCommand "wg-client-private" {buildInputs = [pkgs.wireguard-tools];} ''
wg genkey > $out
'';
wgClientPublicKey = pkgs.runCommand "wg-client-public" {buildInputs = [pkgs.wireguard-tools];} ''
cat ${wgClientPrivateKey} | wg pubkey > $out
'';
# Network configuration
wgGatewayAddr = "10.100.0.1";
wgClientAddr = "10.100.0.2";
wgSubnet = "10.100.0.0/24";
# Fixed VM IPs
gatewayIP = "192.168.1.1";
nixarrHostIP = "192.168.1.2";
# Internet client IPs
internetClientIP = "10.0.1.2";
internetGatewayIP = "10.0.1.1";
# IPv6 addresses
gatewayIPv6 = "fd00:1::1";
nixarrHostIPv6 = "fd00:1::2";
internetClientIPv6 = "fd00:2::2";
internetGatewayIPv6 = "fd00:2::1";
wgGatewayAddrV6 = "fd00:100::1";
wgClientAddrV6 = "fd00:100::2";
# Generate WireGuard config file for client
wgClientConfig = pkgs.writeText "wg-client.conf" ''
[Interface]
PrivateKey = ${builtins.readFile wgClientPrivateKey}
Address = ${wgClientAddr}/24, ${wgClientAddrV6}/64
DNS = ${wgGatewayAddr}
[Peer]
PublicKey = ${builtins.readFile wgGatewayPublicKey}
Endpoint = ${gatewayIP}:${toString wgGatewayPort}
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
'';
in
pkgs.nixosTest {
name = "nixarr-vpn-confinement-test";
# Disable interactive mode to avoid hanging
interactive = false;
nodes = {
# Internet client VM - Simulates external services and clients
internetClient = {
config,
pkgs,
...
}: {
virtualisation.vlans = [2]; # Connect to VLAN 2 (Internet)
networking = {
firewall.enable = false;
};
# Add route to VPN subnet
boot.kernel.sysctl."net.ipv4.ip_forward" = 0; # internetClient doesn't forward
# Enable systemd-networkd for proper route management
systemd.network.enable = true;
networking.useNetworkd = true;
# Configure static routes to VPN subnet using systemd-networkd
systemd.network.networks."40-eth1" = {
matchConfig.Name = "eth1";
networkConfig = {
DHCP = "no";
};
address = [
"${internetClientIP}/24"
"${internetClientIPv6}/64"
];
gateway = ["${internetGatewayIP}" "${internetGatewayIPv6}"];
routes = [
{
Destination = "${wgSubnet}";
Gateway = "${internetGatewayIP}";
}
{
Destination = "fd00:100::/64";
Gateway = "${internetGatewayIPv6}";
}
];
};
# Web server that returns source IP for testing
systemd.services.source-ip-server = {
enable = true;
wantedBy = ["multi-user.target"];
after = ["network.target"];
serviceConfig = {
Type = "exec";
ExecStart = let
server = pkgs.writeText "server.py" ''
import http.server
import socketserver
import socket
class DualStackHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
address_family = socket.AF_INET6
def server_bind(self):
# Enable dual-stack support
self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
super().server_bind()
class MyHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(f"Source: {self.client_address[0]}".encode())
# Listen on all interfaces
with DualStackHTTPServer(("::", 8080), MyHandler) as httpd:
httpd.serve_forever()
'';
in "${pkgs.python3}/bin/python3 ${server}";
Restart = "always";
};
};
environment.systemPackages = with pkgs; [
netcat-gnu
curl
python3
];
};
# VPN Gateway VM - Acts as WireGuard server and internet gateway
gateway = {
config,
pkgs,
...
}: {
virtualisation.vlans = [1 2]; # VLAN 1 for LAN, VLAN 2 for Internet
networking = {
interfaces.eth1 = {
ipv4.addresses = [
{
address = gatewayIP;
prefixLength = 24;
}
];
ipv6.addresses = [
{
address = gatewayIPv6;
prefixLength = 64;
}
];
};
interfaces.eth2 = {
ipv4.addresses = [
{
address = internetGatewayIP;
prefixLength = 24;
}
];
ipv6.addresses = [
{
address = internetGatewayIPv6;
prefixLength = 64;
}
];
};
firewall = {
enable = true;
allowedUDPPorts = [wgGatewayPort 51413];
allowedTCPPorts = [51413];
};
wireguard.interfaces.wg0 = {
ips = ["${wgGatewayAddr}/24" "${wgGatewayAddrV6}/64"];
listenPort = wgGatewayPort;
privateKeyFile = "${wgGatewayPrivateKey}";
peers = [
{
publicKey = builtins.readFile wgClientPublicKey;
allowedIPs = ["${wgClientAddr}/32" "${wgClientAddrV6}/128"];
}
];
};
};
# Enable IP forwarding
boot.kernel.sysctl = {
"net.ipv4.ip_forward" = 1;
"net.ipv6.conf.all.forwarding" = 1;
};
# Port forwarding and firewall rules
networking.firewall.extraCommands = ''
# Allow WireGuard and testing traffic (IPv4)
iptables -A INPUT -i eth1 -j ACCEPT
iptables -A INPUT -i eth2 -j ACCEPT
iptables -A INPUT -i wg0 -j ACCEPT
# Allow WireGuard and testing traffic (IPv6)
ip6tables -A INPUT -i eth1 -j ACCEPT
ip6tables -A INPUT -i eth2 -j ACCEPT
ip6tables -A INPUT -i wg0 -j ACCEPT
# IPv6 forwarding rules - Allow forwarding between interfaces
ip6tables -A FORWARD -i wg0 -o eth2 -j ACCEPT
ip6tables -A FORWARD -i eth2 -o wg0 -j ACCEPT
ip6tables -A FORWARD -i wg0 -o eth1 -j ACCEPT
ip6tables -A FORWARD -i eth1 -o wg0 -j ACCEPT
ip6tables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT
# Note: No masquerading - we want to preserve source IPs for testing
# Forward transmission peer port from internet to VPN client (this is the key test)
iptables -t nat -A PREROUTING -i eth2 -p tcp --dport 51413 -j DNAT --to-destination ${wgClientAddr}:51413
iptables -t nat -A PREROUTING -i eth2 -p udp --dport 51413 -j DNAT --to-destination ${wgClientAddr}:51413
# Allow forwarded traffic
iptables -A FORWARD -p tcp --dport 51413 -d ${wgClientAddr} -j ACCEPT
iptables -A FORWARD -p udp --dport 51413 -d ${wgClientAddr} -j ACCEPT
# Accept return traffic for established connections
iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT
'';
# Simple DNS server for testing
services.dnsmasq = {
enable = true;
settings = {
interface = "wg0";
bind-interfaces = true;
listen-address = wgGatewayAddr;
# Log DNS queries for leak detection
log-queries = true;
log-facility = "/var/log/dnsmasq-queries.log";
# Static DNS entries for testing
address = [
"/test.vpn.local/${wgGatewayAddr}"
"/leak.test.local/1.2.3.4"
"/transmission.local/${wgClientAddr}"
];
};
};
# Ensure dnsmasq starts after WireGuard
systemd.services.dnsmasq = {
after = ["wireguard-wg0.service"];
wants = ["wireguard-wg0.service"];
};
# No additional routing needed on gateway - it has direct interfaces to both networks
# Install test utilities
environment.systemPackages = with pkgs; [
iptables
netcat-gnu
python3
iproute2 # for ss command
tcpdump
];
};
# Nixarr host with VPN-confined transmission
nixarrHost = {
config,
pkgs,
...
}: {
imports = [nixosModules.default];
virtualisation.vlans = [1]; # Connect to VLAN 1
networking = {
interfaces.eth1 = {
ipv4.addresses = [
{
address = nixarrHostIP;
prefixLength = 24;
}
];
ipv6.addresses = [
{
address = nixarrHostIPv6;
prefixLength = 64;
}
];
};
# Disable firewall for testing
firewall.enable = false;
# Add route to gateway
defaultGateway = {
address = gatewayIP;
interface = "eth1";
};
defaultGateway6 = {
address = gatewayIPv6;
interface = "eth1";
};
};
# Copy WireGuard config to the expected location
system.activationScripts.setupWgConfig = ''
mkdir -p /etc/wireguard
cp ${wgClientConfig} /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/wg0.conf
'';
# Minimal nixarr configuration with VPN
nixarr = {
enable = true;
# Required directories
mediaDir = "/data/media";
stateDir = "/data/.state/nixarr";
# Enable VPN
vpn = {
enable = true;
wgConf = "/etc/wireguard/wg0.conf";
};
# Enable transmission with VPN
transmission = {
enable = true;
vpn.enable = true;
# Use specific peer port for testing
peerPort = 51413;
# Disable firewall opening since we're in VPN
openFirewall = false;
};
# Disable all other services
sonarr.enable = false;
radarr.enable = false;
lidarr.enable = false;
readarr.enable = false;
bazarr.enable = false;
prowlarr.enable = false;
jellyfin.enable = false;
plex.enable = false;
sabnzbd.enable = false;
autobrr.enable = false;
recyclarr.enable = false;
jellyseerr.enable = false;
};
# Add IPv6 route for VPN namespace to reach internetClient via WireGuard
systemd.network.networks."10-eth1" = {
matchConfig.Name = "eth1";
routes = [
{
routeConfig = {
Destination = "fd00:2::/64"; # Route to internetClient network
Gateway = gatewayIPv6; # Via gateway IPv6
};
}
];
};
# Install test utilities
environment.systemPackages = with pkgs; [
wireguard-tools
dig
curl
iproute2
iptables
netcat-gnu
tcpdump
];
};
};
testScript = ''
start_all()
print("=== Waiting for VMs to boot ===")
# Wait for all VMs to be ready
internetClient.wait_for_unit("multi-user.target", timeout=60)
gateway.wait_for_unit("multi-user.target", timeout=60)
nixarrHost.wait_for_unit("multi-user.target", timeout=60)
# Wait for web server on internetClient
internetClient.wait_for_unit("source-ip-server.service")
internetClient.wait_for_open_port(8080)
# Wait for systemd-networkd to set up routes
internetClient.wait_for_unit("systemd-networkd.service")
print("=== Test 1: Basic connectivity between VMs ===")
# First verify that nixarrHost can reach the gateway
nixarrHost.succeed("ping -c 1 ${gatewayIP}")
gateway.succeed("ping -c 1 ${nixarrHostIP}")
print("=== Test 2: Check VPN namespace setup ===")
# Check that wg namespace exists
nixarrHost.succeed("ip netns list | grep -q wg")
# The VPN namespace service should be running
nixarrHost.wait_for_unit("wg.service")
# Check if transmission is running
nixarrHost.wait_for_unit("transmission.service", timeout=30)
print("=== Test 3: Check WireGuard connectivity ===")
# Check if WireGuard interface exists in namespace
nixarrHost.succeed("ip netns exec wg ip link show wg0")
# Check WireGuard status
nixarrHost.succeed("ip netns exec wg wg show")
# Test VPN tunnel connectivity
nixarrHost.succeed("ip netns exec wg ping -c 3 ${wgGatewayAddr}")
print("=== Test 4: Verify traffic routing through VPN ===")
# Debug: See what the web server actually returns
response = nixarrHost.succeed("ip netns exec wg curl -s http://${internetClientIP}:8080")
print(f"Web server response: {response}")
# Test traffic through VPN tunnel to internetClient - should show VPN client IP
nixarrHost.succeed("ip netns exec wg curl -s http://${internetClientIP}:8080 | grep -q '${wgClientAddr}'")
print("=== Test 5: Verify traffic routing through VPN ===")
# IPv4 traffic to host network should be blocked (specific route handling)
nixarrHost.fail("ip netns exec wg curl -s --max-time 2 http://${gatewayIP}:8080")
# Debug IPv6 connectivity before main test
print("=== Debug IPv6 connectivity ===")
# Check IPv6 addresses in VPN namespace
ipv6_addrs = nixarrHost.succeed("ip netns exec wg ip -6 addr show")
print(f"VPN namespace IPv6 addresses:\n{ipv6_addrs}")
# Check if VPN namespace can ping gateway IPv6
try:
nixarrHost.succeed("ip netns exec wg ping -6 -c 1 -W 3 ${wgGatewayAddrV6}")
print(" VPN namespace can ping WireGuard gateway IPv6")
except Exception as e:
print(f" VPN namespace cannot ping WireGuard gateway IPv6: {e}")
# Check if VPN namespace can reach internetClient IPv6 (simple connectivity)
try:
result = nixarrHost.succeed("ip netns exec wg curl -6 -s --max-time 5 http://[${internetClientIPv6}]:8080")
print(f" VPN namespace can reach internetClient IPv6: {result}")
except Exception as e:
print(f" VPN namespace cannot reach internetClient IPv6: {e}")
# Check gateway IPv6 routes
gw_routes = gateway.succeed("ip -6 route show")
print(f"Gateway IPv6 routes:\n{gw_routes}")
# Check internetClient IPv6 routes
client_routes = internetClient.succeed("ip -6 route show")
print(f"InternetClient IPv6 routes:\n{client_routes}")
# IPv6 traffic should go through VPN tunnel (shows VPN client source)
nixarrHost.succeed("ip netns exec wg curl -6 -s --max-time 2 http://[${internetClientIPv6}]:8080 | grep -q 'Source: fd00:100::2'")
print("=== Test 6: Verify transmission is confined ===")
# Check transmission is running and confined to VPN namespace
nixarrHost.succeed("systemctl status transmission.service | grep -q 'Active: active'")
print("=== Test 7: Interrupt VPN - Verify no connectivity ===")
# Block WireGuard traffic on gateway using iptables
gateway.succeed("iptables -I INPUT -p udp --dport ${toString wgGatewayPort} -j DROP")
gateway.succeed("iptables -I OUTPUT -p udp --sport ${toString wgGatewayPort} -j DROP")
# All connectivity should fail - no leaks!
nixarrHost.fail("ip netns exec wg ping -c 1 -W 2 ${wgGatewayAddr}")
nixarrHost.fail("ip netns exec wg curl -s --max-time 2 http://${internetClientIP}:8080")
# DNS should also fail completely when VPN is down
nixarrHost.fail("ip netns exec wg dig @${wgGatewayAddr} test.vpn.local +short +timeout=2")
nixarrHost.fail("ip netns exec wg dig leak.test.local +short +timeout=2")
print(" DNS queries fail when VPN is down - no fallback to host DNS")
# Verify no traffic leaks to host network
nixarrHost.fail("ip netns exec wg curl -s --max-time 2 http://${gatewayIP}:8080")
print("=== Test 8: Restore VPN - Verify recovery ===")
# Remove iptables blocks
gateway.succeed("iptables -D INPUT -p udp --dport ${toString wgGatewayPort} -j DROP")
gateway.succeed("iptables -D OUTPUT -p udp --sport ${toString wgGatewayPort} -j DROP")
# Restart the wg namespace service to force reconnection
nixarrHost.succeed("systemctl restart wg.service")
nixarrHost.wait_for_unit("wg.service")
# Verify VPN connectivity is restored
nixarrHost.succeed("ip netns exec wg ping -c 3 ${wgGatewayAddr}")
# Verify source IP is correct again - use internetClient since gateway has no web server
nixarrHost.succeed("ip netns exec wg curl -s http://${internetClientIP}:8080 | grep -q '${wgClientAddr}'")
print("=== Test 9: Verify DNS configuration ===")
# Check that resolv.conf in namespace uses VPN DNS
nixarrHost.succeed("ip netns exec wg cat /etc/resolv.conf | grep -q 'nameserver ${wgGatewayAddr}'")
# Verify no host DNS servers are present
nixarrHost.fail("ip netns exec wg cat /etc/resolv.conf | grep -q 'nameserver 10.0.2.3'")
print("=== Test 9b: DNS leak test ===")
# Debug: Check if dnsmasq is running on gateway
gateway.succeed("systemctl status dnsmasq")
gateway.succeed("ss -unpl | grep :53 || echo 'No DNS listener found'")
# Debug: Check connectivity to DNS server
nixarrHost.succeed("ip netns exec wg ping -c 1 ${wgGatewayAddr}")
# Start tcpdump on host interface to detect DNS leaks
nixarrHost.succeed("nohup tcpdump -i eth1 -n 'port 53' -w /tmp/dns-leak.pcap > /tmp/tcpdump-dns.log 2>&1 &")
# Clear dnsmasq query log
gateway.succeed("echo > /var/log/dnsmasq-queries.log || true")
# Use dig instead of nslookup for more reliable DNS queries
nixarrHost.succeed("ip netns exec wg dig @${wgGatewayAddr} test.vpn.local +short | grep -q ${wgGatewayAddr}")
nixarrHost.succeed("ip netns exec wg dig @${wgGatewayAddr} leak.test.local +short | grep -q '1.2.3.4'")
nixarrHost.succeed("ip netns exec wg dig @${wgGatewayAddr} transmission.local +short | grep -q ${wgClientAddr}")
# Also test without specifying server (uses resolv.conf)
nixarrHost.succeed("ip netns exec wg dig test.vpn.local +short | grep -q ${wgGatewayAddr}")
# Wait for any potential leaked packets
nixarrHost.succeed("pkill tcpdump || true")
# Check if any DNS packets were captured on host interface
dns_packets = nixarrHost.succeed("tcpdump -r /tmp/dns-leak.pcap -nn 2>/dev/null | wc -l").strip()
if int(dns_packets) > 0:
# Show what was captured for debugging
captured = nixarrHost.succeed("tcpdump -r /tmp/dns-leak.pcap -nn 2>/dev/null || echo 'No packets'")
print("DNS leak detected! Captured " + dns_packets + " packets:")
print(captured)
nixarrHost.fail("DNS queries leaked to host network")
# Verify queries went through VPN by checking gateway's dnsmasq log
gateway.succeed("grep -q 'test.vpn.local' /var/log/dnsmasq-queries.log")
gateway.succeed("grep -q 'leak.test.local' /var/log/dnsmasq-queries.log")
print(" No DNS leaks detected - all queries confined to VPN")
# Clean up
nixarrHost.succeed("rm -f /tmp/dns-leak.pcap /tmp/tcpdump-dns.log")
print("=== Test 10: Port forwarding test ===")
# Wait for transmission to be ready and listening
nixarrHost.wait_for_open_port(9091) # Web UI port
# Check that transmission peer port is listening in namespace
nixarrHost.succeed("ip netns exec wg ss -tlnp | grep -q ':51413'")
nixarrHost.succeed("ip netns exec wg ss -ulnp | grep -q ':51413'")
# Ensure WireGuard tunnel is active
nixarrHost.succeed("ip netns exec wg ping -c 1 ${wgGatewayAddr}")
# Debug: Print iptables rules and routing info
print("=== Gateway NAT OUTPUT rules ===")
output_rules = gateway.succeed("iptables -t nat -L OUTPUT -n -v")
print(output_rules)
print("=== Gateway NAT PREROUTING rules ===")
prerouting_rules = gateway.succeed("iptables -t nat -L PREROUTING -n -v")
print(prerouting_rules)
print("=== Gateway routing table ===")
routes = gateway.succeed("ip route show")
print(routes)
print("=== WireGuard status on gateway ===")
wg_gateway = gateway.succeed("wg show")
print(wg_gateway)
print("=== WireGuard status on client ===")
wg_client = nixarrHost.succeed("ip netns exec wg wg show")
print(wg_client)
# Test port forwarding through gateway
print("=== Testing port forwarding ===")
# First verify the NAT rules were actually applied
output_nat = gateway.succeed("iptables -t nat -L OUTPUT | grep 51413 || echo 'No OUTPUT NAT rules found'")
prerouting_nat = gateway.succeed("iptables -t nat -L PREROUTING | grep 51413 || echo 'No PREROUTING NAT rules found'")
print(f"OUTPUT NAT check: {output_nat}")
print(f"PREROUTING NAT check: {prerouting_nat}")
# Debug connectivity and routing
print("=== Testing connectivity from nixarrHost to gateway ===")
nixarrHost.succeed("ping -c 1 ${gatewayIP}")
# Debug FORWARD chain
forward_rules = gateway.succeed("iptables -L FORWARD -n -v")
print(f"Gateway FORWARD rules:\n{forward_rules}")
# Check if gateway can reach VPN client (after handshake)
gateway.succeed("wg show | grep -q 'latest handshake:'")
# Debug port forwarding connectivity
print("=== Debugging port forwarding ===")
# First, ensure WireGuard tunnel is fully established
gateway.succeed("wg") # Force handshake if needed
nixarrHost.succeed("ip netns exec wg wg")
# Skip direct gateway->client test after restart (WireGuard asymmetry)
print("=== Testing port forwarding ===")
# Test from nixarrHost (outside VPN) through gateway DNAT
print("Test: External client -> Gateway -> VPN Client (via DNAT)")
# First, ensure client initiates some traffic to establish WireGuard state
nixarrHost.succeed("ip netns exec wg ping -c 1 ${wgGatewayAddr}")
# Start tcpdump in background using nohup to properly detach it
gateway.succeed("nohup tcpdump -i any -n 'port 51413 or host ${wgClientAddr}' -w /tmp/capture.pcap > /tmp/tcpdump.log 2>&1 &")
# Verify tcpdump is running
gateway.succeed("pgrep tcpdump")
print("Tcpdump started, now testing connection...")
# Now test the connection - this should succeed through DNAT!
# Test from internetClient to gateway's internet IP - this simulates external traffic
# The connection should be forwarded through the VPN to nixarrHost's transmission
internetClient.succeed("timeout 5 nc -z -v ${internetGatewayIP} 51413")
print("Success: Port forwarding works!")
# Stop tcpdump and analyze what happened
gateway.succeed("pkill tcpdump")
tcpdump_output = gateway.succeed("tcpdump -r /tmp/capture.pcap -nn 2>/dev/null || echo 'No packets captured'")
print(f"Tcpdump results:\n{tcpdump_output}")
# Verify transmission can reach external services through VPN tunnel
nixarrHost.succeed("ip netns exec wg curl -s http://${internetClientIP}:8080 | grep -q '${wgClientAddr}'")
# Verify port is NOT accessible from host network (outside VPN)
nixarrHost.fail("timeout 2 nc -z -v localhost 51413")
print("=== Test 11: IPv6 leak test ===")
# Verify IPv6 connectivity between VMs
nixarrHost.succeed("ping -6 -c 1 ${gatewayIPv6}")
gateway.succeed("ping -6 -c 1 ${nixarrHostIPv6}")
# Check if IPv6 is enabled in VPN namespace
nixarrHost.succeed("ip netns exec wg ip -6 addr show")
# Test IPv6 through VPN tunnel
nixarrHost.succeed("ip netns exec wg ping -6 -c 1 ${wgGatewayAddrV6}")
# Test IPv6 traffic routing - should go through VPN tunnel to internetClient
nixarrHost.succeed("ip netns exec wg curl -6 -s --max-time 2 http://[${internetClientIPv6}]:8080")
nixarrHost.succeed("ip netns exec wg curl -6 -s http://[${internetClientIPv6}]:8080 | grep -q '${wgClientAddrV6}'")
print("=== Test 12: IPv6 traffic test with VPN interruption ===")
# Since WireGuard tunnel uses IPv4, blocking it affects both IPv4 and IPv6 traffic
# The IPv6 traffic inside the tunnel should fail when we block the IPv4 WireGuard connection
# This test verifies IPv6 behavior is tied to the VPN tunnel
# Verify WireGuard is listening (debug what ports are actually open)
print("=== Gateway listening ports ===")
listening_ports = gateway.succeed("ss -unp")
print(listening_ports)
print("=== Looking for WireGuard port ${toString wgGatewayPort} ===")
wg_port_check = gateway.succeed("ss -unp | grep :${toString wgGatewayPort} || echo 'WireGuard port not found'")
print(wg_port_check)
# The previous test (Test 7) already blocked IPv4 WireGuard and verified it works
# So IPv6 through the tunnel should also be blocked after IPv4 VPN disruption
# Let's verify IPv6 still works before disruption
nixarrHost.succeed("ip netns exec wg ping -6 -c 1 ${wgGatewayAddrV6}")
# Now use the same IPv4 blocking as Test 7
gateway.succeed("iptables -I INPUT -p udp --dport ${toString wgGatewayPort} -j DROP")
gateway.succeed("iptables -I OUTPUT -p udp --sport ${toString wgGatewayPort} -j DROP")
# Both IPv4 and IPv6 connectivity through VPN should fail
nixarrHost.fail("ip netns exec wg ping -c 1 -W 2 ${wgGatewayAddr}")
nixarrHost.fail("ip netns exec wg ping -6 -c 1 -W 2 ${wgGatewayAddrV6}")
nixarrHost.fail("ip netns exec wg curl -6 -s --max-time 2 http://[${internetClientIPv6}]:8080")
print("=== Test 13: IPv6 VPN recovery ===")
# Remove iptables blocks (IPv4, since that's what WireGuard uses)
gateway.succeed("iptables -D INPUT -p udp --dport ${toString wgGatewayPort} -j DROP")
gateway.succeed("iptables -D OUTPUT -p udp --sport ${toString wgGatewayPort} -j DROP")
# Verify IPv6 VPN connectivity is restored
nixarrHost.succeed("ip netns exec wg ping -6 -c 3 ${wgGatewayAddrV6}")
# Verify source IPv6 is correct again
nixarrHost.succeed("ip netns exec wg curl -6 -s http://[${internetClientIPv6}]:8080 | grep -q '${wgClientAddrV6}'")
print("=== All tests passed! ===")
'';
}
+10
View File
@@ -27,6 +27,7 @@ in {
lidarr = 306;
prowlarr = 293;
jellyseerr = 262;
komga = 145;
sonarr = 274;
radarr = 275;
readarr = 250;
@@ -35,6 +36,7 @@ in {
sabnzbd = 38;
transmission = 70;
cross-seed = 183;
whisparr = 272;
};
gids = {
autobrr = 188;
@@ -65,6 +67,10 @@ in {
user = "jellyseerr";
group = "jellyseerr";
};
komga = {
user = "komga";
group = globals.libraryOwner.group;
};
lidarr = {
user = "lidarr";
group = globals.libraryOwner.group;
@@ -109,5 +115,9 @@ in {
user = "cross-seed";
group = "cross-seed";
};
whisparr = {
user = "whisparr";
group = globals.libraryOwner.group;
};
};
}