From 600839a7759be481904d029798e563e23df930db Mon Sep 17 00:00:00 2001
From: Julien Moutinho <julm+nixpkgs@sourcephile.fr>
Date: Sun, 17 Jan 2021 15:28:25 +0100
Subject: [PATCH 2/2] nixos/openvpn: add netns support

---
 nixos/modules/services/networking/openvpn.nix | 395 ++++++++++++++----
 1 file changed, 314 insertions(+), 81 deletions(-)

diff --git a/nixos/modules/services/networking/openvpn.nix b/nixos/modules/services/networking/openvpn.nix
index 56b1f6f5ab8f..9805099789b1 100644
--- a/nixos/modules/services/networking/openvpn.nix
+++ b/nixos/modules/services/networking/openvpn.nix
@@ -5,55 +5,205 @@ 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 = ''
-        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 = ''
-        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.writeShellScript "openvpn-${name}-up" upScript}"}
-          ${optionalString (cfg.down != "" || cfg.updateResolvConf)
-              "down ${pkgs.writeShellScript "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_<n>
+                # 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
     {
@@ -61,11 +211,14 @@ let
 
       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";
     };
 
@@ -97,30 +250,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";
+            };
           };
         }
       '';
@@ -134,36 +287,80 @@ 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 = ''
               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.
-            '';
-          };
-
-          down = mkOption {
-            default = "";
-            type = types.lines;
-            description = ''
-              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 = ''
+                  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).
+                '';
+              };
+            };
           };
 
           autoStart = mkOption {
@@ -172,6 +369,10 @@ in
             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;
@@ -205,9 +406,41 @@ in
               };
             });
           };
+
+          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 { });
+            }
+          ];
+
+
+      }));
 
     };
 
@@ -222,9 +455,9 @@ in
 
   ###### 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 = [ openvpn ];
-- 
2.44.1