diff --git a/doc/release-notes/rl-2511.section.md b/doc/release-notes/rl-2511.section.md index baa6daea1584..8522948accd4 100644 --- a/doc/release-notes/rl-2511.section.md +++ b/doc/release-notes/rl-2511.section.md @@ -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} diff --git a/nixos/modules/services/networking/yggdrasil.nix b/nixos/modules/services/networking/yggdrasil.nix index 5d1ecd7f4797..3f2f0277f59a 100644 --- a/nixos/modules/services/networking/yggdrasil.nix +++ b/nixos/modules/services/networking/yggdrasil.nix @@ -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 systemd‘s LoadCredential mechanism. For - details, see 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..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 ]; }; } diff --git a/nixos/tests/yggdrasil.nix b/nixos/tests/yggdrasil.nix index e4b15a841cf4..01a9c4cf1d8a 100644 --- a/nixos/tests/yggdrasil.nix +++ b/nixos/tests/yggdrasil.nix @@ -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}]")