Skip to content

feat(vpn): move VPN profiles from _module.args to a NixOS option - #259

Open
MattiasKockum wants to merge 6 commits into
cloud-gouv:mainfrom
MattiasKockum:refactor/vpn-profiles
Open

MattiasKockum wants to merge 6 commits into
cloud-gouv:mainfrom
MattiasKockum:refactor/vpn-profiles

Conversation

@MattiasKockum

@MattiasKockum MattiasKockum commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

feat(vpn): move VPN profiles from _module.args to a NixOS option

Why

VPN profiles are configuration, but they travel through _module.args.vpnProfiles, a side channel that bypasses the module system. So they cannot be typed, documented, overridden per machine, or seen by anything reasoning over config, and only one place can supply them, since mkTerminal takes a single vpnProfiles parameter for the whole fleet.

Concretely: a mistyped field in a profile fails today as attribute 'interface' missing, pointing into modules/vpn/wireguard/default.nix, naming neither the profile nor the edition at fault. And there is no written specification of what a profile may contain, I reconstructed the field list by reading what the three consumers dereference, since no real profile exists in this repository.

The goal of this PR is to provide Securix with a module to handle VPN profiles in a clean and composable way.

This PR declares securix.vpn.profiles and migrates every in-tree consumer to it, but keeps backward compatibility through dual-write.

What changes

New module modules/vpn/profiles that declares securix.vpn.profiles as attrsOf (submodule ...). default.nix holds the type discriminator and mkAddress, which both the IPsec and WireGuard stacks use; ipsec.nix, wireguard.nix and netbird.nix are submodule fragments contributing their own stack's fields.
Side note: a single submodule type is shared by all three stacks, so every stack-specific field has to be nullOr ... default = null, a mandatory endpoint would reject every WireGuard profile. Required-ness lives in assertions gated on type, listed in requiredFields.

lib/default.nix: the inline module now writes both _module.args.vpnProfiles and securix.vpn.profiles from the same parameter, so the two views cannot diverge. vpnProfiles also becomes optional (? { }): an edition can now declare its profiles in one of the modules it already passes to mkTerminal, and drop the parameter entirely.

The three stack modules drop vpnProfiles from their signature and bind vpnProfiles = config.securix.vpn.profiles in their let. Their bodies are untouched. Because module arguments are pulled by name rather than pushed, each could move independently.

modules/self.nix: securix.self.user.allowedVPNs goes from listOf (enum (attrNames vpnProfiles)) to listOf str, with the check moved into an assertion.
The old type could not follow the move: building an option type out of config means reading what the option catalogue has to be built before, which is a recursion waiting to happen. It becomes listOf str, and the check becomes an assertion naming both the offending user and the VPN.

Tests

I love writing tests in Nix (I really do) so I wrote a bunch of them to ensure the refactor works and is backward compatible.

Test Point
characterization Tests that everything works before the refactor, and continues to after
module-refactor Tests the new entry point
legacy-channel Tests that an unmigrated out-of-tree module still receives its profiles
validation Tests an unknown VPN name still gets caught
composition Shows what this refactor buys

vpn-profiles-common.nix holds the fixtures and the shared checks, so the first two are byte-identical apart from the door the profiles come through.

For an easier review of this PR, I would suggest starting from the end and read the composition test as it shows the spirit of the whole refactor.

Notes

The profile submodule is strict, meaning any field it does not declare will be rejected. I built the field list by reading the three consumers, having no real profile in the repository to check against. If the actual inventories carry fields I missed, this breaks them on upgrade. I can either add them, or add a freeformType for the duration of the migration.

@rlahfa-dinum rlahfa-dinum added status: awaiting-maintainers This is blocked on a maintainer's review bandwidth A/nixos Generic NixOS plumbing: NixOS modules, library functions, etc. labels Sep 9, 2026
@rlahfa-dinum rlahfa-dinum self-assigned this Sep 9, 2026
#
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# authenticate. It is shared by every agent. What turns a profile into actual
# authenticate. It is shared by every user. What turns a profile into actual

