diff options
| author | Tim Keller <tjk@tjkeller.xyz> | 2026-10-30 19:16:25 -0500 |
|---|---|---|
| committer | Tim Keller <tjk@tjkeller.xyz> | 2026-10-30 19:16:25 -0500 |
| commit | 4c2d824402c89639b6347fd7904fb296b060890e (patch) | |
| tree | c12be90c4551fe46a0ae9a2f5f925b600e48acaa /hosts/poweredge/vpn-router.nix | |
| parent | 23a01a90e6c6bc00bd4832a3959f6d8b5dc9ce11 (diff) | |
| download | nixos-4c2d824402c89639b6347fd7904fb296b060890e.tar.xz nixos-4c2d824402c89639b6347fd7904fb296b060890e.zip | |
separate openvpn from transmission to its own vpn-router container
Diffstat (limited to 'hosts/poweredge/vpn-router.nix')
| -rw-r--r-- | hosts/poweredge/vpn-router.nix | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/hosts/poweredge/vpn-router.nix b/hosts/poweredge/vpn-router.nix new file mode 100644 index 0000000..ac7ce16 --- /dev/null +++ b/hosts/poweredge/vpn-router.nix @@ -0,0 +1,121 @@ +{ lib, config, pkgs, ... }: { + # Secrets + sops.secrets.vpn-router-ovpn-config = { sopsFile = ./resources/secrets/vpn-router.yaml; key = "ovpn-config"; }; + sops.secrets.vpn-router-ovpn-auth = { sopsFile = ./resources/secrets/vpn-router.yaml; key = "ovpn-auth"; }; + + # Container + containers.vpn-router = { + autoStart = true; + privateNetwork = true; + enableTun = true; # OpenVPN requires + hostBridge = "br-lan0"; + localMacAddress = "02:00:00:00:01:09"; + + # Bind secrets + bindMounts = { + "/run/secrets/ovpn-config.ovpn" = { + hostPath = config.sops.secrets.vpn-router-ovpn-config.path; + isReadOnly = true; + }; + "/run/secrets/ovpn-auth" = { + hostPath = config.sops.secrets.vpn-router-ovpn-auth.path; + isReadOnly = true; + }; + }; + + config = { lib, pkgs, ... }: { + + # Networking + networking = { + enableIPv6 = false; # Prevent ip leaks + interfaces.eth0.useDHCP = true; + }; + + # Router config + # This container acts as a router that clients can connect to via default + # route. All traffic is forwarded over the VPN tunnel instead of WAN. + # This allows multiple containers to share the same VPN connection. + boot.kernel.sysctl."net.ipv4.ip_forward" = 1; # Enable IPv4 forwarding + networking.nat = { + enable = true; + externalInterface = "tun0"; + internalInterfaces = [ "eth0" ]; + # Forward ports over VPN + forwardPorts = [ + { sourcePort = 51413; proto = "tcp"; destination = "192.168.1.47:51413"; } # Transmission TCP + { sourcePort = 51413; proto = "udp"; destination = "192.168.1.47:51413"; } # Transmission UDP + { sourcePort = 50300; proto = "tcp"; destination = "192.168.1.51:50300"; } # Slskd TCP + { sourcePort = 50300; proto = "udp"; destination = "192.168.1.51:50300"; } # Slskd UDP + ]; + }; + + # OpenVPN + services.openvpn.servers.main = { + config = '' + config /run/secrets/ovpn-config.ovpn + auth-user-pass /run/secrets/ovpn-auth + + # The server pushes tun-mtu 1500. A full 1500 byte packet plus + # AES-256-GCM/UDP/IP encapsulation (~52 bytes) is ~1552 on the wire, + # so it has to be IP fragmented to cross a 1500 byte path -- and the + # fragments get dropped upstream, blackholing every full size packet + # while small ones (dns, tcp handshakes) still pass. Clamp the TCP MSS + # so the encapsulated datagram never exceeds this, in both directions. + # Must come after the `config` include above so it wins. + #mssfix 1400 + ''; + autoStart = true; + updateResolvConf = true; + }; + + # VPN "killswitch", split across two chains: + networking.firewall.extraCommands = '' + # Get domain name host and port from ovpn config + SERVER_HOST=$(${pkgs.gawk}/bin/awk '/^remote /{print $2;exit}' /run/secrets/ovpn-config.ovpn) + SERVER_PORT=$(${pkgs.gawk}/bin/awk '/^remote /{print $3;exit}' /run/secrets/ovpn-config.ovpn) + [ -n "$SERVER_PORT" ] || SERVER_PORT=1194 + + # Router's own traffic: only lo, tun0, and established connections + ${pkgs.iptables}/bin/iptables -P OUTPUT DROP + ${pkgs.iptables}/bin/iptables -A OUTPUT -o lo -j ACCEPT + ${pkgs.iptables}/bin/iptables -A OUTPUT -o tun0 -j ACCEPT + ${pkgs.iptables}/bin/iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + + # Get dns ip from resolv.conf + DNS_IP=$(${pkgs.gawk}/bin/awk '/^nameserver /{ if ($2 !~ /:/) { print $2; exit } }' /etc/resolv.conf) + + # Allow dns + ${pkgs.iptables}/bin/iptables -A OUTPUT -o eth0 -p udp -d "$DNS_IP" --dport 53 -j ACCEPT + ${pkgs.iptables}/bin/iptables -A OUTPUT -o eth0 -p tcp -d "$DNS_IP" --dport 53 -j ACCEPT + + # Resolve server ips from host + SERVER_IPS="" + ATTEMPT=0 + while [ -z "$SERVER_IPS" ] && [ "$ATTEMPT" -lt 10 ]; do + ATTEMPT=$((ATTEMPT + 1)) + SERVER_IPS=$(${pkgs.getent}/bin/getent ahostsv4 "$SERVER_HOST" 2>/dev/null | ${pkgs.gawk}/bin/awk '/STREAM/{print $1}') + [ -n "$SERVER_IPS" ] || sleep 3 + done + + # Die if no ips are resolved + if [ -z "$SERVER_IPS" ]; then + echo "killswitch: could not resolve '$SERVER_HOST' after $ATTEMPT attempts, failing closed with no vpn egress" + exit 1 + fi + + # Allow udp traffic to each vpn server ip (for openvpn udp tunnels only, strictly better than tcp) + for SERVER_IP in $SERVER_IPS; do + echo "killswitch: allowing $SERVER_IP:$SERVER_PORT/udp ($SERVER_HOST)" + ${pkgs.iptables}/bin/iptables -A OUTPUT -p udp -d "$SERVER_IP" --dport "$SERVER_PORT" -j ACCEPT + done + + # Anything forwarded through this router: only allowed to reach tun0 + ${pkgs.iptables}/bin/iptables -P FORWARD DROP + ${pkgs.iptables}/bin/iptables -A FORWARD -m state --state ESTABLISHED,RELATED -j ACCEPT + ${pkgs.iptables}/bin/iptables -A FORWARD -i eth0 -o tun0 -j ACCEPT + ''; + + system.stateVersion = "26.05"; + }; + }; +} |
