diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index e5f9c673c18..e331312bdb7 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -862,6 +862,7 @@ ./services/networking/ndppd.nix ./services/networking/nebula.nix ./services/networking/networkmanager.nix + ./services/networking/netns.nix ./services/networking/nextdns.nix ./services/networking/nftables.nix ./services/networking/ngircd.nix diff --git a/nixos/modules/services/networking/netns.nix b/nixos/modules/services/networking/netns.nix new file mode 100644 index 00000000000..2e62738089d --- /dev/null +++ b/nixos/modules/services/networking/netns.nix @@ -0,0 +1,106 @@ +{ pkgs, lib, config, options, ... }: +with lib; +let + cfg = config.services.netns; + inherit (config) networking; + # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html + escapeUnitName = name: + lib.concatMapStrings (s: if lib.isList s then "-" else s) + (builtins.split "[^a-zA-Z0-9_.\\-]+" name); +in +{ +options.services.netns = { + namespaces = mkOption { + description = '' + Network namespaces to create. + + Other services can join a network namespace named netns with: + + PrivateNetwork=true; + JoinsNamespaceOf="netns-''${netns}.service"; + + + So can iproute with: + ip -n ''${netns} + + + You should usually create (or update via your VPN configuration's up script) + a file named /etc/netns/''${netns}/resolv.conf + that will be bind-mounted by ip -n ''${netns} onto /etc/resolv.conf, + which you'll also want to configure in the services joining this network namespace: + + BindReadOnlyPaths = ["/etc/netns/''${netns}/resolv.conf:/etc/resolv.conf"]; + + + ''; + default = {}; + type = types.attrsOf (types.submodule { + options.nftables = mkOption { + description = "Nftables ruleset within the network namespace."; + type = types.lines; + default = networking.nftables.ruleset; + defaultText = "config.networking.nftables.ruleset"; + }; + options.sysctl = options.boot.kernel.sysctl // { + description = "sysctl within the network namespace."; + default = config.boot.kernel.sysctl; + defaultText = "config.boot.kernel.sysctl"; + }; + options.service = mkOption { + description = "Systemd configuration specific to this netns service"; + type = types.attrs; + default = {}; + }; + }); + }; +}; +config = { + systemd.services = mapAttrs' (name: c: + nameValuePair "netns-${escapeUnitName name}" (mkMerge [ + { description = "${name} network namespace"; + before = [ "network.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + # Let systemd create the netns so that PrivateNetwork=true + # with JoinsNamespaceOf="netns-${name}.service" works. + PrivateNetwork = true; + ExecStart = [ + # For convenience, register the netns to the tracking mecanism of iproute, + # and make sure resolv.conf can be used in BindReadOnlyPaths= + # For propagating changes in that file to the services bind mounting it, + # updating must not remove the file, but only truncate it. + (pkgs.writeShellScript "ip-netns-attach" '' + ${pkgs.iproute}/bin/ip netns attach ${escapeShellArg name} $$ + mkdir -p /etc/netns/${escapeShellArg name} + touch /etc/netns/${escapeShellArg name}/resolv.conf + '') + + # Bringing the loopback interface is almost always a good thing. + "${pkgs.iproute}/bin/ip link set dev lo up" + + # Use --ignore because some keys may no longer exist in that new namespace, + # like net.ipv6.conf.eth0.addr_gen_mode or net.core.rmem_max + ''${pkgs.procps}/bin/sysctl --ignore -p ${pkgs.writeScript "sysctl" + (concatStrings (mapAttrsToList (n: v: + optionalString (v != null) + "${n}=${if v == false then "0" else toString v}\n" + ) c.sysctl))} + '' + ] ++ + # Load the nftables ruleset of this netns. + optional networking.nftables.enable (pkgs.writeScript "nftables-ruleset" '' + #!${pkgs.nftables}/bin/nft -f + flush ruleset + ${c.nftables} + ''); + # Unregister the netns from the tracking mecanism of iproute. + ExecStop = "${pkgs.iproute}/bin/ip netns delete ${escapeShellArg name}"; + }; + } + c.service + ] + )) cfg.namespaces; + meta.maintainers = with lib.maintainers; [ julm ]; +}; +} diff --git a/nixos/modules/services/networking/openvpn.nix b/nixos/modules/services/networking/openvpn.nix index 492a0936fdb..adb1c8b5f0d 100644 --- a/nixos/modules/services/networking/openvpn.nix +++ b/nixos/modules/services/networking/openvpn.nix @@ -5,71 +5,216 @@ with lib; let cfg = config.services.openvpn; + enabledServers = filterAttrs (name: srv: srv.enable) cfg.servers; inherit (pkgs) openvpn; + PATH = name: makeBinPath config.systemd.services."openvpn-${name}".path; + makeOpenVPNJob = cfg: name: let - path = makeBinPath (getAttr "openvpn-${name}" config.systemd.services).path; + configFile = pkgs.writeText "openvpn-config-${name}" ( + generators.toKeyValue { + mkKeyValue = key: value: + if hasAttr key scripts + then "${key} " + pkgs.writeShellScript "openvpn-${name}-${key}" (scripts.${key} value) + else if builtins.isBool value + then optionalString value key + else if builtins.isPath value + then "${key} ${value}" + else if builtins.isList value + then concatMapStringsSep "\n" (v: "${key} ${generators.mkValueStringDefault {} v}") value + else "${key} ${generators.mkValueStringDefault {} value}"; + } cfg.settings + ); - upScript = '' - #! /bin/sh - export PATH=${path} + scripts = { + up = script: + let init = '' + export PATH=${PATH name} - # For convenience in client scripts, extract the remote domain - # name and name server. - for var in ''${!foreign_option_*}; do - x=(''${!var}) - if [ "''${x[0]}" = dhcp-option ]; then - if [ "''${x[1]}" = DOMAIN ]; then domain="''${x[2]}" - elif [ "''${x[1]}" = DNS ]; then nameserver="''${x[2]}" - fi - fi - done + # For convenience in client scripts, extract the remote domain + # name and name server. + for var in ''${!foreign_option_*}; do + x=(''${!var}) + if [ "''${x[0]}" = dhcp-option ]; then + if [ "''${x[1]}" = DOMAIN ]; then domain="''${x[2]}" + elif [ "''${x[1]}" = DNS ]; then nameserver="''${x[2]}" + fi + fi + done - ${cfg.up} - ${optionalString cfg.updateResolvConf - "${pkgs.update-resolv-conf}/libexec/openvpn/update-resolv-conf"} - ''; + ${optionalString cfg.updateResolvConf + "${pkgs.update-resolv-conf}/libexec/openvpn/update-resolv-conf"} + ''; + # Add DNS settings given in foreign DHCP options to the resolv.conf of the netns. + # Note that JoinsNamespaceOf="netns-${cfg.netns}.service" will not + # BindReadOnlyPaths=["/etc/netns/${cfg.netns}/resolv.conf:/etc/resolv.conf"]; + # this will have to be added in each service joining the namespace. + setNetNSResolvConf = '' + mkdir -p /etc/netns/${cfg.netns} + # This file is normally created by netns-${cfg.netns}.service, + # care must be taken to not delete it but to truncate it + # in order to propagate the changes to bind-mounted versions. + : > /etc/netns/${cfg.netns}/resolv.conf + chmod 644 /etc/netns/${cfg.netns}/resolv.conf + foreign_opt_domains= + process_foreign_option () { + case "$1:$2" in + dhcp-option:DNS) echo "nameserver $3" >>/etc/netns/'${cfg.netns}'/resolv.conf ;; + dhcp-option:DOMAIN) foreign_opt_domains="$foreign_opt_domains $3" ;; + esac + } + i=1 + while + eval opt=\"\''${foreign_option_$i-}\" + [ -n "$opt" ] + do + process_foreign_option $opt + i=$(( i + 1 )) + done + for d in $foreign_opt_domains; do + printf '%s\n' "domain $1" "search $*" \ + >>/etc/netns/'${cfg.netns}'/resolv.conf + done + ''; + in + if cfg.netns == null + then '' + ${init} + ${script} + '' + else '' + export PATH=${PATH name} + set -eux + ${setNetNSResolvConf} + ip link set dev '${cfg.settings.dev}' up netns '${cfg.netns}' mtu "$tun_mtu" + ip netns exec '${cfg.netns}' ${pkgs.writeShellScript "openvpn-${name}-up-netns.sh" '' + ${init} + set -eux + export PATH=${PATH name} - downScript = '' - #! /bin/sh - export PATH=${path} - ${optionalString cfg.updateResolvConf - "${pkgs.update-resolv-conf}/libexec/openvpn/update-resolv-conf"} - ${cfg.down} - ''; + ip link set dev lo up - configFile = pkgs.writeText "openvpn-config-${name}" - '' - errors-to-stderr - ${optionalString (cfg.up != "" || cfg.down != "" || cfg.updateResolvConf) "script-security 2"} - ${cfg.config} - ${optionalString (cfg.up != "" || cfg.updateResolvConf) - "up ${pkgs.writeScript "openvpn-${name}-up" upScript}"} - ${optionalString (cfg.down != "" || cfg.updateResolvConf) - "down ${pkgs.writeScript "openvpn-${name}-down" downScript}"} - ${optionalString (cfg.authUserPass != null) - "auth-user-pass ${pkgs.writeText "openvpn-credentials-${name}" '' - ${cfg.authUserPass.username} - ${cfg.authUserPass.password} - ''}"} - ''; + netmask4="''${ifconfig_netmask:-30}" + netbits6="''${ifconfig_ipv6_netbits:-112}" + if [ -n "''${ifconfig_local-}" ]; then + if [ -n "''${ifconfig_remote-}" ]; then + ip -4 addr replace \ + local "$ifconfig_local" \ + peer "$ifconfig_remote/$netmask4" \ + ''${ifconfig_broadcast:+broadcast "$ifconfig_broadcast"} \ + dev '${cfg.settings.dev}' + else + ip -4 addr replace \ + local "$ifconfig_local/$netmask4" \ + ''${ifconfig_broadcast:+broadcast "$ifconfig_broadcast"} \ + dev '${cfg.settings.dev}' + fi + fi + if [ -n "''${ifconfig_ipv6_local-}" ]; then + if [ -n "''${ifconfig_ipv6_remote-}" ]; then + ip -6 addr replace \ + local "$ifconfig_ipv6_local" \ + peer "$ifconfig_ipv6_remote/$netbits6" \ + dev '${cfg.settings.dev}' + else + ip -6 addr replace \ + local "$ifconfig_ipv6_local/$netbits6" \ + dev '${cfg.settings.dev}' + fi + fi + set +eux + ${script} + ''} + ''; + route-up = script: + if cfg.netns == null + then script + else '' + export PATH=${PATH name} + set -eux + ip netns exec '${cfg.netns}' ${pkgs.writeShellScript "openvpn-${name}-route-up-netns" '' + export PATH=${PATH name} + set -eux + i=1 + while + eval net=\"\''${route_network_$i-}\" + eval mask=\"\''${route_netmask_$i-}\" + eval gw=\"\''${route_gateway_$i-}\" + eval mtr=\"\''${route_metric_$i-}\" + [ -n "$net" ] + do + ip -4 route replace "$net/$mask" via "$gw" ''${mtr:+metric "$mtr"} + i=$(( i + 1 )) + done + + if [ -n "''${route_vpn_gateway-}" ]; then + ip -4 route replace default via "$route_vpn_gateway" + fi + + i=1 + while + # There doesn't seem to be $route_ipv6_metric_ + # according to the manpage. + eval net=\"\''${route_ipv6_network_$i-}\" + eval gw=\"\''${route_ipv6_gateway_$i-}\" + [ -n "$net" ] + do + ip -6 route replace "$net" via "$gw" metric 100 + i=$(( i + 1 )) + done + + # There's no $route_vpn_gateway for IPv6. It's not + # documented if OpenVPN includes default route in + # $route_ipv6_*. Set default route to remote VPN + # endpoint address if there is one. Use higher metric + # than $route_ipv6_* routes to give preference to a + # possible default route in them. + if [ -n "''${ifconfig_ipv6_remote-}" ]; then + ip -6 route replace default \ + via "$ifconfig_ipv6_remote" metric 200 + fi + ${script} + ''} + ''; + down = script: + let init = '' + export PATH=${PATH name} + ${optionalString cfg.updateResolvConf + "${pkgs.update-resolv-conf}/libexec/openvpn/update-resolv-conf"} + ''; in + if cfg.netns == null + then '' + ${init} + ${script} + '' + else '' + export PATH=${PATH name} + ip netns exec '${cfg.netns}' ${pkgs.writeShellScript "openvpn-${name}-down-netns.sh" '' + ${init} + ${script} + ''} + rm -f /etc/netns/'${cfg.netns}'/resolv.conf + ''; + }; in { description = "OpenVPN instance ‘${name}’"; wantedBy = optional cfg.autoStart "multi-user.target"; after = [ "network.target" ]; + bindsTo = optional (cfg.netns != null) "netns-${cfg.netns}.service"; + requires = optional (cfg.netns != null) "netns-${cfg.netns}.service"; path = [ pkgs.iptables pkgs.iproute2 pkgs.nettools ]; serviceConfig.ExecStart = "@${openvpn}/sbin/openvpn openvpn --suppress-timestamps --config ${configFile}"; serviceConfig.Restart = "always"; + serviceConfig.RestartSec = "5s"; serviceConfig.Type = "notify"; }; - in { @@ -87,30 +232,30 @@ in example = literalExpression '' { server = { - config = ''' + settings = { # Simplest server configuration: https://community.openvpn.net/openvpn/wiki/StaticKeyMiniHowto # server : - dev tun - ifconfig 10.8.0.1 10.8.0.2 - secret /root/static.key - '''; - up = "ip route add ..."; - down = "ip route del ..."; + dev = "tun"; + ifconfig = "10.8.0.1 10.8.0.2"; + secret = "/root/static.key"; + up = "ip route add ..."; + down = "ip route del ..."; + }; }; client = { - config = ''' - client - remote vpn.example.org - dev tun - proto tcp-client - port 8080 - ca /root/.vpn/ca.crt - cert /root/.vpn/alice.crt - key /root/.vpn/alice.key - '''; - up = "echo nameserver $nameserver | ''${pkgs.openresolv}/sbin/resolvconf -m 0 -a $dev"; - down = "''${pkgs.openresolv}/sbin/resolvconf -d $dev"; + settings = { + client = true; + remote = "vpn.example.org"; + dev = "tun"; + proto = "tcp-client"; + port = 8080; + ca = "/root/.vpn/ca.crt"; + cert = "/root/.vpn/alice.crt"; + key = "/root/.vpn/alice.key"; + up = "echo nameserver $nameserver | ''${pkgs.openresolv}/sbin/resolvconf -m 0 -a $dev"; + down = "''${pkgs.openresolv}/sbin/resolvconf -d $dev"; + }; }; } ''; @@ -124,36 +269,77 @@ in attribute name. ''; - type = with types; attrsOf (submodule { + type = with types; attrsOf (submodule ({name, config, options, ...}: { - options = { + options = rec { + enable = mkEnableOption "OpenVPN server" // { default = true; }; - config = mkOption { - type = types.lines; + settings = mkOption { description = lib.mdDoc '' Configuration of this OpenVPN instance. See {manpage}`openvpn(8)` for details. To import an external config file, use the following definition: - `config = "config /path/to/config.ovpn"` - ''; - }; - - up = mkOption { - default = ""; - type = types.lines; - description = lib.mdDoc '' - Shell commands executed when the instance is starting. - ''; - }; - - down = mkOption { - default = ""; - type = types.lines; - description = lib.mdDoc '' - Shell commands executed when the instance is shutting down. + config = /path/to/config.ovpn; ''; + default = {}; + type = types.submodule { + freeformType = with types; + attrsOf ( + nullOr ( + oneOf [ + bool int str path + (listOf (oneOf [bool int str path])) + ] + ) + ); + options.dev = mkOption { + default = null; + type = types.str; + description = lib.mkDoc '' + Shell commands executed when the instance is starting. + ''; + }; + options.down = mkOption { + default = ""; + type = types.lines; + description = lib.mkDoc '' + Shell commands executed when the instance is shutting down. + ''; + }; + options.errors-to-stderr = mkOption { + default = true; + type = types.bool; + description = lib.mkDoc '' + Output errors to stderr instead of stdout + unless log output is redirected by one of the --log options. + ''; + }; + options.route-up = mkOption { + default = ""; + type = types.lines; + description = lib.mkDoc '' + Run command after routes are added. + ''; + }; + options.up = mkOption { + default = ""; + type = types.lines; + description = lib.mkDoc '' + Shell commands executed when the instance is starting. + ''; + }; + options.script-security = mkOption { + default = 1; + type = types.enum [1 2 3]; + description = lib.mkDoc '' + 1 — (Default) Only call built-in executables such as ifconfig, ip, route, or netsh. + 2 — Allow calling of built-in executables and user-defined scripts. + 3 — Allow passwords to be passed to scripts via environmental variables (potentially unsafe). + ''; + }; + }; }; autoStart = mkOption { @@ -162,6 +348,10 @@ in description = lib.mdDoc "Whether this OpenVPN instance should be started automatically."; }; + # Legacy options + down = (elemAt settings.type.functor.payload.modules 0).options.down; + up = (elemAt settings.type.functor.payload.modules 0).options.up; + updateResolvConf = mkOption { default = false; type = types.bool; @@ -195,9 +385,39 @@ in }; }); }; + + netns = mkOption { + default = true; + type = with types; nullOr str; + description = lib.mkDoc "Network namespace."; + }; }; - }); + config.settings = mkMerge + [ (mkIf (config.netns != null) { + # Useless to setup the interface + # because moving it to the netns will reset it + ifconfig-noexec = true; + route-noexec = true; + script-security = 2; + }) + (mkIf (config.authUserPass != null) { + auth-user-pass = pkgs.writeText "openvpn-auth-user-pass-${name}" '' + ${config.authUserPass.username} + ${config.authUserPass.password} + ''; + }) + (mkIf config.updateResolvConf { + script-security = 2; + }) + { # Aliases legacy options + down = mkAliasDefinitions (mkDefault options.down or { }); + up = mkAliasDefinitions (mkDefault options.up or { }); + } + ]; + + + })); }; @@ -206,9 +426,9 @@ in ###### implementation - config = mkIf (cfg.servers != {}) { + config = mkIf (enabledServers != {}) { - systemd.services = listToAttrs (mapAttrsFlatten (name: value: nameValuePair "openvpn-${name}" (makeOpenVPNJob value name)) cfg.servers); + systemd.services = listToAttrs (mapAttrsFlatten (name: value: nameValuePair "openvpn-${name}" (makeOpenVPNJob value name)) enabledServers); environment.systemPackages = [ openvpn ];