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 <code>netns</code> with:
+      <screen>
+      PrivateNetwork=true;
+      JoinsNamespaceOf="netns-''${netns}.service";
+      </screen>
+
+      So can <literal>iproute</literal> with:
+      <code>ip -n ''${netns}</code>
+
+      <warning><para>
+      You should usually create (or update via your VPN configuration's up script)
+      a file named <literal>/etc/netns/''${netns}/resolv.conf</literal>
+      that will be bind-mounted by <code>ip -n ''${netns}</code> onto <literal>/etc/resolv.conf</literal>,
+      which you'll also want to configure in the services joining this network namespace:
+      <screen>
+      BindReadOnlyPaths = ["/etc/netns/''${netns}/resolv.conf:/etc/resolv.conf"];
+      </screen>
+      </para></warning>
+    '';
+    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_<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 {
       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.
+              <literal>config = /path/to/config.ovpn;</literal>
             '';
+            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 <code>--log</code> 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 ];