All checks were successful
Check Flake / check-flake (push) Successful in 3m21s
280 lines
8.6 KiB
Nix
280 lines
8.6 KiB
Nix
{ 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.nullOr types.port;
|
|
default = null;
|
|
description = ''
|
|
Target port to forward to. If null, forwards to the same PIA-assigned port.
|
|
PIA-assigned ports below 10000 are rejected to avoid accidentally
|
|
forwarding traffic to other services.
|
|
'';
|
|
};
|
|
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;
|
|
};
|
|
|
|
# Shared host entries for all containers (host + VPN + service containers)
|
|
containerHosts = mkOption {
|
|
type = types.attrsOf (types.listOf types.str);
|
|
internal = true;
|
|
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;
|
|
|
|
# TODO: re-enable once primary networking uses networkd
|
|
systemd.network.wait-online.enable = false;
|
|
|
|
# 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.
|
|
pia-vpn.containerHosts =
|
|
{ ${cfg.vpnAddress} = [ "pia-vpn.containers" ]; }
|
|
// mapAttrs' (name: ctr: nameValuePair ctr.ip [ "${name}.containers" ]) cfg.containers;
|
|
|
|
networking.hosts = cfg.containerHosts;
|
|
|
|
# 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;
|
|
};
|
|
}
|