From 34a686c5625f969be20d2c62ab8d3c93dc01afb8 Mon Sep 17 00:00:00 2001 From: Lyes Saadi Date: Thu, 15 Jan 2026 03:01:34 +0100 Subject: [PATCH] Setting up tetra and mogma --- README.md | 6 +- hosts/zora/networking.nix | 51 +++++++- hosts/zora/reverse-proxy.nix | 10 +- modules/server/README.md | 1 + modules/server/mogma/README.md | 26 +++++ modules/server/mogma/default.nix | 128 +++++++++++++++++++++ modules/server/mogma/encapsulation.nix | 77 +++++++++++++ modules/server/mogma/forwarding.nix | 103 +++++++++++++++++ modules/server/tetra/default.nix | 44 ++++++- secrets.nix | 2 + secrets/zora/services/mogma-privatekey.age | 7 ++ secrets/zora/services/tetra-pass.age | 7 ++ 12 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 modules/server/mogma/README.md create mode 100644 modules/server/mogma/default.nix create mode 100644 modules/server/mogma/encapsulation.nix create mode 100644 modules/server/mogma/forwarding.nix create mode 100644 secrets/zora/services/mogma-privatekey.age create mode 100644 secrets/zora/services/tetra-pass.age diff --git a/README.md b/README.md index 0039b7d..13b8536 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,6 @@ This repo uses flakes, which can construct the following targets: - `zora` : For my servers hosted at the [Crans](https://crans.org/) - `triforce` : For my custom ISO creation -Unless indicated otherwise by a comment (when code is taken from elsewhere), -everything in this repo is under the MIT-0 LICENSE. Meaning, you can copy -everything here without any restriction (not even attribution). +Unless indicated otherwise by a comment or file (when the code is taken from +elsewhere), everything in this repo is under the MIT-0 LICENSE. Meaning, you +can copy everything here without any restriction (not even attribution). diff --git a/hosts/zora/networking.nix b/hosts/zora/networking.nix index 7f0c16a..abe519c 100644 --- a/hosts/zora/networking.nix +++ b/hosts/zora/networking.nix @@ -1,6 +1,10 @@ -{ ... }: +{ config, ... }: { + imports = [ + ../../modules/server/mogma + ]; + # Networking networking = { hostName = "zora"; @@ -51,6 +55,51 @@ }; }; + # VPN + networking.vpn-netns = { + wireguardInterface = "mogma"; + nameserver = "10.2.0.1"; + + interfaceNamespace = "netns-mogma"; + vethInterfaceName = "veth-mogma"; + + vethIP = "192.168.2.2"; + vethOuterIP = "192.168.2.1"; + + wireguardOptions = { + privateKeyFile = config.age.secrets.mogma-privatekey.path; + ips = [ "10.2.0.2/32" ]; + + peers = [ + { + publicKey = "W4XqVNXMdnhtiRxWNzWThy3f7hRoT9NTx/HYu/jTaRU="; + allowedIPs = [ + "0.0.0.0/0" + "::/0" + ]; + endpoint = "79.127.169.89:51820"; + persistentKeepalive = 25; + } + ]; + }; + + restrictedServices = [ + "qbittorrent" + # "suwayomi-server" + ]; + + portForwarding = { + enable = true; + }; + }; + + age.secrets = { + mogma-privatekey = { + file = ../../secrets/zora/services/mogma-privatekey.age; + mode = "755"; + }; + }; + # Imposing a bandwidth limit to avoid Aurore/Crans disruptions # networking.nftables = { # tables.rate_limit = { diff --git a/hosts/zora/reverse-proxy.nix b/hosts/zora/reverse-proxy.nix index 56dae9b..188a21d 100644 --- a/hosts/zora/reverse-proxy.nix +++ b/hosts/zora/reverse-proxy.nix @@ -83,7 +83,15 @@ "torrent.lyes.eu" = { forceSSL = true; enableACME = true; - locations."/".proxyPass = "http://localhost:${toString config.services.qbittorrent.webuiPort}"; + locations."/" = { + proxyPass = "http://${config.networking.vpn-netns.vethIP}:${toString config.services.qbittorrent.webuiPort}"; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + ''; + }; }; # 9980 diff --git a/modules/server/README.md b/modules/server/README.md index e2b3bf9..ff07635 100644 --- a/modules/server/README.md +++ b/modules/server/README.md @@ -6,6 +6,7 @@ - `link` : Kanidm (`auth.lyes.eu`) - `maistro` : Incus - `mikau` : Jellyfin (`media.lyes.eu`) +- `mogma` : VPN NetNS Configuration - `nayru` : Komga/Manga (`manga.lyes.eu`) - `taf` : Mail (`taf.lyes.eu`/`mail.lyes.eu`) - `tetra` : Torrent (`torrent.lyes.eu`) diff --git a/modules/server/mogma/README.md b/modules/server/mogma/README.md new file mode 100644 index 0000000..cd70c7d --- /dev/null +++ b/modules/server/mogma/README.md @@ -0,0 +1,26 @@ += VPN NetNS + +== Credit + +The modules here come from https://codeberg.org/loutr/dotfiles, and is thus +licensed under the AGPLv3. + +== License + +A collection of NixOS modules for various apps and services, +as well as their uses in host configurations. +Copyright (C) 2016-2021 Henrik Lissner +Copyright (C) 2021-2024 Lucas Tabary-Maujean + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/modules/server/mogma/default.nix b/modules/server/mogma/default.nix new file mode 100644 index 0000000..babb921 --- /dev/null +++ b/modules/server/mogma/default.nix @@ -0,0 +1,128 @@ +{ + lib, + config, + options, + ... +}: + +let + cfg = config.networking.vpn-netns; +in + +{ + imports = [ + ./encapsulation.nix + ./forwarding.nix + ]; + + options.networking.vpn-netns = with lib; { + restrictedServices = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "A list of valid systemd service names to be encapsulated in the vpn netns."; + }; + + nameserver = mkOption { + type = types.singleLineStr; + description = "The DNS server associated with the wireguard connection."; + }; + + wireguardInterface = mkOption { + type = types.str; + description = "The name of the wireguard interface."; + }; + + wireguardOptions = mkOption { + type = with types; submodule { freeformType = attrsOf anything; }; + description = "Regular wireguard settings used to setup interface ${wgInterface}."; + }; + + interfaceNamespace = mkOption { + type = types.singleLineStr; + default = "vpn"; + description = "The name of the encapsulating netns."; + }; + + vethInterfaceName = mkOption { + type = types.singleLineStr; + default = "vethvpn"; + description = "The name of the veth interface accross netns."; + }; + + vethIP = mkOption { + type = types.singleLineStr; + default = "10.0.0.1"; + description = "The veth IP address of encapsulated services"; + }; + + vethOuterIP = mkOption { + type = types.singleLineStr; + default = "10.0.0.2"; + description = "The veth IP address of non-encapsulated services."; + }; + + portForwarding = { + enable = mkEnableOption "a port forwarding service."; + + leaseDuration = mkOption { + type = types.int; + default = 60; + description = "The NATPMP lease duration in seconds."; + }; + + updateDuration = mkOption { + type = types.int; + default = 2; + description = '' + How long the update script takes (in seconds). + This is substracted from the timer, so that leases do not get + interrupted. Tweak this based on your hardware performance etc. + ''; + }; + + endpoint = mkOption { + type = types.singleLineStr; + default = cfg.nameserver; + description = "The VPN endpoint (with which to negotiate the lease)."; + }; + + temporaryPortRange = mkOption { + type = options.networking.firewall.allowedUDPPortRanges.type.nestedTypes.elemType; + default = { + from = 30000; + to = 30010; + }; + description = '' + The port range used for local port redirection. Make sure it doesn't + interfere with other services, including the assignable port from your + VPN provider. + ''; + }; + }; + + encapsulatedServices = mkOption { + default = { }; + type = + with types; + attrsOf (submodule { + options.enable = mkEnableOption "network namespace encapsulation for this service."; + + options.portForwarding = { + enable = mkEnableOption "port forwarding for this service."; + + updateScript = mkOption { + type = str; + example = '' + echo listenPort=$PORT > /var/lib/service/config.conf + ''; + description = '' + The script to apply everytime the forwarded port changes. + The shell has access to the `$PORT` variable with the corresponding + port. Be cautious, this script can perform arbitrary commands. + ''; + }; + }; + }); + }; + }; +} diff --git a/modules/server/mogma/encapsulation.nix b/modules/server/mogma/encapsulation.nix new file mode 100644 index 0000000..51153c2 --- /dev/null +++ b/modules/server/mogma/encapsulation.nix @@ -0,0 +1,77 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + cfg = config.networking.vpn-netns; + + netnsName = cfg.interfaceNamespace; + vethOuterName = cfg.vethInterfaceName; + vethEncapsulatedName = vethOuterName + "0"; + + outerIP = cfg.vethOuterIP + "/32"; + encapsulatedIP = cfg.vethIP + "/32"; +in + +{ + config = lib.mkIf (cfg.restrictedServices != [ ]) { + systemd.services = builtins.listToAttrs ( + builtins.map (name: { + inherit name; + value = { + after = [ "wireguard.target" ]; + # preStart = "sleep 3"; + serviceConfig.NetworkNamespacePath = "/var/run/netns/${netnsName}"; + }; + }) cfg.restrictedServices + ); + + networking = { + wireguard.interfaces.${cfg.wireguardInterface} = + let + ip = "${pkgs.iproute2}/bin/ip"; + in + { + preSetup = '' + # clean interfaces + ${ip} netns delete "${netnsName}" 2> /dev/null || true + ${ip} link delete ${vethOuterName} 2> /dev/null || true + rm -rf /etc/netns/${netnsName} + + # add a namespace-specific resolv.conf + mkdir -p "/etc/netns/${netnsName}" + sed '/nameserver /d' /etc/resolv.conf > "/etc/netns/${netnsName}/resolv.conf" + echo "nameserver ${cfg.nameserver}" >> "/etc/netns/${netnsName}/resolv.conf" + + # create the network namespace + ${ip} netns add "${netnsName}" + ${ip} -n "${netnsName}" link set lo up + + # Add a custom link between netns + ${ip} link add ${vethOuterName} type veth peer name ${vethEncapsulatedName} + ${ip} link set ${vethEncapsulatedName} netns ${netnsName} + ${ip} addr add ${outerIP} dev ${vethOuterName} + ${ip} link set ${vethOuterName} up + ${ip} route add ${encapsulatedIP} dev ${vethOuterName} + ${ip} -n ${netnsName} addr add ${encapsulatedIP} dev ${vethEncapsulatedName} + ${ip} -n ${netnsName} link set ${vethEncapsulatedName} up + ${ip} -n ${netnsName} route add ${outerIP} dev ${vethEncapsulatedName} + ''; + + postShutdown = '' + ${ip} netns delete "${netnsName}" || true + ${ip} link delete ${vethOuterName} || true + rm -rf /etc/netns/${netnsName} + ''; + + inherit (cfg) interfaceNamespace; + } + // cfg.wireguardOptions; + + firewall.trustedInterfaces = [ cfg.vethInterfaceName ]; + }; + }; +} diff --git a/modules/server/mogma/forwarding.nix b/modules/server/mogma/forwarding.nix new file mode 100644 index 0000000..72431e4 --- /dev/null +++ b/modules/server/mogma/forwarding.nix @@ -0,0 +1,103 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + cfg = config.networking.vpn-netns; + + inherit (cfg.portForwarding) leaseDuration updateDuration endpoint; + + forwardedServices = lib.filterAttrs ( + _: value: value.enable && value.portForwarding.enable + ) cfg.encapsulatedServices; + + assignCount = + { acc, temporaryPort }: + name: value: { + acc = acc ++ [ + { + inherit name temporaryPort; + inherit (value.portForwarding) updateScript; + } + ]; + temporaryPort = temporaryPort + 1; + }; + + forwardedServicesWithTmpPort = lib.foldlAttrs assignCount { + acc = [ ]; + temporaryPort = cfg.portForwarding.temporaryPortRange.from; + } forwardedServices; + + serviceList = lib.mapAttrsToList (name: _: name + ".service") forwardedServices; +in +lib.mkIf (forwardedServices != { } && cfg.portForwarding.enable) { + assertions = [ + { + assertion = + forwardedServicesWithTmpPort.temporaryPort <= cfg.portForwarding.temporaryPortRange.to + 1; + message = '' + vpn forwarding: not enough temporary ports. + Increase the range of vpn.endpoint.temporaryPortRange. + ''; + } + ]; + + systemd = { + services.natpmpc-lease = { + description = "Request VPN port forwarding leases."; + + wantedBy = serviceList; + after = [ "wireguard.target" ]; + wants = [ "wireguard.target" ]; + + # preStart = "sleep 3"; + + path = with pkgs; [ + libnatpmp + iptables + ]; + + script = lib.concatMapStrings ( + { + temporaryPort, + name, + updateScript, + }: + let + temporaryPort' = toString temporaryPort; + leaseDuration' = toString leaseDuration; + in + '' + natpmpc -g ${endpoint} -a 1 ${temporaryPort'} udp ${leaseDuration'} > /dev/null + PORT=$(natpmpc -g ${endpoint} -a 1 ${temporaryPort'} tcp ${leaseDuration'} | + grep -oP "Mapped public port \K\d+") + + iptables -t nat -C PREROUTING -p udp --dport ${temporaryPort'} -j REDIRECT --to-port "$PORT" 2>/dev/null || ( + iptables -t nat -A PREROUTING -p tcp --dport ${temporaryPort'} -j REDIRECT --to-port "$PORT"; + iptables -t nat -A PREROUTING -p udp --dport ${temporaryPort'} -j REDIRECT --to-port "$PORT" + ) + + echo "Changing settings for service ${name} with port $PORT, if needed" + ${updateScript} + '' + ) forwardedServicesWithTmpPort.acc; + + serviceConfig = { + Type = "oneshot"; + TimeoutStartSec = 60; # toString (2 * updateDuration); + NetworkNamespacePath = "/var/run/netns/${cfg.interfaceNamespace}"; + }; + }; + + timers.natpmpc-lease = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnUnitActiveSec = toString (leaseDuration - updateDuration); + Persistent = "yes"; + }; + }; + }; +} diff --git a/modules/server/tetra/default.nix b/modules/server/tetra/default.nix index 0a74da5..55a8130 100644 --- a/modules/server/tetra/default.nix +++ b/modules/server/tetra/default.nix @@ -1,8 +1,12 @@ -{ ... }: +{ config, lib, pkgs, ... }: { + environment.systemPackages = with pkgs; [ + libnatpmp + ]; + services.qbittorrent = { - enable = false; + enable = true; user = "qbittorrent"; group = "media"; @@ -33,13 +37,47 @@ Username = "lyes"; Password_PBKDF2 = "@ByteArray(5UU0KdjkWdtIdml1aQVDOQ==:qs0cVTkuQzbHA3EmF9++MK9eJstbx95hIR52amh2PSSgmQxrXavu0oxUZdUMWnaIRKkUuq18o9GV+DMb7T99NA==)"; AuthSubnetWhitelistEnabled = true; - # AuthSubnetWhitelist = "192.168.2.2/32"; + AuthSubnetWhitelist = "192.168.2.2/32"; StatusbarExternalIPDisplayed = true; }; }; }; }; + networking.vpn-netns.encapsulatedServices.qbittorrent = { + enable = true; + + portForwarding = { + enable = true; + + updateScript = + let + configFile = "/var/lib/qbittorrent/qBittorrent/config/qBittorrent.conf"; + passwordFile = config.age.secrets.tetra-pass.path; + apiSetPreferenceUrl = "http://${config.networking.vpn-netns.vethIP}:${toString config.services.qbittorrent.webuiPort}/api/v2/app/setPreferences"; + curl = lib.getExe pkgs.curl; + ip = "${pkgs.iproute2}/bin/ip"; + in + '' + CURRENT_PORT=$(cat ${configFile} | grep 'Session\\Port' | cut -d '=' -f 2) + PASS=$(cat ${passwordFile}) + test "$PORT" -eq "$CURRENT_PORT" || ( + ${ip} netns exec netns-mogma ${curl} -i -X POST -d "json={\"random_port\": false}" "${apiSetPreferenceUrl}" + ${ip} netns exec netns-mogma ${curl} -i -X POST -d "json={\"listen_port\": $PORT}" "${apiSetPreferenceUrl}" + ) + ''; + }; + }; + + age.secrets = { + tetra-pass = { + file = ../../../secrets/zora/services/tetra-pass.age; + mode = "770"; + owner = "qbittorrent"; + group = "media"; + }; + }; + # users.users.qbittorrent.extraGroups = [ "media" ]; users.users.qbittorrent.isSystemUser = true; users.users.qbittorrent.group = "media"; diff --git a/secrets.nix b/secrets.nix index e32cdf5..7e83bb9 100644 --- a/secrets.nix +++ b/secrets.nix @@ -22,4 +22,6 @@ in "secrets/zora/services/biggoron-db-pass.age".publicKeys = all; "secrets/zora/services/biggoron-admin-pass.age".publicKeys = all; "secrets/zora/services/ptigoron-token.age".publicKeys = all; + "secrets/zora/services/mogma-privatekey.age".publicKeys = all; + "secrets/zora/services/tetra-pass.age".publicKeys = all; } diff --git a/secrets/zora/services/mogma-privatekey.age b/secrets/zora/services/mogma-privatekey.age new file mode 100644 index 0000000..edfa532 --- /dev/null +++ b/secrets/zora/services/mogma-privatekey.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 whuRpQ UsWzTO1dXbNc3QQiQ3ucPSE+1tBSdYyLmxKH/wqOXUU +l1iyFn+HmgihWdv6/4pwnR/7tpRRZE2WyD9NOBYpqX8 +-> ssh-ed25519 TFqgIg i7JnezH1VCIoZ689NVYTHsmFZHM6At3OADAg0a7hq0A +w8hxox6Ti+wY6mniY/sjjoOLTuI61TFXWsrHOB/vlkE +--- ckQG+2v4dN1rrpaNPci9mYjHl3Z7Boz4JAPB7c6EeBA +%ldCBOxVqhٙrTR/9k,k{ \ No newline at end of file diff --git a/secrets/zora/services/tetra-pass.age b/secrets/zora/services/tetra-pass.age new file mode 100644 index 0000000..50b69cb --- /dev/null +++ b/secrets/zora/services/tetra-pass.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 whuRpQ 3poc5ONc1KrUFEJ5b+JJOl5DCJuKIqsCDgXsWJkTtU8 +WRr4d9BwyPiY8iVZORHRhcwVPbBqASRMRDvxNfPewiY +-> ssh-ed25519 TFqgIg pb5iRBHs9Sru1cPtmZCAbZiRQFF+EnHdJKF3bnjNmwM +Ap7yRwNQDoY2XDGdruH08qTDU3iGCa+Dk71+d8nnPE8 +--- srqiR5By2Q7OgTr9ciw10FFoD5pgISNc67O8wiNfTLc +q!?%$KGϨ b>wG*‘uZAy->t|/C ڻ \ No newline at end of file