From 14ef78b30a3100dc5e4621bb8c3601a7604b730a Mon Sep 17 00:00:00 2001 From: Julien Moutinho Date: Sun, 17 Jan 2021 15:28:25 +0100 Subject: [PATCH] nixos/openvpn: add netns support --- nixos/modules/services/networking/openvpn.nix | 459 ++++++++++++++++++++-------- 1 file changed, 342 insertions(+), 117 deletions(-) diff --git a/nixos/modules/services/networking/openvpn.nix b/nixos/modules/services/networking/openvpn.nix index cea5b180a3..0145fdd9fe 100644 --- a/nixos/modules/services/networking/openvpn.nix +++ b/nixos/modules/services/networking/openvpn.nix @@ -10,57 +10,205 @@ let cfg = config.services.openvpn; - - makeOpenVPNJob = - cfg: name: + 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; - - upScript = '' - export PATH=${path} - - # 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"} - ''; - - downScript = '' - export PATH=${path} - ${optionalString cfg.updateResolvConf "${pkgs.update-resolv-conf}/libexec/openvpn/update-resolv-conf"} - ${cfg.down} - ''; - - 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.writeShellScript "openvpn-${name}-up" upScript}"} - ${optionalString ( - cfg.down != "" || cfg.updateResolvConf - ) "down ${pkgs.writeShellScript "openvpn-${name}-down" downScript}"} - ${optionalString (cfg.authUserPass != null) ( - if isAttrs cfg.authUserPass then - "auth-user-pass ${pkgs.writeText "openvpn-credentials-${name}" '' - ${cfg.authUserPass.username} - ${cfg.authUserPass.password} - ''}" - else - "auth-user-pass ${cfg.authUserPass}" - )} - ''; + 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 + ); + + 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 + + ${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} + + ip link set dev lo up + + 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 { @@ -68,6 +216,8 @@ 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 @@ -77,6 +227,7 @@ serviceConfig.ExecStart = "@${config.services.openvpn.package}/sbin/openvpn openvpn --suppress-timestamps --config ${configFile}"; serviceConfig.Restart = "always"; + serviceConfig.RestartSec = "5s"; serviceConfig.Type = "notify"; }; @@ -88,7 +239,7 @@ unitNames = map (n: "openvpn-${n}.service") (builtins.attrNames cfg.servers); in "systemctl try-restart ${lib.escapeShellArgs unitNames}"; - description = "Restart system OpenVPN connections when returning from sleep"; + description = "Sends a signal to OpenVPN process to trigger a restart after return from sleep"; }; }; @@ -110,30 +261,30 @@ 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"; + }; }; } ''; @@ -147,55 +298,102 @@ 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; - description = '' - Configuration of this OpenVPN instance. See - {manpage}`openvpn(8)` - for details. + settings = mkOption { + description = '' + 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 = '' - Shell commands executed when the instance is starting. - ''; + 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 = '' + Shell commands executed when the instance is starting. + ''; + }; + options.down = mkOption { + default = ""; + type = types.lines; + description = '' + Shell commands executed when the instance is shutting down. + ''; + }; + options.errors-to-stderr = mkOption { + default = true; + type = types.bool; + description = '' + 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 = '' + Run command after routes are added. + ''; + }; + options.up = mkOption { + default = ""; + type = types.lines; + description = '' + Shell commands executed when the instance is starting. + ''; + }; + options.script-security = mkOption { + default = 1; + type = types.enum [ 1 2 3 ]; + description = '' + - 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). + ''; + }; }; + }; - down = mkOption { - default = ""; - type = types.lines; - description = '' - Shell commands executed when the instance is shutting down. - ''; - }; + autoStart = mkOption { + default = true; + type = types.bool; + description = "Whether this OpenVPN instance should be started automatically."; + }; - autoStart = mkOption { - default = true; - type = types.bool; - description = "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; - description = '' - Use the script from the update-resolv-conf package to automatically - update resolv.conf with the DNS information provided by openvpn. The - script will be run after the "up" commands and before the "down" commands. - ''; - }; + updateResolvConf = mkOption { + default = false; + type = types.bool; + description = '' + Use the script from the update-resolv-conf package to automatically + update resolv.conf with the DNS information provided by openvpn. The + script will be run after the "up" commands and before the "down" commands. + ''; + }; authUserPass = mkOption { default = null; @@ -227,9 +425,39 @@ ] ); }; - }; - - }); + + netns = mkOption { + default = null; + type = with types; nullOr str; + description = "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 = modules.mkAliasAndWrapDefsWithPriority id (options.down or { }); + up = modules.mkAliasAndWrapDefsWithPriority id (options.up or { }); + } + ]; + })); }; @@ -243,14 +471,11 @@ ###### implementation - config = mkIf (cfg.servers != { }) { + config = mkIf (enabledServers != { }) { - systemd.services = - (listToAttrs ( - mapAttrsToList ( - name: value: nameValuePair "openvpn-${name}" (makeOpenVPNJob value name) - ) cfg.servers - )) + systemd.services = (listToAttrs (mapAttrsToList + (name: value: nameValuePair "openvpn-${name}" + (makeOpenVPNJob value name)) enabledServers)) // restartService; environment.systemPackages = [ cfg.package ];