Merge branch 'main' into eap/api-key-extractor
This commit is contained in:
@@ -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
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
Added:
|
||||
- `whisparr` service
|
||||
- `komgarr` service
|
||||
|
||||
## 2025-06-03
|
||||
|
||||
Added:
|
||||
|
||||
Generated
+6
-6
@@ -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": {
|
||||
|
||||
@@ -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; [
|
||||
|
||||
@@ -100,6 +100,8 @@ in {
|
||||
'';
|
||||
KillSignal = "SIGINT";
|
||||
Restart = "on-failure";
|
||||
KillSignal = "SIGINT";
|
||||
SuccessExitStatus = "0 156";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
''
|
||||
)
|
||||
];
|
||||
|
||||
@@ -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}";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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 ===")
|
||||
'';
|
||||
}
|
||||
@@ -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 ===")
|
||||
'';
|
||||
}
|
||||
@@ -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! ===")
|
||||
'';
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user