From a3caaf7d2eaa52e846055ac15065e43d680291db Mon Sep 17 00:00:00 2001 From: Nickolaj Jepsen Date: Tue, 16 Dec 2025 22:12:00 +0100 Subject: [PATCH] feat: add qbittorrent --- modules/homelab/default.nix | 1 + modules/homelab/glance.nix | 8 +- modules/homelab/qbittorrent.nix | 188 ++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 modules/homelab/qbittorrent.nix diff --git a/modules/homelab/default.nix b/modules/homelab/default.nix index 01f8538..00d5a03 100644 --- a/modules/homelab/default.nix +++ b/modules/homelab/default.nix @@ -14,6 +14,7 @@ ./plex.nix ./postgres.nix ./prometheus.nix + ./qbittorrent.nix ./restic.nix ./sso.nix ./vaultwarden.nix diff --git a/modules/homelab/glance.nix b/modules/homelab/glance.nix index dc83561..478a639 100644 --- a/modules/homelab/glance.nix +++ b/modules/homelab/glance.nix @@ -12,7 +12,7 @@ lib.mkIf config.fireproof.homelab.enable (let src = pkgs.fetchFromGitHub { owner = "nickolaj-jepsen"; repo = "glance"; - rev = "996d19a251c4d233f8091546ed0d22305b5e1641"; + rev = "7ea37f329e17908cdcc306f0fbb23a62e7c50584"; hash = "sha256-ZU9iswhgQPeMZeQmzgNhFBcO2TzWYrmIWPnKSAA0fFM="; }; }); @@ -233,6 +233,12 @@ in { icon = "sh:prowlarr"; same-tab = true; } + { + title = "qBittorrent"; + url = "https://qbittorrent.nickolaj.com"; + icon = "sh:qbittorrent"; + same-tab = true; + } { title = "Zitadel"; url = "https://sso.nickolaj.com"; diff --git a/modules/homelab/qbittorrent.nix b/modules/homelab/qbittorrent.nix new file mode 100644 index 0000000..d6dc1e4 --- /dev/null +++ b/modules/homelab/qbittorrent.nix @@ -0,0 +1,188 @@ +{ + config, + lib, + pkgs, + ... +}: +lib.mkIf config.fireproof.homelab.enable (let + inherit (config.fireproof) username; + user = "media"; + group = "media"; + + # VPN namespace configuration + vpnNamespace = "vpn"; + vpnInterface = "wg0"; + + # Ports + webUiPort = 8082; + torrentPort = 51413; + + mkVirtualHost = port: { + enableACME = true; + forceSSL = true; + locations."/" = { + proxyPass = "http://localhost:${toString port}"; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + }; +in { + # Secrets for Mullvad WireGuard config + # mullvad-wg.age should contain just the WireGuard config (not the Address line): + # [Interface] + # PrivateKey = + # + # [Peer] + # PublicKey = + # AllowedIPs = 0.0.0.0/0 + # Endpoint = :51820 + age.secrets.mullvad-wg = { + rekeyFile = ../../secrets/hosts/homelab/mullvad-wg.age; + mode = "400"; + }; + + # mullvad-wg-address.age should contain just the IP address assigned by Mullvad, e.g.: + # 10.66.123.45/32 + age.secrets.mullvad-wg-address = { + rekeyFile = ../../secrets/hosts/homelab/mullvad-wg-address.age; + mode = "444"; + }; + + # Create the VPN network namespace and WireGuard interface + systemd.services."netns-${vpnNamespace}" = { + description = "VPN Network Namespace"; + before = ["qbittorrent.service"]; + wantedBy = ["multi-user.target"]; + path = with pkgs; [iproute2 iptables]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "create-vpn-netns" '' + set -ex + # Create network namespace if it doesn't exist + ip netns add ${vpnNamespace} || true + + # Create veth pair for communication between namespaces + ip link add veth-vpn type veth peer name veth-vpn-br || true + ip link set veth-vpn-br netns ${vpnNamespace} || true + + # Configure host side + ip addr add 10.200.200.1/24 dev veth-vpn || true + ip link set veth-vpn up + + # Configure namespace side + ip netns exec ${vpnNamespace} ip addr add 10.200.200.2/24 dev veth-vpn-br || true + ip netns exec ${vpnNamespace} ip link set veth-vpn-br up + ip netns exec ${vpnNamespace} ip link set lo up + + # Enable IP forwarding for the veth bridge + echo 1 > /proc/sys/net/ipv4/ip_forward + iptables -t nat -A POSTROUTING -s 10.200.200.0/24 -o veth-vpn -j MASQUERADE || true + + # Allow traffic from namespace to host for web UI + iptables -A FORWARD -i veth-vpn -o veth-vpn -j ACCEPT || true + ''; + ExecStop = pkgs.writeShellScript "destroy-vpn-netns" '' + ip link del veth-vpn || true + ip netns del ${vpnNamespace} || true + iptables -t nat -D POSTROUTING -s 10.200.200.0/24 -o veth-vpn -j MASQUERADE || true + ''; + }; + }; + + # WireGuard interface inside the VPN namespace + systemd.services."wg-${vpnNamespace}" = { + description = "WireGuard VPN in namespace"; + after = ["netns-${vpnNamespace}.service"]; + requires = ["netns-${vpnNamespace}.service"]; + before = ["qbittorrent.service"]; + wantedBy = ["multi-user.target"]; + path = with pkgs; [iproute2 wireguard-tools]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = pkgs.writeShellScript "setup-wg-vpn" '' + set -ex + # Create WireGuard interface in namespace + ip link add ${vpnInterface} type wireguard || true + ip link set ${vpnInterface} netns ${vpnNamespace} + + # Configure WireGuard with Mullvad config + ip netns exec ${vpnNamespace} wg setconf ${vpnInterface} ${config.age.secrets.mullvad-wg.path} + + # Set the interface address from secret file + WG_ADDR=$(cat ${config.age.secrets.mullvad-wg-address.path}) + ip netns exec ${vpnNamespace} ip addr add "$WG_ADDR" dev ${vpnInterface} || true + ip netns exec ${vpnNamespace} ip link set ${vpnInterface} up + + # Route all traffic through WireGuard (default route) + ip netns exec ${vpnNamespace} ip route add default dev ${vpnInterface} || true + + # DNS configuration (use Mullvad DNS) + mkdir -p /etc/netns/${vpnNamespace} + echo "nameserver 10.64.0.1" > /etc/netns/${vpnNamespace}/resolv.conf + ''; + ExecStop = pkgs.writeShellScript "teardown-wg-vpn" '' + ip netns exec ${vpnNamespace} ip link del ${vpnInterface} || true + rm -rf /etc/netns/${vpnNamespace} || true + ''; + }; + }; + + # qBittorrent service running inside the VPN namespace + systemd.services.qbittorrent = { + description = "qBittorrent-nox service"; + documentation = ["man:qbittorrent-nox(1)"]; + after = [ + "network.target" + "wg-${vpnNamespace}.service" + ]; + requires = ["wg-${vpnNamespace}.service"]; + wantedBy = ["multi-user.target"]; + serviceConfig = { + Type = "simple"; + User = user; + Group = group; + StateDirectory = "qbittorrent"; + # Run in the VPN namespace + NetworkNamespacePath = "/var/run/netns/${vpnNamespace}"; + ExecStart = "${pkgs.qbittorrent-nox}/bin/qbittorrent-nox --webui-port=${toString webUiPort}"; + Restart = "on-failure"; + TimeoutStopSec = 1800; + }; + }; + + # Port forwarding from host to namespace for web UI access + systemd.services.qbittorrent-port-forward = { + description = "Port forward for qBittorrent Web UI"; + after = ["qbittorrent.service"]; + requires = ["qbittorrent.service"]; + wantedBy = ["multi-user.target"]; + serviceConfig = { + Type = "simple"; + Restart = "on-failure"; + ExecStart = "${pkgs.socat}/bin/socat TCP-LISTEN:${toString webUiPort},fork,reuseaddr EXEC:'${pkgs.iproute2}/bin/ip netns exec ${vpnNamespace} ${pkgs.socat}/bin/socat STDIO TCP\\:127.0.0.1\\:${toString webUiPort}'"; + }; + }; + + # Firewall rules for torrent port (forwarded through VPN) + networking.firewall.allowedTCPPorts = [webUiPort]; + networking.firewall.allowedUDPPorts = [torrentPort]; + + services = { + oauth2-proxy.nginx.virtualHosts = { + "qbittorrent.nickolaj.com".allowed_groups = ["arr"]; + }; + nginx.virtualHosts = { + "qbittorrent.nickolaj.com" = mkVirtualHost webUiPort; + }; + + restic.backups.homelab.paths = [ + "/var/lib/qbittorrent" + ]; + }; +})