Unverified Commit 26faf129 authored by Florian Klink's avatar Florian Klink Committed by GitHub
Browse files

nixos/yggdrasil: refactor (#444604)

parents 6ded26a6 5f0e96d0
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -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
Original line number Diff line number Diff line
@@ -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,44 +130,32 @@ 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.

          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`.
          Configuration for yggdrasil, as a structured Nix attribute set.

          If the {option}`persistentKeys` is enabled then the
          keys that are generated during activation will override
          those in {option}`settings` or
          {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 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.

          You can use the command `nix-shell -p yggdrasil --run "yggdrasil -genconf"`
          to generate default configuration values with documentation.
        '';
      };
          The most important options have dedicated NixOS options above.
          You can also specify any other yggdrasil configuration option directly.

      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.
          For a complete list of available options, see:
          https://yggdrasil-network.github.io/configurationref.html

          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 <https://systemd.io/CREDENTIALS/> and `man 5
          systemd.exec`.
          You can use the command `nix-shell -p yggdrasil --run "yggdrasil -genconf"`
          to generate default configuration values with documentation.
        '';
      };

@@ -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"
        script =
          if cfg.settings != { } || cfg.persistentKeys then
            # Use user settings or persistent keys configuration
            "exec ${binYggdrasil} -useconffile ${configFile} ${lib.strings.escapeShellArgs cfg.extraArgs}"
          else
                "${binYggdrasil} -genconf"
            )
            + " > /run/yggdrasil/yggdrasil.conf"
          }

          # start yggdrasil
          exec ${binYggdrasil} -useconffile /run/yggdrasil/yggdrasil.conf ${lib.strings.escapeShellArgs cfg.extraArgs}
        '';
            # 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 −0
Original line number Diff line number Diff line
@@ -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}]")