nixos/yggdrasil: refactor (#444604)

This commit is contained in:
Florian Klink
2026-01-03 17:44:09 +00:00
committed by GitHub
3 changed files with 297 additions and 87 deletions
+7
View File
@@ -318,6 +318,13 @@ and [release notes for v18](https://goteleport.com/docs/changelog/#1800-070325).
- `zig_0_12` has been removed.
- The `services.yggdrasil` module has been refactored with the following breaking changes:
- The `services.yggdrasil.configFile` option has been removed. Configuration should now be specified directly via `services.yggdrasil.settings`.
- The `services.yggdrasil.persistentKeys` option has been removed. To maintain persistent keys and IPv6 addresses across reboots, use `services.yggdrasil.settings.PrivateKeyPath` to securely load your private key from a file via systemd credentials. The private key must be in PEM format (PKCS #8).
- Storing `PrivateKey` directly in `settings` is now explicitly forbidden to prevent keys from being stored world-readable in the Nix store.
- If you previously used `configFile`, migrate your configuration to the `settings` option and extract the private key to a separate file referenced by `PrivateKeyPath`.
- If you previously used `persistentKeys`, convert your keys to PEM format and store them in a secure location accessible only to root, then reference them via `PrivateKeyPath`.
- `zigbee2mqtt` was updated to version 2.x, which contains breaking changes. See the [discussion](https://github.com/Koenkk/zigbee2mqtt/discussions/24198) for further information.
## Other Notable Changes {#sec-nixpkgs-release-25.11-notable-changes}
+195 -87
View File
@@ -13,14 +13,47 @@ let
bool
listOf
str
attrs
submodule
;
keysPath = "/var/lib/yggdrasil/keys.json";
cfg = config.services.yggdrasil;
settingsProvided = cfg.settings != { };
configFileProvided = cfg.configFile != null;
format = pkgs.formats.json { };
# Paths for persistent keys
stateDir = "/var/lib/yggdrasil";
persistentKeyPath = "${stateDir}/private.pem";
legacyKeysPath = "${stateDir}/keys.json";
# Determine which key path to use:
# 1. If PrivateKeyPath is explicitly set, use that
# 2. If persistentKeys is enabled, use the auto-generated key path
effectiveKeyPath =
if cfg.settings.PrivateKeyPath != null then
cfg.settings.PrivateKeyPath
else if cfg.persistentKeys then
persistentKeyPath
else
null;
# Build base configuration with systemd credential path override
baseSettings =
cfg.settings
// (
if effectiveKeyPath != null then
{
PrivateKeyPath = "/private-key";
}
else
{ }
);
# Remove null values that yggdrasil doesn't expect
cleanSettings = lib.filterAttrs (n: v: v != null) baseSettings;
# Generate configuration file from user settings
configFile = pkgs.writeTextFile {
name = "yggdrasil.conf";
text = builtins.toJSON cleanSettings;
};
in
{
imports = [
@@ -35,7 +68,59 @@ in
enable = lib.mkEnableOption "the yggdrasil system service";
settings = mkOption {
type = format.type;
type = submodule {
freeformType = attrs;
options = {
PrivateKeyPath = mkOption {
type = nullOr path;
default = null;
example = "/run/secrets/yggdrasil-private-key";
description = ''
Path to the private key file on the host system.
When specified, the key will be loaded via systemd credentials
for secure access by the yggdrasil service.
Warning: Do not put private keys directly in the Nix store
as they would be world-readable!
'';
};
Peers = mkOption {
type = listOf str;
default = [ ];
example = [
"tcp://aa.bb.cc.dd:eeeee"
"tcp://[aaaa:bbbb:cccc:dddd::eeee]:fffff"
];
description = ''
List of outbound peer connection strings.
Connection strings can contain options, see the yggdrasil documentation.
'';
};
Listen = mkOption {
type = listOf str;
default = [ ];
example = [
"tcp://0.0.0.0:xxxxx"
"tls://[::]:yyyyy"
];
description = ''
Listen addresses for incoming connections.
You need listeners to accept incoming peerings from non-local nodes.
'';
};
AllowedPublicKeys = mkOption {
type = listOf str;
default = [ ];
description = ''
List of peer public keys to allow incoming peering connections from.
If left empty, all connections are allowed by default.
'';
};
};
};
default = { };
example = {
Peers = [
@@ -45,47 +130,35 @@ in
Listen = [
"tcp://0.0.0.0:xxxxx"
];
PrivateKeyPath = "/run/secrets/yggdrasil-key";
IfName = "ygg0";
IfMTU = 65535;
};
description = ''
Configuration for yggdrasil, as a Nix attribute set.
Configuration for yggdrasil, as a structured Nix attribute set.
Warning: this is stored in the WORLD-READABLE Nix store!
Therefore, it is not appropriate for private keys. If you
wish to specify the keys, use {option}`configFile`.
If you specify settings here, they will be used as persistent
configuration and Yggdrasil will retain the same configuration
(including IPv6 address if keys are provided) across restarts.
If the {option}`persistentKeys` is enabled then the
keys that are generated during activation will override
those in {option}`settings` or
{option}`configFile`.
If no keys are specified then ephemeral keys are generated
If no settings are specified, ephemeral keys are generated
and the Yggdrasil interface will have a random IPv6 address
each time the service is started. This is the default.
each time the service is started.
If both {option}`configFile` and {option}`settings`
are supplied, they will be combined, with values from
{option}`configFile` taking precedence.
Use {option}`settings.PrivateKeyPath` to securely load private
keys from files owned by root via systemd credentials.
The most important options have dedicated NixOS options above.
You can also specify any other yggdrasil configuration option directly.
For a complete list of available options, see:
https://yggdrasil-network.github.io/configurationref.html
You can use the command `nix-shell -p yggdrasil --run "yggdrasil -genconf"`
to generate default configuration values with documentation.
'';
};
configFile = mkOption {
type = nullOr path;
default = null;
example = "/run/keys/yggdrasil.conf";
description = ''
A file which contains JSON or HJSON configuration for yggdrasil. See
the {option}`settings` option for more information.
Note: This file must not be larger than 1 MB because it is passed to
the yggdrasil process via systemds LoadCredential mechanism. For
details, see <https://systemd.io/CREDENTIALS/> and `man 5
systemd.exec`.
'';
};
group = mkOption {
type = nullOr str;
default = null;
@@ -101,7 +174,7 @@ in
NixOS firewall blocks link-local communication, so in order to make
incoming local peering work you will also need to configure
`MulticastInterfaces` in your Yggdrasil configuration
({option}`settings` or {option}`configFile`). You will then have to
({option}`settings`). You will then have to
add the ports that you configure there to your firewall configuration
({option}`networking.firewall.allowedTCPPorts` or
{option}`networking.firewall.interfaces.<name>.allowedTCPPorts`).
@@ -125,9 +198,18 @@ in
package = lib.mkPackageOption pkgs "yggdrasil" { };
persistentKeys = lib.mkEnableOption ''
persistent keys. If enabled then keys will be generated once and Yggdrasil
will retain the same IPv6 address when the service is
restarted. Keys are stored at ${keysPath}
automatic generation and persistence of keys.
If enabled, a private key will be generated on first startup and stored
at ${persistentKeyPath}. This ensures the Yggdrasil node retains the same
IPv6 address across reboots.
If you have existing keys from a previous installation (in the old
keys.json format at ${legacyKeysPath}), they will be automatically
migrated to the new PEM format on first startup.
Note: This option is mutually exclusive with {option}`settings.PrivateKeyPath`.
If you want to use externally managed keys, use {option}`settings.PrivateKeyPath`
instead
'';
extraArgs = mkOption {
@@ -146,7 +228,6 @@ in
config = mkIf cfg.enable (
let
binYggdrasil = "${cfg.package}/bin/yggdrasil";
binHjson = "${pkgs.hjson-go}/bin/hjson-cli";
in
{
assertions = [
@@ -154,67 +235,93 @@ in
assertion = config.networking.enableIPv6;
message = "networking.enableIPv6 must be true for yggdrasil to work";
}
{
assertion = !(cfg.settings ? PrivateKey);
message = ''
services.yggdrasil.settings.PrivateKey is not supported because it
would be stored in the world-readable Nix store.
Use services.yggdrasil.settings.PrivateKeyPath instead to securely load the private key from a file.
'';
}
{
assertion = !(cfg.persistentKeys && cfg.settings.PrivateKeyPath != null);
message = ''
services.yggdrasil.persistentKeys and services.yggdrasil.settings.PrivateKeyPath
are mutually exclusive. Use only one of them.
'';
}
];
# This needs to be a separate service. The yggdrasil service fails if
# this is put into its preStart.
# One-shot service to generate or migrate persistent keys
systemd.services.yggdrasil-persistent-keys = lib.mkIf cfg.persistentKeys {
description = "Generate or migrate Yggdrasil persistent keys";
wantedBy = [ "multi-user.target" ];
before = [ "yggdrasil.service" ];
serviceConfig.Type = "oneshot";
serviceConfig.RemainAfterExit = true;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
path = [
cfg.package
pkgs.jq
];
script = ''
if [ ! -e ${keysPath} ]
then
mkdir --mode=700 -p ${builtins.dirOf keysPath}
${binYggdrasil} -genconf -json \
| ${pkgs.jq}/bin/jq \
'to_entries|map(select(.key|endswith("Key")))|from_entries' \
> ${keysPath}
set -euo pipefail
# Create state directory with secure permissions
mkdir -p ${stateDir}
chmod 700 ${stateDir}
# If new format key already exists, nothing to do
if [ -f ${persistentKeyPath} ]; then
echo "Persistent key already exists at ${persistentKeyPath}"
exit 0
fi
# Check for legacy keys.json and migrate if found
if [ -f ${legacyKeysPath} ]; then
echo "Found legacy keys at ${legacyKeysPath}, migrating to PEM format..."
# Extract the PrivateKey from the legacy JSON format
PRIVATE_KEY_HEX=$(jq -r '.PrivateKey' ${legacyKeysPath})
if [ -n "$PRIVATE_KEY_HEX" ] && [ "$PRIVATE_KEY_HEX" != "null" ]; then
# Use yggdrasil's built-in -exportkey flag to convert to PEM format
# Create a minimal config with just the private key
echo "{\"PrivateKey\": \"$PRIVATE_KEY_HEX\"}" | yggdrasil -useconf -exportkey > ${persistentKeyPath}
chmod 600 ${persistentKeyPath}
echo "Successfully migrated legacy keys to ${persistentKeyPath}"
echo "You may remove the legacy file ${legacyKeysPath} after verifying the migration"
exit 0
fi
fi
# No existing keys found, generate new ones using yggdrasil
echo "Generating new persistent key at ${persistentKeyPath}..."
yggdrasil -genconf | yggdrasil -useconf -exportkey > ${persistentKeyPath}
chmod 600 ${persistentKeyPath}
echo "Successfully generated new persistent key"
'';
};
systemd.services.yggdrasil = {
description = "Yggdrasil Network Service";
after = [ "network-pre.target" ];
after = [
"network-pre.target"
]
++ lib.optional cfg.persistentKeys "yggdrasil-persistent-keys.service";
wants = [ "network.target" ];
before = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
# This script first prepares the config file, then it starts Yggdrasil.
# The preparation could also be done in ExecStartPre/preStart but only
# systemd versions >= v252 support reading credentials in ExecStartPre. As
# of February 2023, systemd v252 is not yet in the stable branch of NixOS.
#
# This could be changed in the future once systemd version v252 has
# reached NixOS but it does not have to be. Config file preparation is
# fast enough, it does not need elevated privileges, and `set -euo
# pipefail` should make sure that the service is not started if the
# preparation fails. Therefore, it is not necessary to move the
# preparation to ExecStartPre.
script = ''
set -euo pipefail
# prepare config file
${
(
if settingsProvided || configFileProvided || cfg.persistentKeys then
"echo "
+ (lib.optionalString settingsProvided "'${builtins.toJSON cfg.settings}'")
+ (lib.optionalString configFileProvided "$(${binHjson} -c \"$CREDENTIALS_DIRECTORY/yggdrasil.conf\")")
+ (lib.optionalString cfg.persistentKeys "$(cat ${keysPath})")
+ " | ${pkgs.jq}/bin/jq -s add | ${binYggdrasil} -normaliseconf -useconf"
else
"${binYggdrasil} -genconf"
)
+ " > /run/yggdrasil/yggdrasil.conf"
}
# start yggdrasil
exec ${binYggdrasil} -useconffile /run/yggdrasil/yggdrasil.conf ${lib.strings.escapeShellArgs cfg.extraArgs}
'';
script =
if cfg.settings != { } || cfg.persistentKeys then
# Use user settings or persistent keys configuration
"exec ${binYggdrasil} -useconffile ${configFile} ${lib.strings.escapeShellArgs cfg.extraArgs}"
else
# Generate and use ephemeral config
"exec ${binYggdrasil} -genconf | ${binYggdrasil} -useconf ${lib.strings.escapeShellArgs cfg.extraArgs}";
serviceConfig = {
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
@@ -224,8 +331,8 @@ in
StateDirectory = "yggdrasil";
RuntimeDirectory = "yggdrasil";
RuntimeDirectoryMode = "0750";
BindReadOnlyPaths = lib.optional cfg.persistentKeys keysPath;
LoadCredential = mkIf configFileProvided "yggdrasil.conf:${cfg.configFile}";
BindReadOnlyPaths = lib.optional (effectiveKeyPath != null) "%d/private-key:/private-key";
LoadCredential = lib.optional (effectiveKeyPath != null) "private-key:${effectiveKeyPath}";
AmbientCapabilities = "CAP_NET_ADMIN CAP_NET_BIND_SERVICE";
CapabilityBoundingSet = "CAP_NET_ADMIN CAP_NET_BIND_SERVICE";
@@ -265,6 +372,7 @@ in
maintainers = with lib.maintainers; [
gazally
nagy
pinpox
];
};
}
+95
View File
@@ -4,6 +4,16 @@ let
PublicKey = "3e91ec9e861960d86e1ce88051f97c435bdf2859640ab681dfa906eb45ad5182";
PrivateKey = "a867f9e078e4ce58d310cf5acd4622d759e2a21df07e1d6fc380a2a26489480d3e91ec9e861960d86e1ce88051f97c435bdf2859640ab681dfa906eb45ad5182";
};
# Frank has a legacy keys.json that should be migrated
# This is the same key as Alice but in the old hex format
frankIp6 = aliceIp6; # Should get same IP after migration
frankLegacyKeys = {
# The old format stored PrivateKey as 128 hex chars (64 bytes = seed + pubkey)
# This corresponds to Alice's key
PrivateKey =
"a867f9e078e4ce58d310cf5acd4622d759e2a21df07e1d6fc380a2a264894809" + aliceKeys.PublicKey;
PublicKey = aliceKeys.PublicKey;
};
bobIp6 = "202:a483:73a4:9f2d:a559:4a19:bc9:8458";
bobPrefix = "302:a483:73a4:9f2d";
bobConfig = {
@@ -153,6 +163,62 @@ in
persistentKeys = true;
};
};
# Eve uses persistentKeys for automatic key generation.
eve =
{ ... }:
{
networking.firewall.allowedTCPPorts = [ 43211 ];
services.yggdrasil = {
enable = true;
persistentKeys = true;
openMulticastPort = true;
settings = {
IfName = "ygg0";
MulticastInterfaces = [
{
Regex = ".*";
Beacon = true;
Listen = true;
Port = 43211;
}
];
};
};
};
# Frank tests migration from legacy keys.json format
frank =
{ pkgs, ... }:
{
networking.firewall.allowedTCPPorts = [ 43212 ];
# Pre-populate the legacy keys.json file before the service starts
system.activationScripts.yggdrasil-legacy-keys = ''
mkdir -p /var/lib/yggdrasil
cat > /var/lib/yggdrasil/keys.json << 'EOF'
${builtins.toJSON frankLegacyKeys}
EOF
chmod 600 /var/lib/yggdrasil/keys.json
'';
services.yggdrasil = {
enable = true;
persistentKeys = true;
openMulticastPort = true;
settings = {
IfName = "ygg0";
MulticastInterfaces = [
{
Regex = ".*";
Beacon = true;
Listen = true;
Port = 43212;
}
];
};
};
};
};
testScript = ''
@@ -164,13 +230,38 @@ in
bob.start()
carol.start()
eve.start()
frank.start()
bob.wait_for_unit("default.target")
carol.wait_for_unit("yggdrasil.service")
# Eve uses persistentKeys - verify the key generation service ran
eve.wait_for_unit("yggdrasil-persistent-keys.service")
eve.wait_for_unit("yggdrasil.service")
eve.succeed("test -f /var/lib/yggdrasil/private.pem")
eve.succeed("grep -q 'BEGIN PRIVATE KEY' /var/lib/yggdrasil/private.pem")
# Frank tests migration from legacy keys.json format
frank.wait_for_unit("yggdrasil-persistent-keys.service")
frank.wait_for_unit("yggdrasil.service")
# Verify migration happened: private.pem should exist
frank.succeed("test -f /var/lib/yggdrasil/private.pem")
frank.succeed("grep -q 'BEGIN PRIVATE KEY' /var/lib/yggdrasil/private.pem")
# Legacy file should still exist (not deleted, user should verify and remove)
frank.succeed("test -f /var/lib/yggdrasil/keys.json")
ip_addr_show = "ip -o -6 addr show dev ygg0 scope global"
carol.wait_until_succeeds(f"[ `{ip_addr_show} | grep -v tentative | wc -l` -ge 1 ]")
carol_ip6 = re.split(" +|/", carol.succeed(ip_addr_show))[3]
eve.wait_until_succeeds(f"[ `{ip_addr_show} | grep -v tentative | wc -l` -ge 1 ]")
eve_ip6 = re.split(" +|/", eve.succeed(ip_addr_show))[3]
# Verify Frank got the expected IP after migration (same key as Alice = same IP)
frank.wait_until_succeeds(f"[ `{ip_addr_show} | grep -v tentative | wc -l` -ge 1 ]")
frank_ip6 = re.split(" +|/", frank.succeed(ip_addr_show))[3]
assert frank_ip6 == "${frankIp6}", f"Frank's IP {frank_ip6} doesn't match expected ${frankIp6} after migration"
# If Alice can talk to Carol, then Bob's outbound peering and Carol's
# local peering have succeeded and everybody is connected.
alice.wait_until_succeeds(f"ping -c 1 {carol_ip6}")
@@ -186,6 +277,10 @@ in
carol.fail("journalctl -u dhcpcd | grep ygg0")
# Eve should be able to communicate with the network via multicast peering
eve.wait_until_succeeds(f"ping -c 1 {carol_ip6}")
carol.wait_until_succeeds(f"ping -c 1 {eve_ip6}")
alice.wait_for_unit("httpd.service")
carol.succeed("curl --fail -g http://[${aliceIp6}]")
carol.succeed("curl --fail -g http://[${danIp6}]")