Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
10 changes: 9 additions & 1 deletion lib/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ rec {
{
name,
userSpecificModule,
vpnProfiles,
vpnProfiles ? { },
extraOperators ? { },
modules,
edition ? args.edition,
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions modules/self.nix
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# SPDX-License-Identifier: MIT

{
vpnProfiles,
pkgs,
config,
lib,
Expand Down Expand Up @@ -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" ];
Expand Down Expand Up @@ -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`.";

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.

}) cfg.user.allowedVPNs;
services.getty.helpLine = optionalString isMachineConfig ''
Bienvenue sur Sécurix (identifiant ${toString machineIdentifier}).
${optionalString isUserConfig "Utilisateur principal: ${toString cfg.user.email}."}
Expand Down
1 change: 1 addition & 0 deletions modules/vpn/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
imports = [
./ipsec
./netbird
./profiles
./wireguard
];
}
2 changes: 1 addition & 1 deletion modules/vpn/ipsec/networkmanager.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

{
pkgs,
vpnProfiles,
operators,
config,
lib,
...
}:
let
vpnProfiles = config.securix.vpn.profiles;
cfg = config.securix.vpn.ipsec;
inherit (lib)
mkIf
Expand Down
2 changes: 1 addition & 1 deletion modules/vpn/netbird/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
# SPDX-License-Identifier: MIT

{
vpnProfiles,
operators,
pkgs,
lib,
config,
...
}:
let
vpnProfiles = config.securix.vpn.profiles;
cfg = config.securix.vpn.netbird;
inherit (lib)
mkIf
Expand Down
107 changes: 107 additions & 0 deletions modules/vpn/profiles/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# SPDX-FileCopyrightText: 2026 Mattias Kockum <mattias@kockum.net>
#
# 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

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

# 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 {

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.

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 {

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.

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 = [

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.

"endpoint"
"esp"
"ike"
"localSubnet"
"method"
"remoteSubnets"
];
netbird = [
"admin-url"
"management-url"
];
wireguard = [
"agePivSlot"
"interface"
"listenPort"
"mkAddress"
"peers"
"wireguardPivSlot"
];
};
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.

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 (

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.

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
);
}
126 changes: 126 additions & 0 deletions modules/vpn/profiles/ipsec.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# SPDX-FileCopyrightText: 2026 Mattias Kockum <mattias@kockum.net>
#
# 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`.";

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 ..."

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.

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.

'';
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.

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.

'';
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

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.

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 {

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.

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 {

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).

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.
'';
};
};
}
30 changes: 30 additions & 0 deletions modules/vpn/profiles/netbird.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-FileCopyrightText: 2026 Mattias Kockum <mattias@kockum.net>
#
# 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";
};
};
}
Loading
Loading