From c1e90b4719ac240ea021cd67035963d649614fe9 Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sun, 6 Sep 2026 16:57:05 +0200 Subject: [PATCH 1/6] test(vpn): characterize the current VPN profile rendering The point of this commit is to pin how vpn profiles work before performing the refactoring. It doesn't touch production code. --- tests/default.nix | 3 + tests/vpn-profiles-characterization.nix | 22 ++++ tests/vpn-profiles-common.nix | 150 ++++++++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 tests/vpn-profiles-characterization.nix create mode 100644 tests/vpn-profiles-common.nix diff --git a/tests/default.nix b/tests/default.nix index 3f6f74b8..d23f3469 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -9,4 +9,7 @@ idempotent-autoinstall = import ./idempotent-autoinstall.nix { inherit pkgs libSecurix; }; portail = import ./portail.nix { inherit pkgs libSecurix; }; tools = import ./tools.nix { inherit pkgs libSecurix; }; + vpn-profiles-characterization = import ./vpn-profiles-characterization.nix { + inherit pkgs libSecurix; + }; } diff --git a/tests/vpn-profiles-characterization.nix b/tests/vpn-profiles-characterization.nix new file mode 100644 index 00000000..3b0c1b0f --- /dev/null +++ b/tests/vpn-profiles-characterization.nix @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Characterization test for VPN profiles. +# +# This test evaluates the configuration produced by mkTerminal +# and checks the observable artifacts, +# using the legacy vpnProfiles channel. + +{ pkgs, libSecurix }: +let + common = import ./vpn-profiles-common.nix { inherit pkgs; }; +in +common.mkTest "vpn-profiles-characterization" ( + libSecurix.mkTerminal { + name = "vpn-profiles"; + inherit (common) userSpecificModule vpnProfiles; + extraOperators = { inherit (common) alice; }; + modules = [ common.baseModule ]; + } +) diff --git a/tests/vpn-profiles-common.nix b/tests/vpn-profiles-common.nix new file mode 100644 index 00000000..af655f4b --- /dev/null +++ b/tests/vpn-profiles-common.nix @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Shared fixtures and checks for the VPN profile tests. + +{ pkgs }: +let + inherit (pkgs) lib; +in +rec { + vpnProfiles = { + ipsec-01 = { + type = "ipsec"; + endpoint = "vpn-01.example.gouv.fr"; + remote-identity = "CN=vpn-01.example.gouv.fr"; + method = "psk"; + ike = "aes256gcm16-prfsha384-ecp384"; + esp = "aes256gcm16-ecp384"; + remoteSubnets = [ "10.10.0.0/16" ]; + localSubnet = "10.42.0.0/24"; + gateway = "10.42.0.254"; + dns = "10.10.0.53"; + mkAddress = bit: "10.42.0.${toString bit}/32"; + mkPasswordVariable = operator: "$IPSEC_PSK_${lib.toUpper operator}"; + }; + + wg-01 = { + type = "wireguard"; + interface = "wg-01"; + listenPort = 51821; + wireguardPivSlot = "0x5fc10d"; + agePivSlot = 10; + mkAddress = bit: "10.43.0.${toString bit}/32"; + peers = [ + { + publicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + endpoint = "wg-01.example.gouv.fr:51820"; + ips = [ "10.43.0.0/24" ]; + } + ]; + }; + + ipsec-02 = { + type = "ipsec"; + endpoint = "vpn-02.example.gouv.fr"; + remote-identity = "CN=vpn-02.example.gouv.fr"; + method = "psk"; + ike = "aes256gcm16-prfsha384-ecp384"; + esp = "aes256gcm16-ecp384"; + remoteSubnets = [ "10.20.0.0/16" ]; + localSubnet = "%any"; + mkPasswordVariable = operator: "$IPSEC_PSK_02_${lib.toUpper operator}"; + }; + }; + + alice = { + securix.self.user = { + username = "alice"; + email = "alice@example.gouv.fr"; + hashedPassword = "$y$j9T$zk4xGLyshz7RzqnMX6M8O0$AybRelILMkQSWcQZV4s.ykRNi/UlgaCUaDwdee0n7N2"; + bit = 12; + allowedVPNs = [ + "ipsec-01" + "wg-01" + ]; + }; + }; + + userSpecificModule = { + imports = [ alice ]; + securix.self = { + mainDisk = "/dev/nvme0n1"; + machine = { + hardwareSKU = "x280"; + inventoryId = 0; + }; + }; + }; + + baseModule = { + users.allowNoPasswordLogin = true; + securix = { + graphical-interface.variant = "sway"; + users.allowAnyOperator = true; + vpn = { + ipsec.enable = true; + wireguard.enable = true; + }; + }; + }; + + mkChecks = + terminal: + let + cfg = terminal.system.config; + nmProfiles = cfg.networking.networkmanager.ensureProfiles.profiles; + alicePackageNames = map (p: p.name) cfg.users.users.alice.packages; + sudoCommands = lib.concatMap (rule: map (c: c.command or c) rule.commands) ( + lib.filter (rule: rule.users or [ ] != [ ]) cfg.security.sudo.extraRules + ); + in + { + "the resulting system evaluates end to end" = lib.isString cfg.system.build.toplevel.drvPath; + + "ipsec-01 yields a NetworkManager profile for alice" = nmProfiles ? "alice-ipsec-01"; + "ipsec-02 yields nothing since it is not in allowedVPNs" = !(nmProfiles ? "alice-ipsec-02"); + "wg-01 yields NO NetworkManager profile" = !(nmProfiles ? "alice-wg-01"); + "no other NetworkManager profile is generated" = lib.attrNames nmProfiles == [ "alice-ipsec-01" ]; + + "endpoint becomes vpn.address" = nmProfiles.alice-ipsec-01.vpn.address == "vpn-01.example.gouv.fr"; + "remoteSubnets becomes remote-ts" = nmProfiles.alice-ipsec-01.vpn.remote-ts == "10.10.0.0/16"; + "mkAddress is applied to the operator bit" = + nmProfiles.alice-ipsec-01.vpn.local-ts == "10.42.0.12/32"; + "manual addressing combines mkAddress and gateway" = + nmProfiles.alice-ipsec-01.ipv4.address1 == "10.42.0.12/32,10.42.0.254"; + "the psk method is carried over to the connection" = nmProfiles.alice-ipsec-01.vpn.method == "psk"; + "mkPasswordVariable yields the secret variable reference" = + nmProfiles.alice-ipsec-01.vpn-secrets.password == "$IPSEC_PSK_ALICE"; + "ipv6 is disabled" = nmProfiles.alice-ipsec-01.ipv6.method == "disabled"; + + "the connection is locked to its user" = + nmProfiles.alice-ipsec-01.connection.permissions == "user:alice;"; + "the connection id follows the expected convention" = + nmProfiles.alice-ipsec-01.connection.id == "VPN ipsec-01 for alice"; + + "the tunnel management script is installed" = lib.elem "wireguard-wg-01" alicePackageNames; + "the key management scripts are installed" = lib.all (n: lib.elem n alicePackageNames) [ + "wireguard-wg-01-genkey" + "wireguard-wg-01-pubkey" + "wireguard-wg-01-importkey" + ]; + "no script is generated for a disallowed profile" = + !(lib.elem "wireguard-ipsec-02" alicePackageNames); + + "sudo allows driving the tunnel without a password" = + lib.elem "/etc/profiles/per-user/alice/bin/wireguard-wg-01" sudoCommands; + }; + + mkTest = + name: terminal: + let + failures = lib.attrNames (lib.filterAttrs (_: passed: !passed) (mkChecks terminal)); + in + assert lib.assertMsg (failures == [ ]) '' + VPN profile behaviour changed. Failing checks: + ${lib.concatMapStringsSep "\n" (f: " - ${f}") failures} + ''; + pkgs.runCommand name { } "touch $out"; +} From f9f2550180a80dca0533a669a2fae9ff2d89ef78 Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sun, 6 Sep 2026 19:37:31 +0200 Subject: [PATCH 2/6] feat(vpn): declare VPN profiles as a NixOS option --- modules/vpn/default.nix | 1 + modules/vpn/profiles/default.nix | 107 ++++++++++++++++++++++++ modules/vpn/profiles/ipsec.nix | 126 +++++++++++++++++++++++++++++ modules/vpn/profiles/netbird.nix | 30 +++++++ modules/vpn/profiles/wireguard.nix | 88 ++++++++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 modules/vpn/profiles/default.nix create mode 100644 modules/vpn/profiles/ipsec.nix create mode 100644 modules/vpn/profiles/netbird.nix create mode 100644 modules/vpn/profiles/wireguard.nix diff --git a/modules/vpn/default.nix b/modules/vpn/default.nix index 71eb3ec4..b29ef4d1 100644 --- a/modules/vpn/default.nix +++ b/modules/vpn/default.nix @@ -6,6 +6,7 @@ imports = [ ./ipsec ./netbird + ./profiles ./wireguard ]; } diff --git a/modules/vpn/profiles/default.nix b/modules/vpn/profiles/default.nix new file mode 100644 index 00000000..44e9b2fe --- /dev/null +++ b/modules/vpn/profiles/default.nix @@ -0,0 +1,107 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Declarative description of the VPN profiles available to an edition. +# +# A profile describes a tunnel from the infrastructure's point of view: where +# the gateway is, which cryptography to use, which subnets sit behind it, how to +# authenticate. It is shared by every agent. What turns a profile into actual +# configuration is an operator listing it in securix.self.user.allowedVPNs. +# The per-stack modules under modules/vpn/ perform that join. + +{ config, lib, ... }: +let + inherit (lib) mkOption types; + + profileModule = { + imports = [ + ./ipsec.nix + ./netbird.nix + ./wireguard.nix + ]; + + options.type = mkOption { + type = types.enum [ + "ipsec" + "netbird" + "wireguard" + ]; + description = '' + VPN stack backing this profile. It selects which module renders the + profile into configuration, and which of the fields below are used. + ''; + }; + + options.mkAddress = mkOption { + type = types.nullOr (types.functionTo types.str); + default = null; + defaultText = lib.literalExpression "null"; + description = '' + Function mapping an operator's {option}`securix.self.user.bit` to their + address inside the tunnel, in CIDR notation. Shared by the IPsec and + WireGuard stacks. For IPsec it must be left unset when + {option}`localSubnet` is `%any`, since the gateway then assigns the + address itself. + ''; + example = lib.literalExpression ''bit: "10.42.0.''${toString bit}/32"''; + }; + }; + + requiredFields = { + ipsec = [ + "endpoint" + "esp" + "ike" + "localSubnet" + "method" + "remoteSubnets" + ]; + netbird = [ + "admin-url" + "management-url" + ]; + wireguard = [ + "agePivSlot" + "interface" + "listenPort" + "mkAddress" + "peers" + "wireguardPivSlot" + ]; + }; +in +{ + options.securix.vpn.profiles = mkOption { + type = types.attrsOf (types.submodule profileModule); + default = { }; + description = '' + VPN profiles available to this edition, keyed by profile name. Operators + opt into them through {option}`securix.self.user.allowedVPNs`. + ''; + example = lib.literalExpression '' + { + vpn-01 = { + type = "ipsec"; + endpoint = "vpn.example.gouv.fr"; + remote-identity = "CN=vpn.example.gouv.fr"; + method = "cert-on-security-token"; + ike = "aes256gcm16-prfsha384-ecp384"; + esp = "aes256gcm16-ecp384"; + remoteSubnets = [ "10.10.0.0/16" ]; + localSubnet = "%any"; + }; + } + ''; + }; + + config.assertions = lib.concatLists ( + lib.mapAttrsToList ( + profileName: profile: + map (field: { + assertion = profile.${field} != null; + message = "VPN profile `${profileName}` is of type `${profile.type}` and must therefore set `${field}`."; + }) requiredFields.${profile.type} + ) config.securix.vpn.profiles + ); +} diff --git a/modules/vpn/profiles/ipsec.nix b/modules/vpn/profiles/ipsec.nix new file mode 100644 index 00000000..19cd64d1 --- /dev/null +++ b/modules/vpn/profiles/ipsec.nix @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# IPsec/IKEv2 fields of a VPN profile. Consumed by +# modules/vpn/ipsec/networkmanager.nix, which renders each (operator, profile) +# pair into a declarative NetworkManager connection. + +{ lib, ... }: +let + inherit (lib) mkOption types; +in +{ + options = { + endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Address of the IPsec gateway. Becomes `vpn.address`."; + example = "vpn.example.gouv.fr"; + }; + + remote-identity = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Identity the gateway is expected to present, used to validate its + certificate. Left unset, NetworkManager falls back to its own default. + ''; + example = "CN=vpn.example.gouv.fr"; + }; + + method = mkOption { + type = types.nullOr ( + types.enum [ + "cert-on-security-token" + "psk" + ] + ); + default = null; + description = '' + Authentication method. `cert-on-security-token` drives the connection + through the agent's smartcard and asks for its PIN; `psk` reads a + pre-shared key from the environment variable named by + {option}`mkPasswordVariable`. + ''; + }; + + ike = mkOption { + type = types.nullOr types.str; + default = null; + description = "IKE (phase 1) cryptographic proposal."; + example = "aes256gcm16-prfsha384-ecp384"; + }; + + esp = mkOption { + type = types.nullOr types.str; + default = null; + description = "ESP (phase 2) cryptographic proposal."; + example = "aes256gcm16-ecp384"; + }; + + remoteSubnets = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = '' + Subnets reachable through the tunnel. Becomes the remote traffic + selectors, and feeds the generated network flow documentation. + ''; + example = [ "10.10.0.0/16" ]; + }; + + localSubnet = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Subnet the agent belongs to inside the tunnel. The special value `%any` + switches the connection to IPsec config mode, where the gateway assigns + the address; in that case {option}`mkAddress` and {option}`gateway` must + be left unset. + ''; + example = "%any"; + }; + + gateway = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Gateway address inside the tunnel, for manual addressing. Must be unset + when {option}`localSubnet` is `%any`. + ''; + }; + + dns = mkOption { + type = types.nullOr types.str; + default = null; + description = "DNS server to use while the tunnel is up."; + }; + + mkPasswordVariable = mkOption { + type = types.nullOr (types.functionTo types.str); + default = null; + defaultText = lib.literalExpression "null"; + description = '' + Function mapping an operator name to the shell variable holding their + pre-shared key. The generated connection file is piped through envsubst, + so the returned string must keep its leading `$`. Required when + {option}`method` is `psk`. + ''; + example = lib.literalExpression ''operator: "\$IPSEC_PSK_''${operator}"''; + }; + + availableHttpProxies = mkOption { + type = types.attrsOf types.raw; + # NOTE: this one defaults to { } rather than null on purpose. The + # consumer tests (profile.availableHttpProxies or { }) != { } to decide + # whether to wire up proxy switching. A null default would make that + # test true for every profile and silently enable the machinery fleet-wide. + default = { }; + description = '' + Deprecated. HTTP proxies to switch to when this tunnel comes up. Use + {option}`securix.vpn.ipsec.proxies.map` or the NetworkManager event + handlers instead. + ''; + }; + }; +} diff --git a/modules/vpn/profiles/netbird.nix b/modules/vpn/profiles/netbird.nix new file mode 100644 index 00000000..54165cca --- /dev/null +++ b/modules/vpn/profiles/netbird.nix @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Netbird fields of a VPN profile. Consumed by +# modules/vpn/netbird/default.nix, which instantiates one Netbird client per +# (operator, profile) pair. Everything else is handled by the upstream Netbird +# module, hence the very small surface here. + +{ lib, ... }: +let + inherit (lib) mkOption types; +in +{ + options = { + management-url = mkOption { + type = types.nullOr types.str; + default = null; + description = "URL of the Netbird management server this client registers against."; + example = "https://netbird.example.gouv.fr:33073"; + }; + + admin-url = mkOption { + type = types.nullOr types.str; + default = null; + description = "URL of the Netbird administration dashboard."; + example = "https://netbird.example.gouv.fr"; + }; + }; +} diff --git a/modules/vpn/profiles/wireguard.nix b/modules/vpn/profiles/wireguard.nix new file mode 100644 index 00000000..666bf30e --- /dev/null +++ b/modules/vpn/profiles/wireguard.nix @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# WireGuard fields of a VPN profile. Consumed by +# modules/vpn/wireguard/default.nix, which renders each (operator, profile) +# pair into per-user scripts rather than a systemd unit: the private key lives +# encrypted inside the agent's security token, so bringing the tunnel up +# requires their physical presence. + +{ lib, ... }: +let + inherit (lib) mkOption types; +in +{ + options = { + interface = mkOption { + type = types.nullOr types.str; + default = null; + description = "Name of the network interface created for this tunnel."; + example = "wg-01"; + }; + + listenPort = mkOption { + type = types.nullOr types.port; + default = null; + description = "Local UDP port the tunnel listens on."; + example = 51820; + }; + + wireguardPivSlot = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + PIV object slot on the security token holding the age-encrypted + WireGuard private key. + ''; + example = "0x5fc10d"; + }; + + agePivSlot = mkOption { + type = types.nullOr (types.either types.int types.str); + default = null; + description = '' + PIV slot holding the age identity used to decrypt the WireGuard private + key stored in {option}`wireguardPivSlot`. + ''; + example = 10; + }; + + peers = mkOption { + default = null; + description = "Remote peers of this tunnel."; + type = types.nullOr ( + types.listOf ( + types.submodule { + options = { + publicKey = mkOption { + type = types.str; + description = "Base64-encoded public key of the peer."; + }; + + endpoint = mkOption { + type = types.str; + description = '' + Reachable address of the peer, as `host:port`. A host route + towards it is added through the default gateway when the + tunnel comes up. + ''; + example = "wg-01.example.gouv.fr:51820"; + }; + + ips = mkOption { + type = types.listOf types.str; + description = '' + Subnets routed towards this peer, in CIDR notation. Used both + as the peer's allowed IPs and to install routes on the tunnel + interface. + ''; + example = [ "10.43.0.0/24" ]; + }; + }; + } + ) + ); + }; + }; +} From a7e9e6e21d66afbeb01d8bff619ab221f16b3b7b Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sun, 6 Sep 2026 19:46:05 +0200 Subject: [PATCH 3/6] feat(vpn): dual-write VPN profiles into securix.vpn.profiles --- lib/default.nix | 10 ++++- tests/default.nix | 1 + tests/vpn-profiles-legacy-channel.nix | 62 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/vpn-profiles-legacy-channel.nix diff --git a/lib/default.nix b/lib/default.nix index f9ddd35e..0c1f62c3 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -474,7 +474,7 @@ rec { { name, userSpecificModule, - vpnProfiles, + vpnProfiles ? { }, extraOperators ? { }, modules, edition ? args.edition, @@ -500,7 +500,15 @@ rec { cfg.securix.self.user or cfg.securix.self ) ) extraOperators; + + # Dual write: the same profiles are published through both channels so that + # out-of-tree modules asking for vpnProfiles in their signature keep working. + # Nothing in this repository reads the legacy channel any more. + # TODO(migration): drop the following _module.args line, along with + # tests/vpn-profiles-legacy-channel.nix and this comment, + # once no one reads it anymore. _module.args.vpnProfiles = vpnProfiles; + securix.vpn.profiles = vpnProfiles; age.identityPaths = [ # FIXME: age ne sait pas encore utiliser le TPM2 pour déchiffrer des secrets diff --git a/tests/default.nix b/tests/default.nix index d23f3469..5ca89819 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -12,4 +12,5 @@ vpn-profiles-characterization = import ./vpn-profiles-characterization.nix { inherit pkgs libSecurix; }; + vpn-profiles-legacy-channel = import ./vpn-profiles-legacy-channel.nix { inherit pkgs libSecurix; }; } diff --git a/tests/vpn-profiles-legacy-channel.nix b/tests/vpn-profiles-legacy-channel.nix new file mode 100644 index 00000000..a177d2af --- /dev/null +++ b/tests/vpn-profiles-legacy-channel.nix @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Backwards-compatibility test for the legacy _module.args.vpnProfiles +# channel. +# +# NOTE: this test exists to protect a behaviour that will be deprecated. +# Delete it in the same commit that removes _module.args.vpnProfiles +# from lib/default.nix. + +{ pkgs, libSecurix }: +let + inherit (pkgs) lib; + common = import ./vpn-profiles-common.nix { inherit pkgs; }; + + outOfTreeConsumer = { vpnProfiles, ... }: { + options.legacyVpnProfilesProbe = lib.mkOption { + type = lib.types.raw; + internal = true; + description = "What an unmigrated module sees through _module.args.vpnProfiles."; + }; + config.legacyVpnProfilesProbe = vpnProfiles; + }; + + terminal = libSecurix.mkTerminal { + name = "vpn-profiles"; + inherit (common) userSpecificModule vpnProfiles; + extraOperators = { inherit (common) alice; }; + modules = [ + common.baseModule + outOfTreeConsumer + ]; + }; + + seen = terminal.system.config.legacyVpnProfilesProbe; + + checks = { + "an unmigrated module still receives the profiles" = + lib.attrNames seen == [ + "ipsec-01" + "ipsec-02" + "wg-01" + ]; + + "the profiles it receives carry their values" = + (seen.ipsec-01.endpoint or null) == "vpn-01.example.gouv.fr"; + + "functions survive the legacy channel" = (seen.ipsec-01.mkAddress 12) == "10.42.0.12/32"; + + "the legacy channel carries the raw attribute set, not the normalized one" = + !(seen.ipsec-01 ? interface); + }; + + failures = lib.attrNames (lib.filterAttrs (_: passed: !passed) checks); +in +assert lib.assertMsg (failures == [ ]) '' + The legacy `_module.args.vpnProfiles` channel no longer works for unmigrated + modules. Failing checks: + ${lib.concatMapStringsSep "\n" (f: " - ${f}") failures} +''; +pkgs.runCommand "vpn-profiles-legacy-channel" { } "touch $out" From ef8076197fb4819a9ec385d5aafec61854445788 Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sun, 6 Sep 2026 19:50:48 +0200 Subject: [PATCH 4/6] refactor(vpn): read profiles from config instead of _module.args --- modules/vpn/ipsec/networkmanager.nix | 2 +- modules/vpn/netbird/default.nix | 2 +- modules/vpn/wireguard/default.nix | 2 +- tests/default.nix | 3 +++ tests/vpn-profiles-module-refactor.nix | 25 +++++++++++++++++++++++++ 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 tests/vpn-profiles-module-refactor.nix diff --git a/modules/vpn/ipsec/networkmanager.nix b/modules/vpn/ipsec/networkmanager.nix index 37c4d800..fb811108 100644 --- a/modules/vpn/ipsec/networkmanager.nix +++ b/modules/vpn/ipsec/networkmanager.nix @@ -4,13 +4,13 @@ { pkgs, - vpnProfiles, operators, config, lib, ... }: let + vpnProfiles = config.securix.vpn.profiles; cfg = config.securix.vpn.ipsec; inherit (lib) mkIf diff --git a/modules/vpn/netbird/default.nix b/modules/vpn/netbird/default.nix index 1821131a..3002dc46 100644 --- a/modules/vpn/netbird/default.nix +++ b/modules/vpn/netbird/default.nix @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MIT { - vpnProfiles, operators, pkgs, lib, @@ -11,6 +10,7 @@ ... }: let + vpnProfiles = config.securix.vpn.profiles; cfg = config.securix.vpn.netbird; inherit (lib) mkIf diff --git a/modules/vpn/wireguard/default.nix b/modules/vpn/wireguard/default.nix index 5a6bd9e4..c88a004e 100644 --- a/modules/vpn/wireguard/default.nix +++ b/modules/vpn/wireguard/default.nix @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MIT { - vpnProfiles, operators, pkgs, lib, @@ -11,6 +10,7 @@ ... }: let + vpnProfiles = config.securix.vpn.profiles; cfg = config.securix.vpn.wireguard; inherit (lib) diff --git a/tests/default.nix b/tests/default.nix index 5ca89819..83afc338 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -13,4 +13,7 @@ inherit pkgs libSecurix; }; vpn-profiles-legacy-channel = import ./vpn-profiles-legacy-channel.nix { inherit pkgs libSecurix; }; + vpn-profiles-module-refactor = import ./vpn-profiles-module-refactor.nix { + inherit pkgs libSecurix; + }; } diff --git a/tests/vpn-profiles-module-refactor.nix b/tests/vpn-profiles-module-refactor.nix new file mode 100644 index 00000000..7c0f4ef3 --- /dev/null +++ b/tests/vpn-profiles-module-refactor.nix @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Characterization test for VPN profiles. +# +# This test evaluates the configuration produced by mkTerminal +# and checks the observable artifacts, +# using the new vpn profiles modules + +{ pkgs, libSecurix }: +let + common = import ./vpn-profiles-common.nix { inherit pkgs; }; +in +common.mkTest "vpn-profiles-module-refactor" ( + libSecurix.mkTerminal { + name = "vpn-profiles"; + inherit (common) userSpecificModule; + extraOperators = { inherit (common) alice; }; + modules = [ + common.baseModule + { securix.vpn.profiles = common.vpnProfiles; } + ]; + } +) From a47829bca201d81d4575a40b253ec313f581472c Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sun, 6 Sep 2026 19:52:45 +0200 Subject: [PATCH 5/6] refactor(self): validate allowedVPNs with an assertion --- modules/self.nix | 9 ++- tests/default.nix | 2 + tests/vpn-profiles-composition.nix | 101 +++++++++++++++++++++++++++++ tests/vpn-profiles-validation.nix | 57 ++++++++++++++++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 tests/vpn-profiles-composition.nix create mode 100644 tests/vpn-profiles-validation.nix diff --git a/modules/self.nix b/modules/self.nix index dd5e9c01..93a45e08 100644 --- a/modules/self.nix +++ b/modules/self.nix @@ -3,7 +3,6 @@ # SPDX-License-Identifier: MIT { - vpnProfiles, pkgs, config, lib, @@ -109,7 +108,7 @@ in }; allowedVPNs = mkOption { - type = types.listOf (types.enum (builtins.attrNames vpnProfiles)); + type = types.listOf types.str; default = [ ]; description = "Liste des VPNs provisionnés pour l'utilisateur"; example = [ "vpn-01" ]; @@ -253,7 +252,11 @@ in assertion = cfg.user.hashedPassword != ""; message = "securix.self.user.hashedPassword ne peut pas être une chaîne vide."; } - ]; + ] + ++ map (vpn: { + assertion = config.securix.vpn.profiles ? ${vpn}; + message = "L'utilisateur ${toString cfg.user.username} référence le VPN `${vpn}` qui n'existe pas dans `securix.vpn.profiles`."; + }) cfg.user.allowedVPNs; services.getty.helpLine = optionalString isMachineConfig '' Bienvenue sur Sécurix (identifiant ${toString machineIdentifier}). ${optionalString isUserConfig "Utilisateur principal: ${toString cfg.user.email}."} diff --git a/tests/default.nix b/tests/default.nix index 83afc338..2f97bbee 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -16,4 +16,6 @@ vpn-profiles-module-refactor = import ./vpn-profiles-module-refactor.nix { inherit pkgs libSecurix; }; + vpn-profiles-composition = import ./vpn-profiles-composition.nix { inherit pkgs libSecurix; }; + vpn-profiles-validation = import ./vpn-profiles-validation.nix { inherit pkgs libSecurix; }; } diff --git a/tests/vpn-profiles-composition.nix b/tests/vpn-profiles-composition.nix new file mode 100644 index 00000000..acfca012 --- /dev/null +++ b/tests/vpn-profiles-composition.nix @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Composition test for securix.vpn.profiles. +# +# The other vpn tests show that the refactor breaks nothing. +# This one shows what it buys: profiles are now ordinary configuration, +# so several modules can each contribute their own, and any machine can +# override one field of one profile. + +{ pkgs, libSecurix }: +let + inherit (pkgs) lib; + common = import ./vpn-profiles-common.nix { inherit pkgs; }; + + stagingEndpoint = "vpn-01-staging.example.gouv.fr"; + + alice = lib.recursiveUpdate common.alice { + securix.self.user.allowedVPNs = [ + "ipsec-01" + "ipsec-legal" + ]; + }; + + editionModule = { + securix.vpn.profiles = { inherit (common.vpnProfiles) ipsec-01 wg-01; }; + }; + + legalTeamModule = { + securix.vpn.profiles.ipsec-legal = { + type = "ipsec"; + endpoint = "vpn-legal.example.gouv.fr"; + remote-identity = "CN=vpn-legal.example.gouv.fr"; + method = "psk"; + ike = "aes256gcm16-prfsha384-ecp384"; + esp = "aes256gcm16-ecp384"; + remoteSubnets = [ "10.30.0.0/16" ]; + localSubnet = "10.44.0.0/24"; + gateway = "10.44.0.254"; + mkAddress = bit: "10.44.0.${toString bit}/32"; + mkPasswordVariable = operator: "$IPSEC_PSK_LEGAL_${lib.toUpper operator}"; + }; + }; + + stagingOverrideModule = { + securix.vpn.profiles.ipsec-01.endpoint = lib.mkForce stagingEndpoint; + }; + + terminal = libSecurix.mkTerminal { + name = "vpn-profiles"; + userSpecificModule = { + imports = [ alice ]; + securix.self = { + mainDisk = "/dev/nvme0n1"; + machine = { + hardwareSKU = "x280"; + inventoryId = 0; + }; + }; + }; + extraOperators = { inherit alice; }; + modules = [ + common.baseModule + editionModule + legalTeamModule + stagingOverrideModule + ]; + }; + + cfg = terminal.system.config; + nmProfiles = cfg.networking.networkmanager.ensureProfiles.profiles; + + checks = { + "the edition profile is rendered" = nmProfiles ? "alice-ipsec-01"; + "the team profile is rendered too" = nmProfiles ? "alice-ipsec-legal"; + "the team profile carries its own values" = + (nmProfiles.alice-ipsec-legal.vpn.address or null) == "vpn-legal.example.gouv.fr"; + "a profile nobody is entitled to is still not rendered" = !(nmProfiles ? "alice-wg-01"); + + "the overridden field takes the machine's value" = + (nmProfiles.alice-ipsec-01.vpn.address or null) == stagingEndpoint; + "the fields around it are untouched" = + (nmProfiles.alice-ipsec-01.vpn.remote-ts or null) == "10.10.0.0/16" + && (nmProfiles.alice-ipsec-01.ipv4.address1 or null) == "10.42.0.12/32,10.42.0.254"; + + "both profiles are visible in the option" = + lib.attrNames cfg.securix.vpn.profiles == [ + "ipsec-01" + "ipsec-legal" + "wg-01" + ]; + }; + + failures = lib.attrNames (lib.filterAttrs (_: passed: !passed) checks); +in +assert lib.assertMsg (failures == [ ]) '' + VPN profiles no longer compose as expected. Failing checks: + ${lib.concatMapStringsSep "\n" (f: " - ${f}") failures} +''; +pkgs.runCommand "vpn-profiles-composition" { } "touch $out" diff --git a/tests/vpn-profiles-validation.nix b/tests/vpn-profiles-validation.nix new file mode 100644 index 00000000..99aef66c --- /dev/null +++ b/tests/vpn-profiles-validation.nix @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 Mattias Kockum +# +# SPDX-License-Identifier: MIT +# +# Validation test for securix.self.user.allowedVPNs. + +{ pkgs, libSecurix }: +let + inherit (pkgs) lib; + common = import ./vpn-profiles-common.nix { inherit pkgs; }; + + aliceWithUnknownVpn = lib.recursiveUpdate common.alice { + securix.self.user.allowedVPNs = [ + "ipsec-01" + "nexistepas" + ]; + }; + + terminal = libSecurix.mkTerminal { + name = "vpn-profiles"; + inherit (common) vpnProfiles; + userSpecificModule = { + imports = [ aliceWithUnknownVpn ]; + securix.self = { + mainDisk = "/dev/nvme0n1"; + machine = { + hardwareSKU = "x280"; + inventoryId = 0; + }; + }; + }; + extraOperators = { + alice = aliceWithUnknownVpn; + }; + modules = [ common.baseModule ]; + }; + + failedAssertions = lib.filter (a: !a.assertion) terminal.system.config.assertions; + messages = lib.concatStringsSep "\n" (map (a: a.message) failedAssertions); + + checks = { + "referencing an unknown VPN raises exactly one assertion" = lib.length failedAssertions == 1; + "the assertion names the offending VPN" = lib.hasInfix "nexistepas" messages; + "the assertion names the offending user" = lib.hasInfix "alice" messages; + "a known VPN raises nothing" = !(lib.hasInfix "ipsec-01" messages); + }; + + failures = lib.attrNames (lib.filterAttrs (_: passed: !passed) checks); +in +assert lib.assertMsg (failures == [ ]) '' + `allowedVPNs` is no longer validated as expected. Failing checks: + ${lib.concatMapStringsSep "\n" (f: " - ${f}") failures} + + Assertions actually raised: + ${messages} +''; +pkgs.runCommand "vpn-profiles-validation" { } "touch $out" From 757f02d399cc71d8c2d6ab93a965fca7c19424d7 Mon Sep 17 00:00:00 2001 From: Mattias Kockum Date: Sun, 6 Sep 2026 19:53:16 +0200 Subject: [PATCH 6/6] docs(changelog): add entries for VPN profiles refactor --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd27b78f..e69685fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ SPDX-License-Identifier: MIT # Unreleased +## Features + +- VPN profiles are now a NixOS option, `securix.vpn.profiles`, instead of an + untyped module argument. They are typed, and can be contributed by any module: + + ```nix + # in a team module + securix.vpn.profiles.vpn-legal = { type = "ipsec"; … }; + + # in a single machine's configuration + securix.vpn.profiles.vpn-01.endpoint = lib.mkForce "vpn-staging.example.gouv.fr"; + ``` + + The `vpnProfiles` parameter of `mkTerminal` keeps working and is now optional. + +- `securix.self.user.allowedVPNs` is now typed as a plain list of strings. + Referencing a VPN that does not exist in `securix.vpn.profiles` now raises an + assertion naming both the offending user and the VPN. + ## Breaking - `availableHttpProxies` definition in `vpnProfiles` is deprecated, if you were using this option, you can replace it by something along these lines: @@ -22,3 +41,9 @@ SPDX-License-Identifier: MIT The advantage of this method is that you can refer to the context of the Securix system and do not suffer from https://github.com/cloud-gouv/securix/issues/195 limitations. + +## Deprecated + +- `_module.args.vpnProfiles` is now deprecated. Modules asking for `vpnProfiles` in + their signature still work, but should read `config.securix.vpn.profiles` + instead. The argument will be removed in a future release.