From a0fcacdcf9a583153301d38690c8abd3f960f051 Mon Sep 17 00:00:00 2001 From: googlebot Date: Sun, 22 Feb 2026 23:10:25 -0800 Subject: [PATCH] Rewrite PIA VPN as multi-container bridge architecture Replace the single VPN container (veth pair, host-side auth scripts) with a multi-container setup on a shared bridge network: - Dedicated VPN container handles all PIA auth, WireGuard config, NAT, and optional port forwarding DNAT - Service containers default-route through VPN container (leak-proof by topology) - Host runs tinyproxy on bridge for PIA API bootstrap before WG is up - WG interface is still created in host netns and moved into VPN container namespace - Monthly renewal to ensure that connection stays up (PIA allows connections to last up to 2 months) - Drop OpenVPN support entirely --- CLAUDE.md | 12 +- common/network/default.nix | 4 +- common/network/pia-openvpn.nix | 113 ------ common/network/pia-vpn/README.md | 89 +++++ common/network/{ => pia-vpn}/ca.rsa.4096.crt | 0 common/network/pia-vpn/default.nix | 265 ++++++++++++++ common/network/pia-vpn/scripts.nix | 201 ++++++++++ common/network/pia-vpn/service-container.nix | 68 ++++ common/network/pia-vpn/vpn-container.nix | 223 ++++++++++++ common/network/pia-wireguard.nix | 363 ------------------- common/network/vpn.nix | 106 ------ common/network/vpnfailsafe.sh | 187 ---------- machines/storage/s0/default.nix | 233 ++++++------ 13 files changed, 979 insertions(+), 885 deletions(-) delete mode 100644 common/network/pia-openvpn.nix create mode 100644 common/network/pia-vpn/README.md rename common/network/{ => pia-vpn}/ca.rsa.4096.crt (100%) create mode 100644 common/network/pia-vpn/default.nix create mode 100644 common/network/pia-vpn/scripts.nix create mode 100644 common/network/pia-vpn/service-container.nix create mode 100644 common/network/pia-vpn/vpn-container.nix delete mode 100644 common/network/pia-wireguard.nix delete mode 100644 common/network/vpn.nix delete mode 100755 common/network/vpnfailsafe.sh diff --git a/CLAUDE.md b/CLAUDE.md index e81338a..f8b3a0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,8 +86,16 @@ When adding or removing a web-facing service, update both: - Don't use `nix build --dry-run` unless you only need evaluation — it skips the actual build - Avoid `2>&1` on nix commands — it can cause error output to be missed -## Git Worktree Requirement +## Git Worktrees -When instructed to work in a git worktree (e.g., via `isolation: "worktree"` or told to use a worktree), you **MUST** do so. If you are unable to create or use a git worktree, you **MUST** stop work immediately and report the failure to the user. Do not fall back to working in the main working tree. +When the user asks you to "start a worktree" or work in a worktree, **do not create one manually** with `git worktree add`. Instead, tell the user to start a new session with: + +```bash +claude --worktree +``` + +This is the built-in Claude Code worktree workflow. It creates the worktree at `.claude/worktrees//` with a branch `worktree-` and starts a new Claude session inside it. Cleanup is handled automatically on exit. + +When instructed to work in a git worktree (e.g., via `isolation: "worktree"` on a subagent), you **MUST** do so. If you are unable to create or use a git worktree, you **MUST** stop work immediately and report the failure to the user. Do not fall back to working in the main working tree. When applying work from a git worktree back to the main branch, commit in the worktree first, then use `git cherry-pick` from the main working tree to bring the commit over. Do not use `git checkout` or `git apply` to copy files directly. Do **not** automatically apply worktree work to the main branch — always ask the user for approval first. diff --git a/common/network/default.nix b/common/network/default.nix index b5cfd93..942cd51 100644 --- a/common/network/default.nix +++ b/common/network/default.nix @@ -7,10 +7,8 @@ let in { imports = [ - ./pia-openvpn.nix - ./pia-wireguard.nix + ./pia-vpn ./tailscale.nix - ./vpn.nix ./sandbox.nix ]; diff --git a/common/network/pia-openvpn.nix b/common/network/pia-openvpn.nix deleted file mode 100644 index 35cde8e..0000000 --- a/common/network/pia-openvpn.nix +++ /dev/null @@ -1,113 +0,0 @@ -{ config, pkgs, lib, ... }: - -let - cfg = config.pia.openvpn; - vpnfailsafe = pkgs.stdenv.mkDerivation { - pname = "vpnfailsafe"; - version = "0.0.1"; - src = ./.; - installPhase = '' - mkdir -p $out - cp vpnfailsafe.sh $out/vpnfailsafe.sh - sed -i 's|getent|${pkgs.getent}/bin/getent|' $out/vpnfailsafe.sh - ''; - }; -in -{ - options.pia.openvpn = { - enable = lib.mkEnableOption "Enable private internet access"; - server = lib.mkOption { - type = lib.types.str; - default = "us-washingtondc.privacy.network"; - example = "swiss.privacy.network"; - }; - }; - - config = lib.mkIf cfg.enable { - services.openvpn = { - servers = { - pia = { - config = '' - client - dev tun - proto udp - remote ${cfg.server} 1198 - resolv-retry infinite - nobind - persist-key - persist-tun - cipher aes-128-cbc - auth sha1 - tls-client - remote-cert-tls server - - auth-user-pass - compress - verb 1 - reneg-sec 0 - - -----BEGIN X509 CRL----- - MIICWDCCAUAwDQYJKoZIhvcNAQENBQAwgegxCzAJBgNVBAYTAlVTMQswCQYDVQQI - EwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRl - cm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAw - HgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0 - ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRl - aW50ZXJuZXRhY2Nlc3MuY29tFw0xNjA3MDgxOTAwNDZaFw0zNjA3MDMxOTAwNDZa - MCYwEQIBARcMMTYwNzA4MTkwMDQ2MBECAQYXDDE2MDcwODE5MDA0NjANBgkqhkiG - 9w0BAQ0FAAOCAQEAQZo9X97ci8EcPYu/uK2HB152OZbeZCINmYyluLDOdcSvg6B5 - jI+ffKN3laDvczsG6CxmY3jNyc79XVpEYUnq4rT3FfveW1+Ralf+Vf38HdpwB8EW - B4hZlQ205+21CALLvZvR8HcPxC9KEnev1mU46wkTiov0EKc+EdRxkj5yMgv0V2Re - ze7AP+NQ9ykvDScH4eYCsmufNpIjBLhpLE2cuZZXBLcPhuRzVoU3l7A9lvzG9mjA - 5YijHJGHNjlWFqyrn1CfYS6koa4TGEPngBoAziWRbDGdhEgJABHrpoaFYaL61zqy - MR6jC0K2ps9qyZAN74LEBedEfK7tBOzWMwr58A== - -----END X509 CRL----- - - - - -----BEGIN CERTIFICATE----- - MIIFqzCCBJOgAwIBAgIJAKZ7D5Yv87qDMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD - VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV - BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu - dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx - IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB - FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzM1 - MThaFw0zNDA0MTIxNzM1MThaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex - EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg - QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE - AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50 - ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy - bmV0YWNjZXNzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPXD - L1L9tX6DGf36liA7UBTy5I869z0UVo3lImfOs/GSiFKPtInlesP65577nd7UNzzX - lH/P/CnFPdBWlLp5ze3HRBCc/Avgr5CdMRkEsySL5GHBZsx6w2cayQ2EcRhVTwWp - cdldeNO+pPr9rIgPrtXqT4SWViTQRBeGM8CDxAyTopTsobjSiYZCF9Ta1gunl0G/ - 8Vfp+SXfYCC+ZzWvP+L1pFhPRqzQQ8k+wMZIovObK1s+nlwPaLyayzw9a8sUnvWB - /5rGPdIYnQWPgoNlLN9HpSmsAcw2z8DXI9pIxbr74cb3/HSfuYGOLkRqrOk6h4RC - OfuWoTrZup1uEOn+fw8CAwEAAaOCAVQwggFQMB0GA1UdDgQWBBQv63nQ/pJAt5tL - y8VJcbHe22ZOsjCCAR8GA1UdIwSCARYwggESgBQv63nQ/pJAt5tLy8VJcbHe22ZO - sqGB7qSB6zCB6DELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpM - b3NBbmdlbGVzMSAwHgYDVQQKExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4G - A1UECxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAMTF1ByaXZhdGUg - SW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQpExdQcml2YXRlIEludGVybmV0IEFjY2Vz - czEvMC0GCSqGSIb3DQEJARYgc2VjdXJlQHByaXZhdGVpbnRlcm5ldGFjY2Vzcy5j - b22CCQCmew+WL/O6gzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4IBAQAn - a5PgrtxfwTumD4+3/SYvwoD66cB8IcK//h1mCzAduU8KgUXocLx7QgJWo9lnZ8xU - ryXvWab2usg4fqk7FPi00bED4f4qVQFVfGfPZIH9QQ7/48bPM9RyfzImZWUCenK3 - 7pdw4Bvgoys2rHLHbGen7f28knT2j/cbMxd78tQc20TIObGjo8+ISTRclSTRBtyC - GohseKYpTS9himFERpUgNtefvYHbn70mIOzfOJFTVqfrptf9jXa9N8Mpy3ayfodz - 1wiqdteqFXkTYoSDctgKMiZ6GdocK9nMroQipIQtpnwd4yBDWIyC6Bvlkrq5TQUt - YDQ8z9v+DMO6iwyIDRiU - -----END CERTIFICATE----- - - - disable-occ - auth-user-pass /run/agenix/pia-login.conf - ''; - autoStart = true; - up = "${vpnfailsafe}/vpnfailsafe.sh"; - down = "${vpnfailsafe}/vpnfailsafe.sh"; - }; - }; - }; - age.secrets."pia-login.conf".file = ../../secrets/pia-login.age; - }; -} diff --git a/common/network/pia-vpn/README.md b/common/network/pia-vpn/README.md new file mode 100644 index 0000000..8c5e99e --- /dev/null +++ b/common/network/pia-vpn/README.md @@ -0,0 +1,89 @@ +# PIA VPN Multi-Container Module + +Routes service containers through a PIA WireGuard VPN using a shared bridge network. + +## Architecture + +``` + internet + │ + ┌──────┴──────┐ + │ Host │ + │ tinyproxy │ ← PIA API bootstrap proxy + │ 10.100.0.1 │ + └──────┬───────┘ + │ br-vpn (no IPMasquerade) + ┌────────────┼──────────────┐ + │ │ │ + ┌──────┴──────┐ ┌───┴────┐ ┌─────┴──────┐ + │ VPN ctr │ │ servarr│ │transmission│ + │ 10.100.0.2 │ │ .11 │ │ .10 │ + │ piaw (WG) │ │ │ │ │ + │ gateway+NAT │ └────────┘ └────────────┘ + └─────────────┘ +``` + +- **Host** creates the WG interface (encrypted UDP stays in host netns) and runs tinyproxy on the bridge so the VPN container can bootstrap PIA auth before WG is up. +- **VPN container** authenticates with PIA via the proxy, configures WG, sets up NAT (masquerade bridge→WG) and optional port forwarding DNAT. +- **Service containers** default-route through the VPN container. No WG interface = no internet if VPN is down = leak-proof by topology. +- **Host** reaches containers directly on the bridge for nginx reverse proxying. + +## Key design decisions + +- **Bridge, not veth pairs**: All containers share one bridge (`br-vpn`), so the VPN container can act as a single gateway. The host does NOT masquerade bridge traffic — only the VPN container does (through WG). +- **Port forwarding is implicit**: If any container sets `receiveForwardedPort`, the VPN container automatically handles PIA port forwarding and DNAT. No separate toggle needed. +- **DNS through WG**: Service containers use the VPN container as their DNS server. The VPN container runs `systemd-resolved` listening on its bridge IP, forwarding queries through the WG tunnel. +- **Monthly renewal**: `pia-vpn-setup` uses `Type=simple` + `Restart=always` + `RuntimeMaxSec=30d` to periodically re-authenticate with PIA and get a fresh port forwarding signature (signatures expire after ~2 months). Service containers are unaffected during renewal. + +## Files + +| File | Purpose | +|---|---| +| `default.nix` | Options, bridge, tinyproxy, host firewall, WG interface creation, assertions | +| `vpn-container.nix` | VPN container: PIA auth, WG config, NAT, DNAT, port refresh timer | +| `service-container.nix` | Generates service containers with static IP and gateway→VPN | +| `scripts.nix` | Bash function library for PIA API calls and WG configuration | +| `ca.rsa.4096.crt` | PIA CA certificate for API TLS verification | + +## Usage + +```nix +pia-vpn = { + enable = true; + serverLocation = "swiss"; + + containers.my-service = { + ip = "10.100.0.10"; + mounts."/data".hostPath = "/data"; + config = { services.my-app.enable = true; }; + + # Optional: receive PIA's forwarded port (at most one container) + receiveForwardedPort = { port = 8080; protocol = "both"; }; + onPortForwarded = '' + echo "PIA assigned port $PORT, forwarding to $TARGET_IP:8080" + ''; + }; +}; +``` + +## Debugging + +```bash +# Check VPN container status +machinectl shell pia-vpn +systemctl status pia-vpn-setup +journalctl -u pia-vpn-setup + +# Verify WG tunnel +wg show + +# Check NAT/DNAT rules +iptables -t nat -L -v +iptables -L FORWARD -v + +# From a service container — verify VPN routing +curl ifconfig.me + +# Port refresh logs +journalctl -u pia-vpn-port-refresh +``` diff --git a/common/network/ca.rsa.4096.crt b/common/network/pia-vpn/ca.rsa.4096.crt similarity index 100% rename from common/network/ca.rsa.4096.crt rename to common/network/pia-vpn/ca.rsa.4096.crt diff --git a/common/network/pia-vpn/default.nix b/common/network/pia-vpn/default.nix new file mode 100644 index 0000000..cfe6907 --- /dev/null +++ b/common/network/pia-vpn/default.nix @@ -0,0 +1,265 @@ +{ config, lib, pkgs, ... }: + +# PIA VPN multi-container module. +# +# Architecture: +# Host creates WG interface, runs tinyproxy on bridge for PIA API bootstrap. +# VPN container does all PIA logic via proxy, configures WG, masquerades bridge→piaw. +# Service containers default route → VPN container (leak-proof by topology). +# +# Reference: https://www.wireguard.com/netns/#ordinary-containerization + +with lib; + +let + cfg = config.pia-vpn; + + # Derive prefix length from subnet CIDR (e.g. "10.100.0.0/24" → "24") + subnetPrefixLen = last (splitString "/" cfg.subnet); + + containerSubmodule = types.submodule ({ name, ... }: { + options = { + ip = mkOption { + type = types.str; + description = "Static IP address for this container on the VPN bridge"; + }; + + config = mkOption { + type = types.anything; + default = { }; + description = "NixOS configuration for this container"; + }; + + mounts = mkOption { + type = types.attrsOf (types.submodule { + options = { + hostPath = mkOption { + type = types.str; + description = "Path on the host to bind mount"; + }; + isReadOnly = mkOption { + type = types.bool; + default = false; + description = "Whether the mount is read-only"; + }; + }; + }); + default = { }; + description = "Bind mounts for the container"; + }; + + receiveForwardedPort = mkOption { + type = types.nullOr (types.submodule { + options = { + port = mkOption { + type = types.port; + description = "Target port to forward PIA-assigned port to"; + }; + protocol = mkOption { + type = types.enum [ "tcp" "udp" "both" ]; + default = "both"; + description = "Protocol(s) to forward"; + }; + }; + }); + default = null; + description = "Port forwarding configuration. At most one container may set this."; + }; + + onPortForwarded = mkOption { + type = types.nullOr types.lines; + default = null; + description = '' + Optional script run in the VPN container after port forwarding is established. + Available environment variables: $PORT (PIA-assigned port), $TARGET_IP (this container's IP). + ''; + }; + }; + }); + + # NOTE: All derivations of cfg.containers are kept INSIDE config = mkIf ... { } + # to avoid infinite recursion. The module system's pushDownProperties eagerly + # evaluates let bindings and mkMerge contents, so any top-level let binding + # that touches cfg.containers would force config evaluation during structure + # discovery, creating a cycle. +in +{ + imports = [ + ./vpn-container.nix + ./service-container.nix + ]; + + options.pia-vpn = { + enable = mkEnableOption "PIA VPN multi-container setup"; + + serverLocation = mkOption { + type = types.str; + default = "swiss"; + description = "PIA server region ID"; + }; + + interfaceName = mkOption { + type = types.str; + default = "piaw"; + description = "WireGuard interface name"; + }; + + wireguardListenPort = mkOption { + type = types.port; + default = 51820; + description = "WireGuard listen port"; + }; + + bridgeName = mkOption { + type = types.str; + default = "br-vpn"; + description = "Bridge interface name for VPN containers"; + }; + + subnet = mkOption { + type = types.str; + default = "10.100.0.0/24"; + description = "Subnet CIDR for VPN bridge network"; + }; + + hostAddress = mkOption { + type = types.str; + default = "10.100.0.1"; + description = "Host IP on the VPN bridge"; + }; + + vpnAddress = mkOption { + type = types.str; + default = "10.100.0.2"; + description = "VPN container IP on the bridge"; + }; + + proxyPort = mkOption { + type = types.port; + default = 8888; + description = "Tinyproxy port for PIA API bootstrap"; + }; + + containers = mkOption { + type = types.attrsOf containerSubmodule; + default = { }; + description = "Service containers that route through the VPN"; + }; + + # Subnet prefix length derived from cfg.subnet (exposed for other submodules) + subnetPrefixLen = mkOption { + type = types.str; + default = subnetPrefixLen; + description = "Prefix length derived from subnet CIDR"; + readOnly = true; + }; + }; + + config = mkIf cfg.enable { + assertions = + let + forwardingContainers = filterAttrs (_: c: c.receiveForwardedPort != null) cfg.containers; + containerIPs = mapAttrsToList (_: c: c.ip) cfg.containers; + in + [ + { + assertion = length (attrNames forwardingContainers) <= 1; + message = "At most one pia-vpn container may set receiveForwardedPort"; + } + { + assertion = length containerIPs == length (unique containerIPs); + message = "pia-vpn container IPs must be unique"; + } + ]; + + # Enable systemd-networkd for bridge management + systemd.network.enable = true; + + # Don't let systemd-networkd-wait-online block boot on bridge + systemd.network.wait-online.ignoredInterfaces = [ cfg.bridgeName ]; + + # Tell NetworkManager to ignore VPN bridge and container interfaces + networking.networkmanager.unmanaged = mkIf config.networking.networkmanager.enable [ + "interface-name:${cfg.bridgeName}" + "interface-name:ve-*" + ]; + + # Bridge network device + systemd.network.netdevs."20-${cfg.bridgeName}".netdevConfig = { + Kind = "bridge"; + Name = cfg.bridgeName; + }; + + # Bridge network configuration — NO IPMasquerade (host must NOT be gateway) + systemd.network.networks."20-${cfg.bridgeName}" = { + matchConfig.Name = cfg.bridgeName; + networkConfig = { + Address = "${cfg.hostAddress}/${cfg.subnetPrefixLen}"; + DHCPServer = false; + }; + linkConfig.RequiredForOnline = "no"; + }; + + # Allow wireguard traffic through rpfilter + networking.firewall.checkReversePath = "loose"; + + # Block bridge → outside forwarding (prevents host from being a gateway for containers) + networking.firewall.extraForwardRules = '' + iifname "${cfg.bridgeName}" oifname != "${cfg.bridgeName}" drop + ''; + + # Allow tinyproxy from bridge (tinyproxy itself restricts to VPN container IP) + networking.firewall.interfaces.${cfg.bridgeName}.allowedTCPPorts = [ cfg.proxyPort ]; + + # Tinyproxy — runs on bridge IP so VPN container can bootstrap PIA auth + services.tinyproxy = { + enable = true; + settings = { + Listen = cfg.hostAddress; + Port = cfg.proxyPort; + }; + }; + systemd.services.tinyproxy.before = [ "container@pia-vpn.service" ]; + + # WireGuard interface creation (host-side oneshot) + # Creates the interface in the host namespace so encrypted UDP stays in host netns. + # The container takes ownership of the interface on startup via `interfaces = [ ... ]`. + systemd.services.pia-vpn-wg-create = { + description = "Create PIA VPN WireGuard interface"; + + before = [ "container@pia-vpn.service" ]; + requiredBy = [ "container@pia-vpn.service" ]; + partOf = [ "container@pia-vpn.service" ]; + wantedBy = [ "multi-user.target" ]; + + path = with pkgs; [ iproute2 ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + + script = '' + [[ -z $(ip link show dev ${cfg.interfaceName} 2>/dev/null) ]] || exit 0 + ip link add ${cfg.interfaceName} type wireguard + ''; + + preStop = '' + ip link del ${cfg.interfaceName} 2>/dev/null || true + ''; + }; + + # Host entries for container hostnames — NixOS only auto-creates these for + # hostAddress/localAddress containers, not hostBridge. Use the standard + # {name}.containers convention. + networking.hosts = + { ${cfg.vpnAddress} = [ "pia-vpn.containers" ]; } + // mapAttrs' (name: ctr: nameValuePair ctr.ip [ "${name}.containers" ]) cfg.containers; + + # PIA login secret + age.secrets."pia-login.conf".file = ../../../secrets/pia-login.age; + + # IP forwarding needed for bridge traffic between containers + networking.ip_forward = true; + }; +} diff --git a/common/network/pia-vpn/scripts.nix b/common/network/pia-vpn/scripts.nix new file mode 100644 index 0000000..6cad0bb --- /dev/null +++ b/common/network/pia-vpn/scripts.nix @@ -0,0 +1,201 @@ +let + caPath = ./ca.rsa.4096.crt; +in + +# Bash function library for PIA VPN WireGuard operations. +# All PIA API calls accept an optional $proxy variable: +# proxy="http://10.100.0.1:8888" fetchPIAToken +# When $proxy is set, curl uses --proxy "$proxy"; otherwise direct connection. + +# Reference materials: +# https://serverlist.piaservers.net/vpninfo/servers/v6 +# https://github.com/pia-foss/manual-connections +# https://github.com/thrnz/docker-wireguard-pia/blob/master/extra/wg-gen.sh +# https://www.wireguard.com/netns/#ordinary-containerization + +{ + scriptCommon = '' + proxy_args() { + if [[ -n "''${proxy:-}" ]]; then + echo "--proxy $proxy" + fi + } + + fetchPIAToken() { + local PIA_USER PIA_PASS resp + echo "Reading PIA credentials..." + PIA_USER=$(sed '1q;d' /run/agenix/pia-login.conf) + PIA_PASS=$(sed '2q;d' /run/agenix/pia-login.conf) + echo "Requesting PIA authentication token..." + resp=$(curl -s $(proxy_args) -u "$PIA_USER:$PIA_PASS" \ + "https://www.privateinternetaccess.com/gtoken/generateToken") + PIA_TOKEN=$(echo "$resp" | jq -r '.token') + if [[ -z "$PIA_TOKEN" || "$PIA_TOKEN" == "null" ]]; then + echo "ERROR: Failed to fetch PIA token: $resp" >&2 + return 1 + fi + echo "PIA token acquired" + } + + choosePIAServer() { + local serverLocation=$1 + local servers servers_json totalservers serverindex + servers=$(mktemp) + servers_json=$(mktemp) + echo "Fetching PIA server list..." + curl -s $(proxy_args) \ + "https://serverlist.piaservers.net/vpninfo/servers/v6" > "$servers" + head -n 1 "$servers" | tr -d '\n' > "$servers_json" + + echo "Available location ids:" + jq '.regions | .[] | {name, id, port_forward}' "$servers_json" + + totalservers=$(jq -r \ + '.regions | .[] | select(.id=="'"$serverLocation"'") | .servers.wg | length' \ + "$servers_json") + if ! [[ "$totalservers" =~ ^[0-9]+$ ]] || [ "$totalservers" -eq 0 ] 2>/dev/null; then + echo "ERROR: Location \"$serverLocation\" not found." >&2 + rm -f "$servers_json" "$servers" + return 1 + fi + echo "Found $totalservers WireGuard servers in region '$serverLocation'" + serverindex=$(( RANDOM % totalservers )) + + WG_HOSTNAME=$(jq -r \ + '.regions | .[] | select(.id=="'"$serverLocation"'") | .servers.wg | .['"$serverindex"'].cn' \ + "$servers_json") + WG_SERVER_IP=$(jq -r \ + '.regions | .[] | select(.id=="'"$serverLocation"'") | .servers.wg | .['"$serverindex"'].ip' \ + "$servers_json") + WG_SERVER_PORT=$(jq -r '.groups.wg | .[0] | .ports | .[0]' "$servers_json") + + rm -f "$servers_json" "$servers" + echo "Selected server $serverindex/$totalservers: $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)" + } + + generateWireguardKey() { + PRIVATE_KEY=$(wg genkey) + PUBLIC_KEY=$(echo "$PRIVATE_KEY" | wg pubkey) + echo "Generated WireGuard keypair" + } + + authorizeKeyWithPIAServer() { + local addKeyResponse + echo "Sending addKey request to $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)..." + addKeyResponse=$(curl -s -G $(proxy_args) \ + --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \ + --cacert "${caPath}" \ + --data-urlencode "pt=$PIA_TOKEN" \ + --data-urlencode "pubkey=$PUBLIC_KEY" \ + "https://$WG_HOSTNAME:$WG_SERVER_PORT/addKey") + local status + status=$(echo "$addKeyResponse" | jq -r '.status') + if [[ "$status" != "OK" ]]; then + echo "ERROR: addKey failed: $addKeyResponse" >&2 + return 1 + fi + MY_IP=$(echo "$addKeyResponse" | jq -r '.peer_ip') + WG_SERVER_PUBLIC_KEY=$(echo "$addKeyResponse" | jq -r '.server_key') + WG_SERVER_PORT=$(echo "$addKeyResponse" | jq -r '.server_port') + echo "Key authorized — assigned VPN IP: $MY_IP, server port: $WG_SERVER_PORT" + } + + writeWireguardQuickFile() { + local wgFile=$1 + local listenPort=$2 + rm -f "$wgFile" + touch "$wgFile" + chmod 700 "$wgFile" + cat > "$wgFile" < "$serverFile" + chmod 700 "$serverFile" + echo "Wrote server info to $serverFile" + } + + loadChosenServerFromFile() { + local serverFile=$1 + WG_HOSTNAME=$(jq -r '.hostname' "$serverFile") + WG_SERVER_IP=$(jq -r '.ip' "$serverFile") + WG_SERVER_PORT=$(jq -r '.port' "$serverFile") + echo "Loaded server info from $serverFile: $WG_HOSTNAME ($WG_SERVER_IP:$WG_SERVER_PORT)" + } + + connectToServer() { + local wgFile=$1 + local interfaceName=$2 + + echo "Applying WireGuard config to $interfaceName..." + wg setconf "$interfaceName" "$wgFile" + ip -4 address add "$MY_IP" dev "$interfaceName" + ip link set mtu 1420 up dev "$interfaceName" + echo "WireGuard interface $interfaceName is up with IP $MY_IP" + } + + reservePortForward() { + local payload_and_signature + echo "Requesting port forward signature from $WG_HOSTNAME..." + payload_and_signature=$(curl -s -m 5 $(proxy_args) \ + --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \ + --cacert "${caPath}" \ + -G --data-urlencode "token=$PIA_TOKEN" \ + "https://$WG_HOSTNAME:19999/getSignature") + local status + status=$(echo "$payload_and_signature" | jq -r '.status') + if [[ "$status" != "OK" ]]; then + echo "ERROR: getSignature failed: $payload_and_signature" >&2 + return 1 + fi + PORT_SIGNATURE=$(echo "$payload_and_signature" | jq -r '.signature') + PORT_PAYLOAD=$(echo "$payload_and_signature" | jq -r '.payload') + PORT=$(echo "$PORT_PAYLOAD" | base64 -d | jq -r '.port') + echo "Port forward reserved: port $PORT" + } + + writePortRenewalFile() { + local portRenewalFile=$1 + jq -n \ + --arg signature "$PORT_SIGNATURE" \ + --arg payload "$PORT_PAYLOAD" \ + '{signature: $signature, payload: $payload}' > "$portRenewalFile" + chmod 700 "$portRenewalFile" + echo "Wrote port renewal data to $portRenewalFile" + } + + readPortRenewalFile() { + local portRenewalFile=$1 + PORT_SIGNATURE=$(jq -r '.signature' "$portRenewalFile") + PORT_PAYLOAD=$(jq -r '.payload' "$portRenewalFile") + echo "Loaded port renewal data from $portRenewalFile" + } + + refreshPIAPort() { + local bindPortResponse + echo "Refreshing port forward binding with $WG_HOSTNAME..." + bindPortResponse=$(curl -Gs -m 5 \ + --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" \ + --cacert "${caPath}" \ + --data-urlencode "payload=$PORT_PAYLOAD" \ + --data-urlencode "signature=$PORT_SIGNATURE" \ + "https://$WG_HOSTNAME:19999/bindPort") + echo "bindPort response: $bindPortResponse" + } + ''; +} diff --git a/common/network/pia-vpn/service-container.nix b/common/network/pia-vpn/service-container.nix new file mode 100644 index 0000000..f02a26e --- /dev/null +++ b/common/network/pia-vpn/service-container.nix @@ -0,0 +1,68 @@ +{ config, lib, allModules, ... }: + +# Generates service containers that route all traffic through the VPN container. +# Each container gets a static IP on the VPN bridge with default route → VPN container. +# +# Uses lazy mapAttrs inside fixed config keys to avoid infinite recursion. +# (mkMerge + mapAttrsToList at the top level forces eager evaluation of cfg.containers +# during module structure discovery, which creates a cycle with config evaluation.) + +with lib; + +let + cfg = config.pia-vpn; + + mkContainer = name: ctr: { + autoStart = true; + ephemeral = true; + privateNetwork = true; + hostBridge = cfg.bridgeName; + + bindMounts = mapAttrs + (_: mount: { + hostPath = mount.hostPath; + isReadOnly = mount.isReadOnly; + }) + ctr.mounts; + + config = { config, pkgs, lib, ... }: { + imports = allModules ++ [ ctr.config ]; + + # Static IP with gateway pointing to VPN container + networking.useNetworkd = true; + systemd.network.enable = true; + networking.useDHCP = false; + + systemd.network.networks."20-eth0" = { + matchConfig.Name = "eth0"; + networkConfig = { + Address = "${ctr.ip}/${cfg.subnetPrefixLen}"; + Gateway = cfg.vpnAddress; + DNS = [ cfg.vpnAddress ]; + }; + linkConfig.RequiredForOnline = "no"; + }; + + # DNS through VPN container (queries go through WG tunnel = no DNS leak) + networking.nameservers = [ cfg.vpnAddress ]; + + # Trust the bridge interface (host reaches us directly for nginx) + networking.firewall.trustedInterfaces = [ "eth0" ]; + + # Disable host resolv.conf — we use our own networkd DNS config + networking.useHostResolvConf = false; + }; + }; + + mkContainerOrdering = name: _ctr: nameValuePair "container@${name}" { + after = [ "container@pia-vpn.service" ]; + requires = [ "container@pia-vpn.service" ]; + partOf = [ "container@pia-vpn.service" ]; + }; +in +{ + config = mkIf cfg.enable { + containers = mapAttrs mkContainer cfg.containers; + systemd.services = mapAttrs' mkContainerOrdering cfg.containers; + }; +} diff --git a/common/network/pia-vpn/vpn-container.nix b/common/network/pia-vpn/vpn-container.nix new file mode 100644 index 0000000..de1a55e --- /dev/null +++ b/common/network/pia-vpn/vpn-container.nix @@ -0,0 +1,223 @@ +{ config, lib, allModules, ... }: + +# VPN container: runs all PIA logic, acts as WireGuard gateway + NAT for service containers. + +with lib; + +let + cfg = config.pia-vpn; + scripts = import ./scripts.nix; + + # Port forwarding derived state + forwardingContainers = filterAttrs (_: c: c.receiveForwardedPort != null) cfg.containers; + portForwarding = forwardingContainers != { }; + forwardingContainerName = if portForwarding then head (attrNames forwardingContainers) else null; + forwardingContainer = if portForwarding then forwardingContainers.${forwardingContainerName} else null; + + serverFile = "/var/lib/pia-vpn/server.json"; + wgFile = "/var/lib/pia-vpn/wg.conf"; + portRenewalFile = "/var/lib/pia-vpn/port-renewal.json"; + proxy = "http://${cfg.hostAddress}:${toString cfg.proxyPort}"; + + # DNAT/forwarding rules for port forwarding + dnatSetupScript = optionalString portForwarding ( + let + fwd = forwardingContainer.receiveForwardedPort; + targetIp = forwardingContainer.ip; + targetPort = toString fwd.port; + tcpRules = optionalString (fwd.protocol == "tcp" || fwd.protocol == "both") '' + echo "Setting up TCP DNAT: port $PORT → ${targetIp}:${targetPort}" + iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p tcp --dport $PORT -j DNAT --to ${targetIp}:${targetPort} + iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p tcp --dport ${targetPort} -j ACCEPT + ''; + udpRules = optionalString (fwd.protocol == "udp" || fwd.protocol == "both") '' + echo "Setting up UDP DNAT: port $PORT → ${targetIp}:${targetPort}" + iptables -t nat -A PREROUTING -i ${cfg.interfaceName} -p udp --dport $PORT -j DNAT --to ${targetIp}:${targetPort} + iptables -A FORWARD -i ${cfg.interfaceName} -d ${targetIp} -p udp --dport ${targetPort} -j ACCEPT + ''; + onPortForwarded = optionalString (forwardingContainer.onPortForwarded != null) '' + TARGET_IP="${targetIp}" + export PORT TARGET_IP + echo "Running onPortForwarded hook for ${forwardingContainerName} (port=$PORT, target=$TARGET_IP)" + ${forwardingContainer.onPortForwarded} + ''; + in + '' + ${tcpRules} + ${udpRules} + ${onPortForwarded} + '' + ); +in +{ + config = mkIf cfg.enable { + containers.pia-vpn = { + autoStart = true; + ephemeral = true; + privateNetwork = true; + hostBridge = cfg.bridgeName; + interfaces = [ cfg.interfaceName ]; + + bindMounts."/run/agenix" = { + hostPath = "/run/agenix"; + isReadOnly = true; + }; + + config = { config, pkgs, lib, ... }: + let + scriptPkgs = with pkgs; [ wireguard-tools iproute2 curl jq iptables coreutils ]; + in + { + imports = allModules; + + # Static IP on bridge — no gateway (VPN container routes via WG only) + networking.useNetworkd = true; + systemd.network.enable = true; + networking.useDHCP = false; + + systemd.network.networks."20-eth0" = { + matchConfig.Name = "eth0"; + networkConfig = { + Address = "${cfg.vpnAddress}/${cfg.subnetPrefixLen}"; + DHCPServer = false; + }; + linkConfig.RequiredForOnline = "no"; + }; + + # Enable forwarding so bridge traffic can go through WG + boot.kernel.sysctl."net.ipv4.ip_forward" = 1; + + # Trust bridge interface + networking.firewall.trustedInterfaces = [ "eth0" ]; + + # DNS: use systemd-resolved listening on bridge IP so service containers + # can use VPN container as DNS server (queries go through WG tunnel = no DNS leak) + services.resolved = { + enable = true; + settings.Resolve.DNSStubListenerExtra = cfg.vpnAddress; + }; + + # Don't use host resolv.conf — resolved manages DNS + networking.useHostResolvConf = false; + + # State directory for PIA config files + systemd.tmpfiles.rules = [ + "d /var/lib/pia-vpn 0700 root root -" + ]; + + # PIA VPN setup service — does all the PIA auth, WG config, and NAT setup + systemd.services.pia-vpn-setup = { + description = "PIA VPN WireGuard Setup"; + + wants = [ "network-online.target" ]; + after = [ "network.target" "network-online.target" "systemd-networkd.service" ]; + wantedBy = [ "multi-user.target" ]; + + path = scriptPkgs; + + serviceConfig = { + Type = "simple"; + Restart = "always"; + RuntimeMaxSec = "30d"; + }; + + script = '' + set -euo pipefail + ${scripts.scriptCommon} + + proxy="${proxy}" + + # 1. Authenticate with PIA via proxy (VPN container has no internet yet) + echo "Choosing PIA server in region '${cfg.serverLocation}'..." + choosePIAServer '${cfg.serverLocation}' + + echo "Fetching PIA authentication token..." + fetchPIAToken + + # 2. Generate WG keys and authorize with PIA server + echo "Generating WireGuard keypair..." + generateWireguardKey + + echo "Authorizing key with PIA server $WG_HOSTNAME..." + authorizeKeyWithPIAServer + + # 3. Configure WG interface (already created by host and moved into our namespace) + echo "Configuring WireGuard interface ${cfg.interfaceName}..." + writeWireguardQuickFile '${wgFile}' ${toString cfg.wireguardListenPort} + writeChosenServerToFile '${serverFile}' + connectToServer '${wgFile}' '${cfg.interfaceName}' + + # 4. Default route through WG + ip route add default dev ${cfg.interfaceName} + echo "Default route set through ${cfg.interfaceName}" + + # 5. NAT: masquerade bridge → WG (so service containers' traffic appears to come from VPN IP) + echo "Setting up NAT masquerade..." + iptables -t nat -A POSTROUTING -o ${cfg.interfaceName} -j MASQUERADE + iptables -A FORWARD -i eth0 -o ${cfg.interfaceName} -j ACCEPT + iptables -A FORWARD -i ${cfg.interfaceName} -o eth0 -m state --state RELATED,ESTABLISHED -j ACCEPT + + ${optionalString portForwarding '' + # 6. Port forwarding setup + echo "Reserving port forward..." + reservePortForward + writePortRenewalFile '${portRenewalFile}' + + # First bindPort triggers actual port allocation + echo "Binding port $PORT..." + refreshPIAPort + + echo "PIA assigned port: $PORT" + + # DNAT rules to forward PIA port to target container + ${dnatSetupScript} + ''} + + echo "PIA VPN setup complete" + exec sleep infinity + ''; + + preStop = '' + echo "Tearing down PIA VPN..." + ip -4 address flush dev ${cfg.interfaceName} 2>/dev/null || true + ip route del default dev ${cfg.interfaceName} 2>/dev/null || true + iptables -t nat -F POSTROUTING 2>/dev/null || true + iptables -F FORWARD 2>/dev/null || true + ${optionalString portForwarding '' + iptables -t nat -F PREROUTING 2>/dev/null || true + ''} + ''; + }; + + # Port refresh timer (every 10 min) — keeps PIA port forwarding alive + systemd.services.pia-vpn-port-refresh = mkIf portForwarding { + description = "PIA VPN Port Forward Refresh"; + after = [ "pia-vpn-setup.service" ]; + requires = [ "pia-vpn-setup.service" ]; + + path = scriptPkgs; + + serviceConfig.Type = "oneshot"; + + script = '' + set -euo pipefail + ${scripts.scriptCommon} + loadChosenServerFromFile '${serverFile}' + readPortRenewalFile '${portRenewalFile}' + echo "Refreshing PIA port forward..." + refreshPIAPort + ''; + }; + + systemd.timers.pia-vpn-port-refresh = mkIf portForwarding { + partOf = [ "pia-vpn-port-refresh.service" ]; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*:0/10"; + RandomizedDelaySec = "1m"; + }; + }; + }; + }; + }; +} diff --git a/common/network/pia-wireguard.nix b/common/network/pia-wireguard.nix deleted file mode 100644 index 0305f7f..0000000 --- a/common/network/pia-wireguard.nix +++ /dev/null @@ -1,363 +0,0 @@ -{ config, lib, pkgs, ... }: - -# Server list: -# https://serverlist.piaservers.net/vpninfo/servers/v6 -# Reference materials: -# https://github.com/pia-foss/manual-connections -# https://github.com/thrnz/docker-wireguard-pia/blob/master/extra/wg-gen.sh - -# TODO handle potential errors (or at least print status, success, and failures to the console) -# TODO parameterize names of systemd services so that multiple wg VPNs could coexist in theory easier -# TODO implement this module such that the wireguard VPN doesn't have to live in a container -# TODO don't add forward rules if the PIA port is the same as cfg.forwardedPort -# TODO verify signatures of PIA responses -# TODO `RuntimeMaxSec = "30d";` for pia-vpn-wireguard-init isn't allowed per the systemd logs. Find alternative. - -with builtins; -with lib; - -let - cfg = config.pia.wireguard; - - getPIAToken = '' - PIA_USER=`sed '1q;d' /run/agenix/pia-login.conf` - PIA_PASS=`sed '2q;d' /run/agenix/pia-login.conf` - # PIA_TOKEN only lasts 24hrs - PIA_TOKEN=`curl -s -u "$PIA_USER:$PIA_PASS" https://www.privateinternetaccess.com/gtoken/generateToken | jq -r '.token'` - ''; - - chooseWireguardServer = '' - servers=$(mktemp) - servers_json=$(mktemp) - curl -s "https://serverlist.piaservers.net/vpninfo/servers/v6" > "$servers" - # extract json part only - head -n 1 "$servers" | tr -d '\n' > "$servers_json" - - echo "Available location ids:" && jq '.regions | .[] | {name, id, port_forward}' "$servers_json" - - # Some locations have multiple servers available. Pick a random one. - totalservers=$(jq -r '.regions | .[] | select(.id=="'${cfg.serverLocation}'") | .servers.wg | length' "$servers_json") - if ! [[ "$totalservers" =~ ^[0-9]+$ ]] || [ "$totalservers" -eq 0 ] 2>/dev/null; then - echo "Location \"${cfg.serverLocation}\" not found." - exit 1 - fi - serverindex=$(( RANDOM % totalservers)) - WG_HOSTNAME=$(jq -r '.regions | .[] | select(.id=="'${cfg.serverLocation}'") | .servers.wg | .['$serverindex'].cn' "$servers_json") - WG_SERVER_IP=$(jq -r '.regions | .[] | select(.id=="'${cfg.serverLocation}'") | .servers.wg | .['$serverindex'].ip' "$servers_json") - WG_SERVER_PORT=$(jq -r '.groups.wg | .[0] | .ports | .[0]' "$servers_json") - - # write chosen server - rm -f /tmp/${cfg.interfaceName}-server.conf - touch /tmp/${cfg.interfaceName}-server.conf - chmod 700 /tmp/${cfg.interfaceName}-server.conf - echo "$WG_HOSTNAME" >> /tmp/${cfg.interfaceName}-server.conf - echo "$WG_SERVER_IP" >> /tmp/${cfg.interfaceName}-server.conf - echo "$WG_SERVER_PORT" >> /tmp/${cfg.interfaceName}-server.conf - - rm $servers_json $servers - ''; - - getChosenWireguardServer = '' - WG_HOSTNAME=`sed '1q;d' /tmp/${cfg.interfaceName}-server.conf` - WG_SERVER_IP=`sed '2q;d' /tmp/${cfg.interfaceName}-server.conf` - WG_SERVER_PORT=`sed '3q;d' /tmp/${cfg.interfaceName}-server.conf` - ''; - - refreshPIAPort = '' - ${getChosenWireguardServer} - signature=`sed '1q;d' /tmp/${cfg.interfaceName}-port-renewal` - payload=`sed '2q;d' /tmp/${cfg.interfaceName}-port-renewal` - bind_port_response=`curl -Gs -m 5 --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" --cacert "${./ca.rsa.4096.crt}" --data-urlencode "payload=$payload" --data-urlencode "signature=$signature" "https://$WG_HOSTNAME:19999/bindPort"` - ''; - - portForwarding = cfg.forwardPortForTransmission || cfg.forwardedPort != null; - - containerServiceName = "container@${config.vpn-container.containerName}.service"; -in -{ - options.pia.wireguard = { - enable = mkEnableOption "Enable private internet access"; - badPortForwardPorts = mkOption { - type = types.listOf types.port; - description = '' - Ports that will not be accepted from PIA. - If PIA assigns a port from this list, the connection is aborted since we cannot ask for a different port. - This is used to guarantee we are not assigned a port that is used by a service we do not want exposed. - ''; - }; - wireguardListenPort = mkOption { - type = types.port; - description = "The port wireguard listens on for this VPN connection"; - default = 51820; - }; - serverLocation = mkOption { - type = types.str; - default = "swiss"; - }; - interfaceName = mkOption { - type = types.str; - default = "piaw"; - }; - forwardedPort = mkOption { - type = types.nullOr types.port; - description = "The port to redirect port forwarded TCP VPN traffic too"; - default = null; - }; - forwardPortForTransmission = mkEnableOption "PIA port forwarding for transmission should be performed."; - }; - - config = mkIf cfg.enable { - assertions = [ - { - assertion = cfg.forwardPortForTransmission != (cfg.forwardedPort != null); - message = '' - The PIA forwarded port cannot simultaneously be used by transmission and redirected to another port. - ''; - } - ]; - - # mounts used to pass the connection parameters to the container - # the container doesn't have internet until it uses these parameters so it cannot fetch them itself - vpn-container.mounts = [ - "/tmp/${cfg.interfaceName}.conf" - "/tmp/${cfg.interfaceName}-server.conf" - "/tmp/${cfg.interfaceName}-address.conf" - ]; - - # The container takes ownership of the wireguard interface on its startup - containers.vpn.interfaces = [ cfg.interfaceName ]; - - # TODO: while this is much better than "loose" networking, it seems to have issues with firewall restarts - # allow traffic for wireguard interface to pass since wireguard trips up rpfilter - # networking.firewall = { - # extraCommands = '' - # ip46tables -t raw -I nixos-fw-rpfilter -p udp -m udp --sport ${toString cfg.wireguardListenPort} -j RETURN - # ip46tables -t raw -I nixos-fw-rpfilter -p udp -m udp --dport ${toString cfg.wireguardListenPort} -j RETURN - # ''; - # extraStopCommands = '' - # ip46tables -t raw -D nixos-fw-rpfilter -p udp -m udp --sport ${toString cfg.wireguardListenPort} -j RETURN || true - # ip46tables -t raw -D nixos-fw-rpfilter -p udp -m udp --dport ${toString cfg.wireguardListenPort} -j RETURN || true - # ''; - # }; - networking.firewall.checkReversePath = "loose"; - - systemd.services.pia-vpn-wireguard-init = { - description = "Creates PIA VPN Wireguard Interface"; - - wants = [ "network-online.target" ]; - after = [ "network.target" "network-online.target" ]; - before = [ containerServiceName ]; - requiredBy = [ containerServiceName ]; - partOf = [ containerServiceName ]; - wantedBy = [ "multi-user.target" ]; - - path = with pkgs; [ wireguard-tools jq curl iproute2 iputils ]; - - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - - # restart once a month; PIA forwarded port expires after two months - # because the container is "PartOf" this unit, it gets restarted too - RuntimeMaxSec = "30d"; - }; - - script = '' - echo Waiting for internet... - while ! ping -c 1 -W 1 1.1.1.1; do - sleep 1 - done - - # Prepare to connect by generating wg secrets and auth'ing with PIA since the container - # cannot do without internet to start with. NAT'ing the host's internet would address this - # issue but is not ideal because then leaking network outside of the VPN is more likely. - - ${chooseWireguardServer} - - ${getPIAToken} - - # generate wireguard keys - privKey=$(wg genkey) - pubKey=$(echo "$privKey" | wg pubkey) - - # authorize our WG keys with the PIA server we are about to connect to - wireguard_json=`curl -s -G --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" --cacert "${./ca.rsa.4096.crt}" --data-urlencode "pt=$PIA_TOKEN" --data-urlencode "pubkey=$pubKey" https://$WG_HOSTNAME:$WG_SERVER_PORT/addKey` - - # create wg-quick config file - rm -f /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf - touch /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf - chmod 700 /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf - echo " - [Interface] - # Address = $(echo "$wireguard_json" | jq -r '.peer_ip') - PrivateKey = $privKey - ListenPort = ${toString cfg.wireguardListenPort} - [Peer] - PersistentKeepalive = 25 - PublicKey = $(echo "$wireguard_json" | jq -r '.server_key') - AllowedIPs = 0.0.0.0/0 - Endpoint = $WG_SERVER_IP:$(echo "$wireguard_json" | jq -r '.server_port') - " >> /tmp/${cfg.interfaceName}.conf - - # create file storing the VPN ip address PIA assigned to us - echo "$wireguard_json" | jq -r '.peer_ip' >> /tmp/${cfg.interfaceName}-address.conf - - # Create wg interface now so it inherits from the namespace with internet access - # the container will handle actually connecting the interface since that info is - # not preserved upon moving into the container's networking namespace - # Roughly following this guide https://www.wireguard.com/netns/#ordinary-containerization - [[ -z $(ip link show dev ${cfg.interfaceName} 2>/dev/null) ]] || exit - ip link add ${cfg.interfaceName} type wireguard - ''; - - preStop = '' - # cleanup wireguard interface - ip link del ${cfg.interfaceName} - rm -f /tmp/${cfg.interfaceName}.conf /tmp/${cfg.interfaceName}-address.conf - ''; - }; - - vpn-container.config.systemd.services.pia-vpn-wireguard = { - description = "Initializes the PIA VPN WireGuard Tunnel"; - - wants = [ "network-online.target" ]; - after = [ "network.target" "network-online.target" ]; - wantedBy = [ "multi-user.target" ]; - - path = with pkgs; [ wireguard-tools iproute2 curl jq iptables ]; - - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - }; - - script = '' - # pseudo calls wg-quick - # Near equivalent of "wg-quick up /tmp/${cfg.interfaceName}.conf" - # cannot actually call wg-quick because the interface has to be already - # created before the container taken ownership of the interface - # Thus, assumes wg interface was already created: - # ip link add ${cfg.interfaceName} type wireguard - - ${getChosenWireguardServer} - - myaddress=`cat /tmp/${cfg.interfaceName}-address.conf` - - wg setconf ${cfg.interfaceName} /tmp/${cfg.interfaceName}.conf - ip -4 address add $myaddress dev ${cfg.interfaceName} - ip link set mtu 1420 up dev ${cfg.interfaceName} - wg set ${cfg.interfaceName} fwmark ${toString cfg.wireguardListenPort} - ip -4 route add 0.0.0.0/0 dev ${cfg.interfaceName} table ${toString cfg.wireguardListenPort} - - # TODO is this needed? - ip -4 rule add not fwmark ${toString cfg.wireguardListenPort} table ${toString cfg.wireguardListenPort} - ip -4 rule add table main suppress_prefixlength 0 - - # The rest of the script is only for only for port forwarding skip if not needed - if [ ${boolToString portForwarding} == false ]; then exit 0; fi - - # Reserve port - ${getPIAToken} - payload_and_signature=`curl -s -m 5 --connect-to "$WG_HOSTNAME::$WG_SERVER_IP:" --cacert "${./ca.rsa.4096.crt}" -G --data-urlencode "token=$PIA_TOKEN" "https://$WG_HOSTNAME:19999/getSignature"` - signature=$(echo "$payload_and_signature" | jq -r '.signature') - payload=$(echo "$payload_and_signature" | jq -r '.payload') - port=$(echo "$payload" | base64 -d | jq -r '.port') - - # Check if the port is acceptable - notallowed=(${concatStringsSep " " (map toString cfg.badPortForwardPorts)}) - if [[ " ''${notallowed[*]} " =~ " $port " ]]; then - # the port PIA assigned is not allowed, kill the connection - wg-quick down /tmp/${cfg.interfaceName}.conf - exit 1 - fi - - # write reserved port to file readable for all users - echo $port > /tmp/${cfg.interfaceName}-port - chmod 644 /tmp/${cfg.interfaceName}-port - - # write payload and signature info needed to allow refreshing allocated forwarded port - rm -f /tmp/${cfg.interfaceName}-port-renewal - touch /tmp/${cfg.interfaceName}-port-renewal - chmod 700 /tmp/${cfg.interfaceName}-port-renewal - echo $signature >> /tmp/${cfg.interfaceName}-port-renewal - echo $payload >> /tmp/${cfg.interfaceName}-port-renewal - - # Block all traffic from VPN interface except for traffic that is from the forwarded port - iptables -I nixos-fw -p tcp --dport $port -j nixos-fw-accept -i ${cfg.interfaceName} - iptables -I nixos-fw -p udp --dport $port -j nixos-fw-accept -i ${cfg.interfaceName} - - # The first port refresh triggers the port to be actually allocated - ${refreshPIAPort} - - ${optionalString (cfg.forwardedPort != null) '' - # redirect the fowarded port - iptables -A INPUT -i ${cfg.interfaceName} -p tcp --dport $port -j ACCEPT - iptables -A INPUT -i ${cfg.interfaceName} -p udp --dport $port -j ACCEPT - iptables -A INPUT -i ${cfg.interfaceName} -p tcp --dport ${toString cfg.forwardedPort} -j ACCEPT - iptables -A INPUT -i ${cfg.interfaceName} -p udp --dport ${toString cfg.forwardedPort} -j ACCEPT - iptables -A PREROUTING -t nat -i ${cfg.interfaceName} -p tcp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort} - iptables -A PREROUTING -t nat -i ${cfg.interfaceName} -p udp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort} - ''} - - ${optionalString cfg.forwardPortForTransmission '' - # assumes no auth needed for transmission - curlout=$(curl localhost:9091/transmission/rpc 2>/dev/null) - regex='X-Transmission-Session-Id\: (\w*)' - if [[ $curlout =~ $regex ]]; then - sessionId=''${BASH_REMATCH[1]} - else - exit 1 - fi - - # set the port in transmission - data='{"method": "session-set", "arguments": { "peer-port" :'$port' } }' - curl http://localhost:9091/transmission/rpc -d "$data" -H "X-Transmission-Session-Id: $sessionId" - ''} - ''; - - preStop = '' - wg-quick down /tmp/${cfg.interfaceName}.conf - - # The rest of the script is only for only for port forwarding skip if not needed - if [ ${boolToString portForwarding} == false ]; then exit 0; fi - - ${optionalString (cfg.forwardedPort != null) '' - # stop redirecting the forwarded port - iptables -D INPUT -i ${cfg.interfaceName} -p tcp --dport $port -j ACCEPT - iptables -D INPUT -i ${cfg.interfaceName} -p udp --dport $port -j ACCEPT - iptables -D INPUT -i ${cfg.interfaceName} -p tcp --dport ${toString cfg.forwardedPort} -j ACCEPT - iptables -D INPUT -i ${cfg.interfaceName} -p udp --dport ${toString cfg.forwardedPort} -j ACCEPT - iptables -D PREROUTING -t nat -i ${cfg.interfaceName} -p tcp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort} - iptables -D PREROUTING -t nat -i ${cfg.interfaceName} -p udp --dport $port -j REDIRECT --to-port ${toString cfg.forwardedPort} - ''} - ''; - }; - - vpn-container.config.systemd.services.pia-vpn-wireguard-forward-port = { - enable = portForwarding; - description = "PIA VPN WireGuard Tunnel Port Forwarding"; - after = [ "pia-vpn-wireguard.service" ]; - requires = [ "pia-vpn-wireguard.service" ]; - - path = with pkgs; [ curl ]; - - serviceConfig = { - Type = "oneshot"; - }; - - script = refreshPIAPort; - }; - - vpn-container.config.systemd.timers.pia-vpn-wireguard-forward-port = { - enable = portForwarding; - partOf = [ "pia-vpn-wireguard-forward-port.service" ]; - wantedBy = [ "timers.target" ]; - timerConfig = { - OnCalendar = "*:0/10"; # 10 minutes - RandomizedDelaySec = "1m"; # vary by 1 min to give PIA servers some relief - }; - }; - - age.secrets."pia-login.conf".file = ../../secrets/pia-login.age; - }; -} diff --git a/common/network/vpn.nix b/common/network/vpn.nix deleted file mode 100644 index f2f37a8..0000000 --- a/common/network/vpn.nix +++ /dev/null @@ -1,106 +0,0 @@ -{ config, lib, allModules, ... }: - -with lib; - -let - cfg = config.vpn-container; -in -{ - options.vpn-container = { - enable = mkEnableOption "Enable VPN container"; - - containerName = mkOption { - type = types.str; - default = "vpn"; - description = '' - Name of the VPN container. - ''; - }; - - mounts = mkOption { - type = types.listOf types.str; - default = [ "/var/lib" ]; - example = "/home/example"; - description = '' - List of mounts on the host to bind to the vpn container. - ''; - }; - - useOpenVPN = mkEnableOption "Uses OpenVPN instead of wireguard for PIA VPN connection"; - - config = mkOption { - type = types.anything; - default = { }; - example = '' - { - services.nginx.enable = true; - } - ''; - description = '' - NixOS config for the vpn container. - ''; - }; - }; - - config = mkIf cfg.enable { - pia.wireguard.enable = !cfg.useOpenVPN; - pia.wireguard.forwardPortForTransmission = !cfg.useOpenVPN; - - containers.${cfg.containerName} = { - ephemeral = true; - autoStart = true; - - bindMounts = mkMerge ([{ - "/run/agenix" = { - hostPath = "/run/agenix"; - isReadOnly = true; - }; - }] ++ (lists.forEach cfg.mounts (mount: - { - "${mount}" = { - hostPath = mount; - isReadOnly = false; - }; - } - ))); - - enableTun = cfg.useOpenVPN; - privateNetwork = true; - hostAddress = "172.16.100.1"; - localAddress = "172.16.100.2"; - - config = { - imports = allModules ++ [ cfg.config ]; - - # networking.firewall.enable = mkForce false; - networking.firewall.trustedInterfaces = [ - # completely trust internal interface to host - "eth0" - ]; - - pia.openvpn.enable = cfg.useOpenVPN; - pia.openvpn.server = "swiss.privacy.network"; # swiss vpn - - # TODO fix so it does run it's own resolver again - # run it's own DNS resolver - networking.useHostResolvConf = false; - # services.resolved.enable = true; - networking.nameservers = [ "1.1.1.1" "8.8.8.8" ]; - }; - }; - - # load secrets the container needs - age.secrets = config.containers.${cfg.containerName}.config.age.secrets; - - # forwarding for vpn container (only for OpenVPN) - networking.nat.enable = mkIf cfg.useOpenVPN true; - networking.nat.internalInterfaces = mkIf cfg.useOpenVPN [ - "ve-${cfg.containerName}" - ]; - networking.ip_forward = mkIf cfg.useOpenVPN true; - - # assumes only one potential interface - networking.usePredictableInterfaceNames = false; - networking.nat.externalInterface = "eth0"; - }; -} diff --git a/common/network/vpnfailsafe.sh b/common/network/vpnfailsafe.sh deleted file mode 100755 index e929c3c..0000000 --- a/common/network/vpnfailsafe.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash - -set -eEo pipefail - -# $@ := "" -set_route_vars() { - local network_var - local -a network_vars; read -ra network_vars <<<"${!route_network_*}" - for network_var in "${network_vars[@]}"; do - local -i i="${network_var#route_network_}" - local -a vars=("route_network_$i" "route_netmask_$i" "route_gateway_$i" "route_metric_$i") - route_networks[i]="${!vars[0]}" - route_netmasks[i]="${!vars[1]:-255.255.255.255}" - route_gateways[i]="${!vars[2]:-$route_vpn_gateway}" - route_metrics[i]="${!vars[3]:-0}" - done -} - -# Configuration. -readonly prog="$(basename "$0")" -readonly private_nets="127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" -declare -a remotes cnf_remote_domains cnf_remote_ips route_networks route_netmasks route_gateways route_metrics -read -ra remotes <<<"$(env|grep -oP '^remote_[0-9]+=.*'|sort -n|cut -d= -f2|tr '\n' '\t')" -read -ra cnf_remote_domains <<<"$(printf '%s\n' "${remotes[@]%%*[0-9]}"|sort -u|tr '\n' '\t')" -read -ra cnf_remote_ips <<<"$(printf '%s\n' "${remotes[@]##*[!0-9.]*}"|sort -u|tr '\n' '\t')" -set_route_vars -read -ra numbered_vars <<<"${!foreign_option_*} ${!proto_*} ${!remote_*} ${!remote_port_*} \ - ${!route_network_*} ${!route_netmask_*} ${!route_gateway_*} ${!route_metric_*}" -readonly numbered_vars "${numbered_vars[@]}" dev ifconfig_local ifconfig_netmask ifconfig_remote \ - route_net_gateway route_vpn_gateway script_type trusted_ip trusted_port untrusted_ip untrusted_port \ - remotes cnf_remote_domains cnf_remote_ips route_networks route_netmasks route_gateways route_metrics -readonly cur_remote_ip="${trusted_ip:-$untrusted_ip}" -readonly cur_port="${trusted_port:-$untrusted_port}" - -# $@ := "" -update_hosts() { - if remote_entries="$(getent -s dns hosts "${cnf_remote_domains[@]}"|grep -v :)"; then - local -r beg="# VPNFAILSAFE BEGIN" end="# VPNFAILSAFE END" - { - sed -e "/^$beg/,/^$end/d" /etc/hosts - echo -e "$beg\\n$remote_entries\\n$end" - } >/etc/hosts.vpnfailsafe - chmod --reference=/etc/hosts /etc/hosts.vpnfailsafe - mv /etc/hosts.vpnfailsafe /etc/hosts - fi -} - -# $@ := "up" | "down" -update_routes() { - local -a resolved_ips - read -ra resolved_ips <<<"$(getent -s files hosts "${cnf_remote_domains[@]:-ENOENT}"|cut -d' ' -f1|tr '\n' '\t' || true)" - local -ar remote_ips=("$cur_remote_ip" "${resolved_ips[@]}" "${cnf_remote_ips[@]}") - if [[ "$*" == up ]]; then - for remote_ip in "${remote_ips[@]}"; do - if [[ -n "$remote_ip" && -z "$(ip route show "$remote_ip")" ]]; then - ip route add "$remote_ip" via "$route_net_gateway" - fi - done - for net in 0.0.0.0/1 128.0.0.0/1; do - if [[ -z "$(ip route show "$net")" ]]; then - ip route add "$net" via "$route_vpn_gateway" - fi - done - for i in $(seq 1 "${#route_networks[@]}"); do - if [[ -z "$(ip route show "${route_networks[i]}/${route_netmasks[i]}")" ]]; then - ip route add "${route_networks[i]}/${route_netmasks[i]}" \ - via "${route_gateways[i]}" metric "${route_metrics[i]}" dev "$dev" - fi - done - elif [[ "$*" == down ]]; then - for route in "${remote_ips[@]}" 0.0.0.0/1 128.0.0.0/1; do - if [[ -n "$route" && -n "$(ip route show "$route")" ]]; then - ip route del "$route" - fi - done - for i in $(seq 1 "${#route_networks[@]}"); do - if [[ -n "$(ip route show "${route_networks[i]}/${route_netmasks[i]}")" ]]; then - ip route del "${route_networks[i]}/${route_netmasks[i]}" - fi - done - fi -} - -# $@ := "" -update_firewall() { - # $@ := "INPUT" | "OUTPUT" | "FORWARD" - insert_chain() { - if iptables -C "$*" -j "VPNFAILSAFE_$*" 2>/dev/null; then - iptables -D "$*" -j "VPNFAILSAFE_$*" - for opt in F X; do - iptables -"$opt" "VPNFAILSAFE_$*" - done - fi - iptables -N "VPNFAILSAFE_$*" - iptables -I "$*" -j "VPNFAILSAFE_$*" - } - - # $@ := "INPUT" | "OUTPUT" - accept_remotes() { - case "$@" in - INPUT) local -r icmp_type=reply io=i sd=s states="";; - OUTPUT) local -r icmp_type=request io=o sd=d states=NEW,;; - esac - local -r public_nic="$(ip route show "$cur_remote_ip"|cut -d' ' -f5)" - local -ar suf=(-m conntrack --ctstate "$states"RELATED,ESTABLISHED -"$io" "${public_nic:?}" -j ACCEPT) - icmp_rule() { - iptables "$1" "$2" -p icmp --icmp-type "echo-$icmp_type" -"$sd" "$3" "${suf[@]/%ACCEPT/RETURN}" - } - for ((i=1; i <= ${#remotes[*]}; ++i)); do - local port="remote_port_$i" - local proto="proto_$i" - iptables -A "VPNFAILSAFE_$*" -p "${!proto%-client}" -"$sd" "${remotes[i-1]}" --"$sd"port "${!port}" "${suf[@]}" - if ! icmp_rule -C "VPNFAILSAFE_$*" "${remotes[i-1]}" 2>/dev/null; then - icmp_rule -A "VPNFAILSAFE_$*" "${remotes[i-1]}" - fi - done - if ! iptables -S|grep -q "^-A VPNFAILSAFE_$* .*-$sd $cur_remote_ip/32 .*-j ACCEPT$"; then - for p in tcp udp; do - iptables -A "VPNFAILSAFE_$*" -p "$p" -"$sd" "$cur_remote_ip" --"$sd"port "${cur_port}" "${suf[@]}" - done - icmp_rule -A "VPNFAILSAFE_$*" "$cur_remote_ip" - fi - } - - # $@ := "OUTPUT" | "FORWARD" - reject_dns() { - for proto in udp tcp; do - iptables -A "VPNFAILSAFE_$*" -p "$proto" --dport 53 ! -o "$dev" -j REJECT - done - } - - # $@ := "INPUT" | "OUTPUT" | "FORWARD" - pass_private_nets() { - case "$@" in - INPUT) local -r io=i sd=s;;& - OUTPUT|FORWARD) local -r io=o sd=d;;& - INPUT) local -r vpn="${ifconfig_remote:-$ifconfig_local}/${ifconfig_netmask:-32}" - iptables -A "VPNFAILSAFE_$*" -"$sd" "$vpn" -"$io" "$dev" -j RETURN - for i in $(seq 1 "${#route_networks[@]}"); do - iptables -A "VPNFAILSAFE_$*" -"$sd" "${route_networks[i]}/${route_netmasks[i]}" -"$io" "$dev" -j RETURN - done;;& - *) iptables -A "VPNFAILSAFE_$*" -"$sd" "$private_nets" ! -"$io" "$dev" -j RETURN;;& - INPUT) iptables -A "VPNFAILSAFE_$*" -s "$private_nets" -i "$dev" -j DROP;;& - *) for iface in "$dev" lo+; do - iptables -A "VPNFAILSAFE_$*" -"$io" "$iface" -j RETURN - done;; - esac - } - - # $@ := "INPUT" | "OUTPUT" | "FORWARD" - drop_other() { - iptables -A "VPNFAILSAFE_$*" -j DROP - } - - for chain in INPUT OUTPUT FORWARD; do - insert_chain "$chain" - [[ $chain == FORWARD ]] || accept_remotes "$chain" - [[ $chain == INPUT ]] || reject_dns "$chain" - pass_private_nets "$chain" - drop_other "$chain" - done -} - -# $@ := "" -cleanup() { - update_resolv down - update_routes down -} -trap cleanup INT TERM - -# $@ := line_number exit_code -err_msg() { - echo "$0:$1: \`$(sed -n "$1,+0{s/^\\s*//;p}" "$0")' returned $2" >&2 - cleanup -} -trap 'err_msg "$LINENO" "$?"' ERR - -# $@ := "" -main() { - case "${script_type:-down}" in - up) for f in hosts routes firewall; do "update_$f" up; done;; - down) update_routes down - update_resolv down;; - esac -} - -main \ No newline at end of file diff --git a/machines/storage/s0/default.nix b/machines/storage/s0/default.nix index c33463e..8f918aa 100644 --- a/machines/storage/s0/default.nix +++ b/machines/storage/s0/default.nix @@ -55,123 +55,134 @@ users.users.googlebot.extraGroups = [ "transmission" ]; users.groups.transmission.gid = config.ids.gids.transmission; - vpn-container.enable = true; - vpn-container.mounts = [ - "/var/lib" - "/data/samba/Public" - ]; - vpn-container.config = { - # servarr services - services.prowlarr.enable = true; - services.sonarr.enable = true; - services.sonarr.user = "public_data"; - services.sonarr.group = "public_data"; - services.bazarr.enable = true; - services.bazarr.user = "public_data"; - services.bazarr.group = "public_data"; - services.radarr.enable = true; - services.radarr.user = "public_data"; - services.radarr.group = "public_data"; - services.lidarr.enable = true; - services.lidarr.user = "public_data"; - services.lidarr.group = "public_data"; - services.recyclarr = { - enable = true; - configuration = { - radarr.radarr_main = { - api_key = { - _secret = "/run/credentials/recyclarr.service/radarr-api-key"; - }; - base_url = "http://localhost:7878"; + pia-vpn = { + enable = true; + serverLocation = "swiss"; - quality_definition.type = "movie"; + containers.transmission = { + ip = "10.100.0.10"; + mounts."/var/lib".hostPath = "/var/lib"; + mounts."/data/samba/Public".hostPath = "/data/samba/Public"; + receiveForwardedPort = { port = 51413; protocol = "both"; }; + onPortForwarded = '' + # Notify Transmission of the PIA-assigned peer port via RPC + for i in $(seq 1 30); do + curlout=$(curl -s "http://$TARGET_IP:9091/transmission/rpc" 2>/dev/null) && break + sleep 2 + done + regex='X-Transmission-Session-Id: (\w*)' + if [[ $curlout =~ $regex ]]; then + sessionId=''${BASH_REMATCH[1]} + curl -s "http://$TARGET_IP:9091/transmission/rpc" \ + -d "{\"method\":\"session-set\",\"arguments\":{\"peer-port\":$PORT}}" \ + -H "X-Transmission-Session-Id: $sessionId" + fi + ''; + config = { + services.transmission = { + enable = true; + package = pkgs.transmission_4; + performanceNetParameters = true; + user = "public_data"; + group = "public_data"; + settings = { + "download-dir" = "/data/samba/Public/Media/Transmission"; + "incomplete-dir" = "/var/lib/transmission/.incomplete"; + "incomplete-dir-enabled" = true; + + "rpc-enabled" = true; + "rpc-bind-address" = "0.0.0.0"; + "rpc-whitelist" = "127.0.0.1,10.100.*.*,192.168.*.*"; + "rpc-host-whitelist-enabled" = false; + + "port-forwarding-enabled" = true; + "peer-port" = 51413; + "peer-port-random-on-start" = false; + + "encryption" = 1; + "lpd-enabled" = true; + "dht-enabled" = true; + "pex-enabled" = true; + + "blocklist-enabled" = true; + "blocklist-updates-enabled" = true; + "blocklist-url" = "https://github.com/Naunter/BT_BlockLists/raw/master/bt_blocklists.gz"; + + "ratio-limit" = 3; + "ratio-limit-enabled" = true; + + "download-queue-enabled" = true; + "download-queue-size" = 20; + }; + }; + # https://github.com/NixOS/nixpkgs/issues/258793 + systemd.services.transmission.serviceConfig = { + RootDirectoryStartOnly = lib.mkForce (lib.mkForce false); + RootDirectory = lib.mkForce (lib.mkForce ""); }; - sonarr.sonarr_main = { - api_key = { - _secret = "/run/credentials/recyclarr.service/sonarr-api-key"; - }; - base_url = "http://localhost:8989"; - quality_definition.type = "series"; + users.groups.public_data.gid = 994; + users.users.public_data = { + isSystemUser = true; + group = "public_data"; + uid = 994; }; }; }; - systemd.services.recyclarr.serviceConfig.LoadCredential = [ - "radarr-api-key:/run/agenix/radarr-api-key" - "sonarr-api-key:/run/agenix/sonarr-api-key" - ]; + containers.servarr = { + ip = "10.100.0.11"; + mounts."/var/lib".hostPath = "/var/lib"; + mounts."/data/samba/Public".hostPath = "/data/samba/Public"; + mounts."/run/agenix" = { hostPath = "/run/agenix"; isReadOnly = true; }; + config = { + services.prowlarr.enable = true; + services.sonarr.enable = true; + services.sonarr.user = "public_data"; + services.sonarr.group = "public_data"; + services.bazarr.enable = true; + services.bazarr.user = "public_data"; + services.bazarr.group = "public_data"; + services.radarr.enable = true; + services.radarr.user = "public_data"; + services.radarr.group = "public_data"; + services.lidarr.enable = true; + services.lidarr.user = "public_data"; + services.lidarr.group = "public_data"; + services.recyclarr = { + enable = true; + configuration = { + radarr.radarr_main = { + api_key = { + _secret = "/run/credentials/recyclarr.service/radarr-api-key"; + }; + base_url = "http://localhost:7878"; + quality_definition.type = "movie"; + }; + sonarr.sonarr_main = { + api_key = { + _secret = "/run/credentials/recyclarr.service/sonarr-api-key"; + }; + base_url = "http://localhost:8989"; + quality_definition.type = "series"; + }; + }; + }; - services.transmission = { - enable = true; - package = pkgs.transmission_4; - performanceNetParameters = true; - user = "public_data"; - group = "public_data"; - settings = { - /* directory settings */ - # "watch-dir" = "/srv/storage/Transmission/To-Download"; - # "watch-dir-enabled" = true; - "download-dir" = "/data/samba/Public/Media/Transmission"; - "incomplete-dir" = "/var/lib/transmission/.incomplete"; - "incomplete-dir-enabled" = true; + systemd.services.recyclarr.serviceConfig.LoadCredential = [ + "radarr-api-key:/run/agenix/radarr-api-key" + "sonarr-api-key:/run/agenix/sonarr-api-key" + ]; - /* web interface, accessible from local network */ - "rpc-enabled" = true; - "rpc-bind-address" = "0.0.0.0"; - "rpc-whitelist" = "127.0.0.1,192.168.*.*,172.16.*.*"; - "rpc-host-whitelist" = "void,192.168.*.*,172.16.*.*"; - "rpc-host-whitelist-enabled" = false; - - "port-forwarding-enabled" = true; - "peer-port" = 50023; - "peer-port-random-on-start" = false; - - "encryption" = 1; - "lpd-enabled" = true; /* local peer discovery */ - "dht-enabled" = true; /* dht peer discovery in swarm */ - "pex-enabled" = true; /* peer exchange */ - - /* ip blocklist */ - "blocklist-enabled" = true; - "blocklist-updates-enabled" = true; - "blocklist-url" = "https://github.com/Naunter/BT_BlockLists/raw/master/bt_blocklists.gz"; - - /* download speed settings */ - # "speed-limit-down" = 1200; - # "speed-limit-down-enabled" = false; - # "speed-limit-up" = 500; - # "speed-limit-up-enabled" = true; - - /* seeding limit */ - "ratio-limit" = 3; - "ratio-limit-enabled" = true; - - "download-queue-enabled" = true; - "download-queue-size" = 20; # gotta go fast + users.groups.public_data.gid = 994; + users.users.public_data = { + isSystemUser = true; + group = "public_data"; + uid = 994; + }; }; }; - # https://github.com/NixOS/nixpkgs/issues/258793 - systemd.services.transmission.serviceConfig = { - RootDirectoryStartOnly = lib.mkForce (lib.mkForce false); - RootDirectory = lib.mkForce (lib.mkForce ""); - }; - - users.groups.public_data.gid = 994; - users.users.public_data = { - isSystemUser = true; - group = "public_data"; - uid = 994; - }; }; - pia.wireguard.badPortForwardPorts = [ - 9696 # prowlarr - 8989 # sonarr - 6767 # bazarr - 7878 # radarr - 8686 # lidarr - 9091 # transmission web - ]; age.secrets.radarr-api-key.file = ../../../secrets/radarr-api-key.age; age.secrets.sonarr-api-key.file = ../../../secrets/sonarr-api-key.age; @@ -215,12 +226,12 @@ }; in lib.mkMerge [ - (mkVirtualHost "bazarr.s0.neet.dev" "http://vpn.containers:6767") - (mkVirtualHost "radarr.s0.neet.dev" "http://vpn.containers:7878") - (mkVirtualHost "lidarr.s0.neet.dev" "http://vpn.containers:8686") - (mkVirtualHost "sonarr.s0.neet.dev" "http://vpn.containers:8989") - (mkVirtualHost "prowlarr.s0.neet.dev" "http://vpn.containers:9696") - (mkVirtualHost "transmission.s0.neet.dev" "http://vpn.containers:9091") + (mkVirtualHost "bazarr.s0.neet.dev" "http://servarr.containers:6767") + (mkVirtualHost "radarr.s0.neet.dev" "http://servarr.containers:7878") + (mkVirtualHost "lidarr.s0.neet.dev" "http://servarr.containers:8686") + (mkVirtualHost "sonarr.s0.neet.dev" "http://servarr.containers:8989") + (mkVirtualHost "prowlarr.s0.neet.dev" "http://servarr.containers:9696") + (mkVirtualHost "transmission.s0.neet.dev" "http://transmission.containers:9091") (mkVirtualHost "unifi.s0.neet.dev" "https://localhost:8443") (mkVirtualHost "music.s0.neet.dev" "http://localhost:4533") (mkVirtualHost "jellyfin.s0.neet.dev" "http://localhost:8096")