diff --git a/.github/workflows/formatting.yml b/.github/workflows/lint.yml similarity index 61% rename from .github/workflows/formatting.yml rename to .github/workflows/lint.yml index bdfea4e..c03c02e 100644 --- a/.github/workflows/formatting.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index de5726c..ed936d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +Added: +- `whisparr` service +- `komgarr` service + ## 2025-06-03 Added: diff --git a/flake.lock b/flake.lock index 86f3aaf..742fa94 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/flake.nix b/flake.nix index 6018ee5..1fa1ac3 100644 --- a/flake.nix +++ b/flake.nix @@ -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; [ diff --git a/nixarr/bazarr/default.nix b/nixarr/bazarr/default.nix index 5d5c54a..4266060 100644 --- a/nixarr/bazarr/default.nix +++ b/nixarr/bazarr/default.nix @@ -100,6 +100,8 @@ in { ''; KillSignal = "SIGINT"; Restart = "on-failure"; + KillSignal = "SIGINT"; + SuccessExitStatus = "0 156"; }; }; diff --git a/nixarr/default.nix b/nixarr/default.nix index 2729286..c2cab36 100644 --- a/nixarr/default.nix +++ b/nixarr/default.nix @@ -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 ]; diff --git a/nixarr/komga/default.nix b/nixarr/komga/default.nix new file mode 100644 index 0000000..da12d02 --- /dev/null +++ b/nixarr/komga/default.nix @@ -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; + } + ]; + }; + }; +} diff --git a/nixarr/nixarr-command/default.nix b/nixarr/nixarr-command/default.nix index a9067be..8a0d8d3 100644 --- a/nixarr/nixarr-command/default.nix +++ b/nixarr/nixarr-command/default.nix @@ -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 " 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 "" diff --git a/nixarr/prowlarr/default.nix b/nixarr/prowlarr/default.nix index a9f4fa6..e76b828 100644 --- a/nixarr/prowlarr/default.nix +++ b/nixarr/prowlarr/default.nix @@ -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 { diff --git a/nixarr/transmission/cross-seed/default.nix b/nixarr/transmission/cross-seed/default.nix index 929b12b..7a26309 100644 --- a/nixarr/transmission/cross-seed/default.nix +++ b/nixarr/transmission/cross-seed/default.nix @@ -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" '' ) ]; diff --git a/nixarr/whisparr/default.nix b/nixarr/whisparr/default.nix new file mode 100644 index 0000000..de116f7 --- /dev/null +++ b/nixarr/whisparr/default.nix @@ -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}"; + }; + }; + }; + }; +} diff --git a/tests/permissions-test.nix b/tests/permissions-test.nix new file mode 100644 index 0000000..30fe33b --- /dev/null +++ b/tests/permissions-test.nix @@ -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 ===") + ''; +} diff --git a/tests/simple-test.nix b/tests/simple-test.nix new file mode 100644 index 0000000..a402456 --- /dev/null +++ b/tests/simple-test.nix @@ -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 ===") + ''; +} diff --git a/tests/vpn-confinement-test.nix b/tests/vpn-confinement-test.nix new file mode 100644 index 0000000..ce9c96d --- /dev/null +++ b/tests/vpn-confinement-test.nix @@ -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! ===") + ''; + } diff --git a/util/globals/default.nix b/util/globals/default.nix index 8aff0ec..628e27b 100644 --- a/util/globals/default.nix +++ b/util/globals/default.nix @@ -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; + }; }; }