Files
nix-config/common/network/pia-vpn/vpn-container.nix

239 lines
9.1 KiB
Nix

{ 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 {
# Give the container more time to boot (pia-vpn-setup retries can delay readiness)
systemd.services."container@pia-vpn".serviceConfig.TimeoutStartSec = mkForce "180s";
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;
networking.hosts = cfg.containerHosts;
# 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;
};
};
# Ignore WG interface for wait-online (it's configured manually, not by networkd)
systemd.network.wait-online.ignoredInterfaces = [ cfg.interfaceName ];
# 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";
RestartSec = "10s";
RuntimeMaxSec = "30d";
};
script = ''
set -euo pipefail
${scripts.scriptCommon}
# Clean up stale state from previous attempts
wg set ${cfg.interfaceName} listen-port 0 2>/dev/null || true
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 2>/dev/null || true
iptables -F FORWARD 2>/dev/null || true
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 replace 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";
};
};
};
};
};
}