-
Notifications
You must be signed in to change notification settings - Fork 50
feat(vpn): move VPN profiles from _module.args to a NixOS option #259
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c1e90b4
f9f2550
a7e9e6e
ef80761
a47829b
757f02d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ | |
| imports = [ | ||
| ./ipsec | ||
| ./netbird | ||
| ./profiles | ||
| ./wireguard | ||
| ]; | ||
| } | ||
| 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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| # 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 { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 This way, each |
||||||
| 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 { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This option should be a backward compatibility system or so. In the NixOS module system, we have access to 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 = [ | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||||||
| "endpoint" | ||||||
| "esp" | ||||||
| "ike" | ||||||
| "localSubnet" | ||||||
| "method" | ||||||
| "remoteSubnets" | ||||||
| ]; | ||||||
| netbird = [ | ||||||
| "admin-url" | ||||||
| "management-url" | ||||||
| ]; | ||||||
| wireguard = [ | ||||||
| "agePivSlot" | ||||||
| "interface" | ||||||
| "listenPort" | ||||||
| "mkAddress" | ||||||
| "peers" | ||||||
| "wireguardPivSlot" | ||||||
| ]; | ||||||
| }; | ||||||
| in | ||||||
| { | ||||||
| options.securix.vpn.profiles = mkOption { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||
| ); | ||||||
| } | ||||||
| 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`."; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's still get rid of |
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add a warning for the deprecation using the module system in |
||
| 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. | ||
| ''; | ||
| }; | ||
| }; | ||
| } | ||
| 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"; | ||
| }; | ||
| }; | ||
| } |
There was a problem hiding this comment.
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
libto compute a suggestion of typo for the vpn name among the list of vpn profiles. This will make UX way better.