vpn.nix: factor into a proper module
this will allow for better integration with 'sane.programs'
This commit is contained in:
parent
bdab1aa7e3
commit
ce35330923
|
@ -1,155 +1,22 @@
|
|||
{ config, lib, pkgs, ... }:
|
||||
|
||||
# to add a new OVPN VPN:
|
||||
# - generate a privkey `wg genkey`
|
||||
# - add this key to `sops secrets/universal.yaml`
|
||||
# - upload pubkey to OVPN.com (`cat wg.priv | wg pubkey`)
|
||||
# - generate config @ OVPN.com
|
||||
# - copy the Address, PublicKey, Endpoint from OVPN's config
|
||||
#
|
||||
# debugging:
|
||||
# - `journalctl -u systemd-networkd`
|
||||
#
|
||||
# docs:
|
||||
# - wireguard (nixos): <https://nixos.wiki/wiki/WireGuard#Setting_up_WireGuard_with_systemd-networkd>
|
||||
# - wireguard (arch): <https://wiki.archlinux.org/title/WireGuard>
|
||||
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
def-wg-vpn = name: { endpoint, publicKey, addrV4, bridgeAddrV4, dns, privateKeyFile, id }: {
|
||||
# id is a number < 2^15 which has to be unique in iproute and as a fwmark.
|
||||
# my own convention is > 10000
|
||||
systemd.network.netdevs."98-${name}" = {
|
||||
# see: `man 5 systemd.netdev`
|
||||
netdevConfig = {
|
||||
Kind = "wireguard";
|
||||
Name = name;
|
||||
};
|
||||
wireguardConfig = {
|
||||
PrivateKeyFile = privateKeyFile;
|
||||
FirewallMark = id;
|
||||
};
|
||||
wireguardPeers = [{
|
||||
wireguardPeerConfig = {
|
||||
AllowedIPs = [
|
||||
"0.0.0.0/0"
|
||||
"::/0"
|
||||
];
|
||||
Endpoint = endpoint;
|
||||
PublicKey = publicKey;
|
||||
};
|
||||
}];
|
||||
};
|
||||
systemd.network.networks."50-${name}" = {
|
||||
# see: `man 5 systemd.network`
|
||||
matchConfig.Name = name;
|
||||
networkConfig.Address = [ addrV4 ];
|
||||
networkConfig.DNS = dns;
|
||||
# TODO: `sane-vpn up <vpn>` should configure DNS to be sent over the VPN
|
||||
# DNSDefaultRoute: system DNS queries are sent to this link's DNS server
|
||||
# networkConfig.DNSDefaultRoute = true;
|
||||
# Domains = ~.: system DNS queries are sent to this link's DNS server
|
||||
# networkConfig.Domains = "~.";
|
||||
routingPolicyRules = [
|
||||
{
|
||||
routingPolicyRuleConfig = {
|
||||
# send traffic from the the container bridge out over the VPN.
|
||||
# the bridge itself does source nat (SNAT) to rewrite the packet source address to that of the VPNs
|
||||
# -- but that happens in POSTROUTING: *after* the kernel decides which interface to give the packet to next.
|
||||
# therefore, we have to route here based on the packet's address as it is in PREROUTING, i.e. the bridge address. weird!
|
||||
Priority = id;
|
||||
From = bridgeAddrV4;
|
||||
Table = id;
|
||||
};
|
||||
}
|
||||
def-ovpn = name: { endpoint, publicKey, addrV4, id }: {
|
||||
sane.vpn."ovpnd-${name}" = {
|
||||
inherit endpoint publicKey addrV4 id;
|
||||
privateKeyFile = config.sops.secrets."wg/ovpnd_${name}_privkey".path;
|
||||
dns = [
|
||||
"46.227.67.134"
|
||||
"192.165.9.158"
|
||||
];
|
||||
routes = [{
|
||||
routeConfig.Table = id;
|
||||
routeConfig.Scope = "link";
|
||||
routeConfig.Destination = "0.0.0.0/0";
|
||||
routeConfig.Source = addrV4;
|
||||
}];
|
||||
# RequiredForOnline => should `systemd-networkd-wait-online` fail if this network can't come up?
|
||||
linkConfig.RequiredForOnline = false;
|
||||
};
|
||||
|
||||
systemd.network.netdevs."99-br-${name}" = {
|
||||
netdevConfig.Kind = "bridge";
|
||||
netdevConfig.Name = "br-${name}";
|
||||
};
|
||||
systemd.network.networks."51-br-${name}" = {
|
||||
matchConfig.Name = "br-${name}";
|
||||
networkConfig.Description = "NATs inbound traffic to ${name}, intended for container isolation";
|
||||
networkConfig.Address = [ bridgeAddrV4 ];
|
||||
networkConfig.DNS = dns;
|
||||
# ConfigureWithoutCarrier: a bridge with no attached interface has no carrier (this is to be expected).
|
||||
# systemd ordinarily waits for a carrier to be present before "configuring" the bridge.
|
||||
# i tell it to not do that, so that i can assign an IP address to the bridge before i connect it to anything.
|
||||
# alternative may be to issue the bridge a static MACAddress?
|
||||
# see: <https://github.com/systemd/systemd/issues/9252#issuecomment-808710185>
|
||||
networkConfig.ConfigureWithoutCarrier = true;
|
||||
# networkConfig.DHCPServer = true;
|
||||
linkConfig.RequiredForOnline = false;
|
||||
};
|
||||
networking.localCommands = with pkgs; ''
|
||||
# view rules with:
|
||||
# - `sudo iptables -t nat --list-rules -v`
|
||||
# rewrite the source address of every packet leaving the container so that it's routable by the wg tunnel.
|
||||
# note that this alone doesn't get it routed *to* the wg device. we can't, since SNAT is POSTROUTING (not PREROUTING).
|
||||
# that part's done by an `ip rule` entry.
|
||||
${iptables}/bin/iptables -A POSTROUTING -t nat -s ${bridgeAddrV4} -j SNAT --to-source ${addrV4}
|
||||
'';
|
||||
|
||||
# linux will drop inbound packets if it thinks a reply to that packet wouldn't exit via the same interface (rpfilter).
|
||||
# wg-quick has a solution via `iptables -j CONNMARK`, and that does work for system-wide VPNs,
|
||||
# but i couldn't get that to work for firejail/netns with SNAT, so set rpfilter to "loose".
|
||||
networking.firewall.checkReversePath = "loose";
|
||||
|
||||
# networking.firewall.extraCommands = with pkgs; ''
|
||||
# # wireguard packet marking. without this, rpfilter drops responses from a wireguard VPN
|
||||
# # because the "reverse path check" fails (i.e. it thinks a response to the packet would go out via a different interface than what the wireguard packet arrived at).
|
||||
# # debug with e.g. `iptables --list -v -n -t mangle`
|
||||
# # - and `networking.firewall.logReversePathDrops = true;`, `networking.firewall.logRefusedPackets = true;`
|
||||
# # - and `journalctl -k` to see dropped packets
|
||||
# #
|
||||
# # note that wg-quick also adds a rule to reject non-local traffic from all interfaces EXCEPT the tunnel.
|
||||
# # that may protect against actors trying to probe us: actors we connect to via wireguard who send their response packets (speculatively) to our plaintext IP to see if we accept them.
|
||||
# # but that's fairly low concern, and firewalling by the gateway/NAT helps protect against that already.
|
||||
# ${iptables}/bin/iptables -t mangle -I PREROUTING 1 -i ${name} -m mark --mark 0 -j CONNMARK --restore-mark
|
||||
# ${iptables}/bin/iptables -t mangle -A POSTROUTING -o ${name} -m mark --mark ${builtins.toString id} -j CONNMARK --save-mark
|
||||
# '';
|
||||
|
||||
systemd.services."vpn-${name}" = {
|
||||
path = with pkgs; [ iproute2 ];
|
||||
serviceConfig = let
|
||||
prioMain = id - 200;
|
||||
prioFwMark = id - 100;
|
||||
mkScript = verb: pkgs.writeShellScript "vpn-${name}-${verb}" ''
|
||||
# first, allow all non-default routes (prefix-length != 0) a chance to route the packet.
|
||||
# - this allows the wireguard tunnel itself to pass traffic via our LAN gateway.
|
||||
# - incidentally, it allows traffic to LAN devices and other machine-local or virtual networks.
|
||||
ip rule ${verb} from all lookup main suppress_prefixlength 0 priority ${builtins.toString prioMain}
|
||||
# if packet hasn't gone through the wg device yet (fwmark), then move it to the table which will cause it to.
|
||||
ip rule ${verb} not from all fwmark ${builtins.toString id} lookup ${builtins.toString id} priority ${builtins.toString prioFwMark}
|
||||
'';
|
||||
in {
|
||||
ExecStart = ''${mkScript "add"}'';
|
||||
ExecStop = ''${mkScript "del"}'';
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Restart = "on-failure";
|
||||
# it'd be nice if this could depend on the network device we just declared, but there seems no way to do that except via 3rd-party tooling.
|
||||
};
|
||||
};
|
||||
};
|
||||
def-ovpn = name: { endpoint, publicKey, addrV4, id }: (def-wg-vpn "ovpnd-${name}" {
|
||||
inherit endpoint publicKey addrV4;
|
||||
id = 10000 + id;
|
||||
privateKeyFile = config.sops.secrets."wg/ovpnd_${name}_privkey".path;
|
||||
bridgeAddrV4 = "10.20.${builtins.toString id}.1/24";
|
||||
dns = [
|
||||
"46.227.67.134"
|
||||
"192.165.9.158"
|
||||
];
|
||||
}) // {
|
||||
sops.secrets."wg/ovpnd_${name}_privkey" = {
|
||||
# needs to be readable by systemd-network or else it says "Ignoring network device" and doesn't expose it to networkctl.
|
||||
owner = "systemd-network";
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
./sops.nix
|
||||
./ssh.nix
|
||||
./users.nix
|
||||
./vpn.nix
|
||||
./wowlan.nix
|
||||
];
|
||||
|
||||
|
|
207
modules/vpn.nix
Normal file
207
modules/vpn.nix
Normal file
|
@ -0,0 +1,207 @@
|
|||
# debugging:
|
||||
# - `journalctl -u systemd-networkd`
|
||||
#
|
||||
# docs:
|
||||
# - wireguard (nixos): <https://nixos.wiki/wiki/WireGuard#Setting_up_WireGuard_with_systemd-networkd>
|
||||
# - wireguard (arch): <https://wiki.archlinux.org/title/WireGuard>
|
||||
|
||||
{ config, lib, pkgs, sane-lib, ... }:
|
||||
let
|
||||
cfg = config.sane.vpn;
|
||||
vpnOpts = with lib; types.submodule {
|
||||
options = {
|
||||
id = mkOption {
|
||||
type = types.int;
|
||||
description = ''
|
||||
unique integer identifier for this VPN.
|
||||
lower number = higher priority, in many senses.
|
||||
lowest number = default VPN to use when no other is specified, or when multiple are enabled in the same circumstance.
|
||||
|
||||
safe values for this number: 1 < id < 100.
|
||||
'';
|
||||
};
|
||||
endpoint = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
host:port which hosts the other end of the VPN.
|
||||
e.g. "vpn.example.com:55280"
|
||||
'';
|
||||
};
|
||||
publicKey = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
pubkey of the remote peer.
|
||||
'';
|
||||
};
|
||||
addrV4 = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
IP address of my end of the VPN.
|
||||
e.g. "172.27.12.34"
|
||||
'';
|
||||
};
|
||||
dns = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [
|
||||
"46.227.67.134"
|
||||
"192.165.9.158"
|
||||
];
|
||||
description = ''
|
||||
dns servers to use for traffic associated with this VPN.
|
||||
'';
|
||||
};
|
||||
privateKeyFile = mkOption {
|
||||
type = types.either types.str types.path;
|
||||
description = ''
|
||||
path to the private key for my end of the VPN.
|
||||
e.g. "/run/secrets/wg-home.priv"
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
mkVpnConfig = name: { id, dns, endpoint, publicKey, addrV4, privateKeyFile }: let
|
||||
fwmark = id + 10000;
|
||||
bridgeAddrV4 = "10.20.${builtins.toString id}.1/24";
|
||||
in {
|
||||
systemd.network.netdevs."98-${name}" = {
|
||||
# see: `man 5 systemd.netdev`
|
||||
netdevConfig = {
|
||||
Kind = "wireguard";
|
||||
Name = name;
|
||||
};
|
||||
wireguardConfig = {
|
||||
PrivateKeyFile = privateKeyFile;
|
||||
FirewallMark = id;
|
||||
};
|
||||
wireguardPeers = [{
|
||||
wireguardPeerConfig = {
|
||||
AllowedIPs = [
|
||||
"0.0.0.0/0"
|
||||
"::/0"
|
||||
];
|
||||
Endpoint = endpoint;
|
||||
PublicKey = publicKey;
|
||||
};
|
||||
}];
|
||||
};
|
||||
systemd.network.networks."50-${name}" = {
|
||||
# see: `man 5 systemd.network`
|
||||
matchConfig.Name = name;
|
||||
networkConfig.Address = [ addrV4 ];
|
||||
networkConfig.DNS = dns;
|
||||
# TODO: `sane-vpn up <vpn>` should configure DNS to be sent over the VPN
|
||||
# DNSDefaultRoute: system DNS queries are sent to this link's DNS server
|
||||
# networkConfig.DNSDefaultRoute = true;
|
||||
# Domains = ~.: system DNS queries are sent to this link's DNS server
|
||||
# networkConfig.Domains = "~.";
|
||||
routingPolicyRules = [
|
||||
{
|
||||
routingPolicyRuleConfig = {
|
||||
# send traffic from the the container bridge out over the VPN.
|
||||
# the bridge itself does source nat (SNAT) to rewrite the packet source address to that of the VPNs
|
||||
# -- but that happens in POSTROUTING: *after* the kernel decides which interface to give the packet to next.
|
||||
# therefore, we have to route here based on the packet's address as it is in PREROUTING, i.e. the bridge address. weird!
|
||||
Priority = id;
|
||||
From = bridgeAddrV4;
|
||||
Table = id;
|
||||
};
|
||||
}
|
||||
];
|
||||
routes = [{
|
||||
routeConfig.Table = id;
|
||||
routeConfig.Scope = "link";
|
||||
routeConfig.Destination = "0.0.0.0/0";
|
||||
routeConfig.Source = addrV4;
|
||||
}];
|
||||
# RequiredForOnline => should `systemd-networkd-wait-online` fail if this network can't come up?
|
||||
linkConfig.RequiredForOnline = false;
|
||||
};
|
||||
|
||||
systemd.network.netdevs."99-br-${name}" = {
|
||||
netdevConfig.Kind = "bridge";
|
||||
netdevConfig.Name = "br-${name}";
|
||||
};
|
||||
systemd.network.networks."51-br-${name}" = {
|
||||
matchConfig.Name = "br-${name}";
|
||||
networkConfig.Description = "NATs inbound traffic to ${name}, intended for container isolation";
|
||||
networkConfig.Address = [ bridgeAddrV4 ];
|
||||
networkConfig.DNS = dns;
|
||||
# ConfigureWithoutCarrier: a bridge with no attached interface has no carrier (this is to be expected).
|
||||
# systemd ordinarily waits for a carrier to be present before "configuring" the bridge.
|
||||
# i tell it to not do that, so that i can assign an IP address to the bridge before i connect it to anything.
|
||||
# alternative may be to issue the bridge a static MACAddress?
|
||||
# see: <https://github.com/systemd/systemd/issues/9252#issuecomment-808710185>
|
||||
networkConfig.ConfigureWithoutCarrier = true;
|
||||
# networkConfig.DHCPServer = true;
|
||||
linkConfig.RequiredForOnline = false;
|
||||
};
|
||||
networking.localCommands = with pkgs; ''
|
||||
# view rules with:
|
||||
# - `sudo iptables -t nat --list-rules -v`
|
||||
# rewrite the source address of every packet leaving the container so that it's routable by the wg tunnel.
|
||||
# note that this alone doesn't get it routed *to* the wg device. we can't, since SNAT is POSTROUTING (not PREROUTING).
|
||||
# that part's done by an `ip rule` entry.
|
||||
${iptables}/bin/iptables -A POSTROUTING -t nat -s ${bridgeAddrV4} -j SNAT --to-source ${addrV4}
|
||||
'';
|
||||
|
||||
# linux will drop inbound packets if it thinks a reply to that packet wouldn't exit via the same interface (rpfilter).
|
||||
# wg-quick has a solution via `iptables -j CONNMARK`, and that does work for system-wide VPNs,
|
||||
# but i couldn't get that to work for firejail/netns with SNAT, so set rpfilter to "loose".
|
||||
networking.firewall.checkReversePath = "loose";
|
||||
|
||||
# networking.firewall.extraCommands = with pkgs; ''
|
||||
# # wireguard packet marking. without this, rpfilter drops responses from a wireguard VPN
|
||||
# # because the "reverse path check" fails (i.e. it thinks a response to the packet would go out via a different interface than what the wireguard packet arrived at).
|
||||
# # debug with e.g. `iptables --list -v -n -t mangle`
|
||||
# # - and `networking.firewall.logReversePathDrops = true;`, `networking.firewall.logRefusedPackets = true;`
|
||||
# # - and `journalctl -k` to see dropped packets
|
||||
# #
|
||||
# # note that wg-quick also adds a rule to reject non-local traffic from all interfaces EXCEPT the tunnel.
|
||||
# # that may protect against actors trying to probe us: actors we connect to via wireguard who send their response packets (speculatively) to our plaintext IP to see if we accept them.
|
||||
# # but that's fairly low concern, and firewalling by the gateway/NAT helps protect against that already.
|
||||
# ${iptables}/bin/iptables -t mangle -I PREROUTING 1 -i ${name} -m mark --mark 0 -j CONNMARK --restore-mark
|
||||
# ${iptables}/bin/iptables -t mangle -A POSTROUTING -o ${name} -m mark --mark ${builtins.toString id} -j CONNMARK --save-mark
|
||||
# '';
|
||||
|
||||
systemd.services."vpn-${name}" = {
|
||||
path = with pkgs; [ iproute2 ];
|
||||
serviceConfig = let
|
||||
prioMain = id - 200;
|
||||
prioFwMark = id - 100;
|
||||
mkScript = verb: pkgs.writeShellScript "vpn-${name}-${verb}" ''
|
||||
# first, allow all non-default routes (prefix-length != 0) a chance to route the packet.
|
||||
# - this allows the wireguard tunnel itself to pass traffic via our LAN gateway.
|
||||
# - incidentally, it allows traffic to LAN devices and other machine-local or virtual networks.
|
||||
ip rule ${verb} from all lookup main suppress_prefixlength 0 priority ${builtins.toString prioMain}
|
||||
# if packet hasn't gone through the wg device yet (fwmark), then move it to the table which will cause it to.
|
||||
ip rule ${verb} not from all fwmark ${builtins.toString id} lookup ${builtins.toString id} priority ${builtins.toString prioFwMark}
|
||||
'';
|
||||
in {
|
||||
ExecStart = ''${mkScript "add"}'';
|
||||
ExecStop = ''${mkScript "del"}'';
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Restart = "on-failure";
|
||||
# it'd be nice if this could depend on the network device we just declared, but there seems no way to do that except via 3rd-party tooling.
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
options = with lib; {
|
||||
sane.vpn = mkOption {
|
||||
type = types.attrsOf vpnOpts;
|
||||
default = {};
|
||||
};
|
||||
};
|
||||
|
||||
config = let
|
||||
configs = lib.mapAttrsToList mkVpnConfig cfg;
|
||||
take = f: {
|
||||
networking.firewall.checkReversePath = f.networking.firewall.checkReversePath;
|
||||
networking.localCommands = f.networking.localCommands;
|
||||
systemd.network = f.systemd.network;
|
||||
systemd.services = f.systemd.services;
|
||||
};
|
||||
in take (sane-lib.mkTypedMerge take configs);
|
||||
}
|
Loading…
Reference in New Issue
Block a user