./wireguard.nix
];

options.type = mkOption {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative proposal: Let's not have a giga big supermodule that everyone writes to.

Let's have securix.vpn.ipsec.profiles, securix.vpn.netbird.profiles, securix.vpn.wireguard.profiles, etc.
Extension becomes "easy" by just creating a new sub-module tree rather than performing type option merges (i.e. declaring securix.vpn.type outside and merging the types.enum thing).

This way, each {type}.* can have its own customisation and specialties and doesn't have to conform to a generic interface which would fit some sort of poor minimal denominator.

'';
};

options.mkAddress = mkOption {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option should be a backward compatibility system or so.
The old VPN profiles contains mkAddress functions, but we should not model them again.

In the NixOS module system, we have access to securix.self.user.bit so developers can simply replicate that feature by using config.securix.self.user.bit if they want to (or use %any, etc.)

This feature only exist because we did not have the module system and had to reinvent pieces of the module system by having a "late binding" in the form of a function.

};

requiredFields = {
ipsec = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With my alternative proposal, this could go into the defn of the IPsec VPN.
Same for the others.

};
in
{
options.securix.vpn.profiles = mkOption {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option can disappear with the alternative proposal.

'';
};

config.assertions = lib.concatLists (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be split into 3 assertions checks for each VPN module defined.

endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Address of the IPsec gateway. Becomes `vpn.address`.";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description is weird to read. Drop the "Becomes ..."

default = null;
description = ''
Identity the gateway is expected to present, used to validate its
certificate. Left unset, NetworkManager falls back to its own default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Can you put a link to the documentation on the remote-identity for IPsec (the strongswan one)? This would be greatly helpful for developers.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's still get rid of mkAddress.

default = null;
description = ''
Subnets reachable through the tunnel. Becomes the remote traffic
selectors, and feeds the generated network flow documentation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the "generated network flow documentation", it's not a feature that is really used here.

description = "DNS server to use while the tunnel is up.";
};

mkPasswordVariable = mkOption {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, we should rethink this option but this one is going to be hard because it relates to the automatic generation in networkmanager.

We would put something akin to securix.vpn.profiles.ipsec.$operator.pskPath = "...";, not sure what to do here.

If you have an idea, interested to hear it.

example = lib.literalExpression ''operator: "\$IPSEC_PSK_''${operator}"'';
};

availableHttpProxies = mkOption {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a warning for the deprecation using the module system in config.warnings if it is set (options should help you knowing that).

Comment thread modules/self.nix
]
++ 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`.";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: use the levenshtein calculation in nixpkgs lib to compute a suggestion of typo for the vpn name among the list of vpn profiles. This will make UX way better.

Comment thread tests/default.nix
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea! Can we move the entire thing into vpn-profiles/ though and have a test loader for directories?

vpnProfiles = {
ipsec-01 = {
type = "ipsec";
endpoint = "vpn-01.example.gouv.fr";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's replace example.gouv.fr here by .example.com, see https://www.rfc-editor.org/info/rfc6761/ why.

};
};

mkChecks =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great idea to perform such characterization tests, can we extract the absolutely generic part independent of VPN profiles to produce such tests more throughout Sécurix and instantiate the VPN profiles specialization here (and instantiate the characterization specific scenarios later in the other files)?

# Backwards-compatibility test for the legacy _module.args.vpnProfiles
# channel.
#
# NOTE: this test exists to protect a behaviour that will be deprecated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will break anyway as soon as you remove the corresponding code, so the test will automatically go away to make CI happy.

@rlahfa-dinum rlahfa-dinum left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work. I think the architectural pieces that are left pertains to the future of mk* functions in the module system form of this. I left a couple of nitpicks, but my most important concern are those. Open to proposals :).

@rlahfa-dinum rlahfa-dinum added status: awaiting-author Blocked on author's actions and removed status: awaiting-maintainers This is blocked on a maintainer's review bandwidth labels Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A/nixos Generic NixOS plumbing: NixOS modules, library functions, etc. status: awaiting-author Blocked on author's actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants