All checks were successful
Check Flake / check-flake (push) Successful in 3m18s
Every PIA API response includes a trailing RSA-SHA256 signature (line 1 = JSON, lines 3+ = base64-encoded signature) which was previously ignored entirely. Add verifyPIAResponse() that checks each response against PIA's public signing key before trusting the data. On verification failure the service aborts and systemd restarts it. Also bump RestartSec to 5m to avoid hammering PIA servers on repeated failures.
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-resolvedlistening on its bridge IP, forwarding queries through the WG tunnel. - Monthly renewal:
pia-vpn-setupusesType=simple+Restart=always+RuntimeMaxSec=30dto 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
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
# 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