git.delta.rocks / jrsonnet / refs/commits / 5b343db89280

difftreelog

feat infer secret parts from generator

uqnzwwslYaroslav Bolyukin2025-10-27parent: #3bff084.patch.diff
in: trunk

3 files changed

modifiedcmds/install-secrets/src/main.rsdiffbeforeafterboth
--- a/cmds/install-secrets/src/main.rs
+++ b/cmds/install-secrets/src/main.rs
@@ -58,7 +58,6 @@
 	owner: String,
 	root_path: Option<PathBuf>,
 
-	#[serde(flatten)]
 	parts: BTreeMap<String, Part>,
 }
 
modifiedlib/default.nixdiffbeforeafterboth
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -54,170 +54,214 @@
 
   inherit (modules) mkFleetDefault mkFleetGeneratorDefault;
 
-  secrets = {
-    /**
-      Generate a random secret password, 32 ascii characters by default
+  secrets =
+    let
+      describedGenerator =
+        generator: {parts ? {}}:
+        {parts = {};}
+        // {
+          __functor = generator;
+        };
+    in
+    {
+      inherit describedGenerator;
 
-      Options:
-        size: generated password length in ascii characters (bytes).
-        noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
-                   not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
+      /**
+        Generate a random secret password, 32 ascii characters by default
 
-      Output:
-        Resulting secret has only part: secret, which contains encrypted password.
-    */
-    mkPassword =
-      {
-        size ? 32,
-      }:
-      {
-        coreutils,
-        mkSecretGenerator,
-      }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate password -o $out/secret --size ${toString size}
-        '';
-      };
+        Options:
+          size: generated password length in ascii characters (bytes).
+          noSymbols: by default, character set includes various special characters ($ , ! + * : ~), and might
+                     not be accepted in some contexts, this option switches charset to just [A-Za-z0-9].
 
-    /**
-      Generate a random ed25519 keypair
+        Output:
+          Resulting secret has only part: secret, which contains encrypted password.
+      */
+      mkPassword =
+        {
+          size ? 32,
+        }:
+        describedGenerator
+          (
+            {
+              coreutils,
+              mkSecretGenerator,
+            }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate password -o $out/secret --size ${toString size}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+          };
+
+      /**
+        Generate a random ed25519 keypair
 
-      Options:
-        noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)
-                       When noEmbedPublis is enabled - only the private scalar is included.
-        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
+        Options:
+          noEmbedPublic: By default, secret key also embeds public key in itself ("extended" format, 64 bytes)
+                         When noEmbedPublis is enabled - only the private scalar is included.
+          encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
 
-      Output:
-        Resulting secret has two parts: public and secret, where the secret part is encrypted.
+        Output:
+          Resulting secret has two parts: public and secret, where the secret part is encrypted.
 
-      This secret format is used by e.g Garage S3 server
-    */
-    mkEd25519 =
-      {
-        noEmbedPublic ? false,
-        encoding ? null,
-      }:
-      { mkSecretGenerator }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate ed25519 -p $out/public -s $out/secret \
-            ${optionalString noEmbedPublic "--no-embed-public"} \
-            ${optionalString (encoding != null) "--encoding=${encoding}"}
-        '';
-      };
+        This secret format is used by e.g Garage S3 server
+      */
+      mkEd25519 =
+        {
+          noEmbedPublic ? false,
+          encoding ? null,
+        }:
+        describedGenerator
+          (
+            { mkSecretGenerator }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate ed25519 -p $out/public -s $out/secret \
+                  ${optionalString noEmbedPublic "--no-embed-public"} \
+                  ${optionalString (encoding != null) "--encoding=${encoding}"}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+            parts.public.encrypted = false;
+          };
 
-    /**
-      Generate a random x25519 keypair
+      /**
+        Generate a random x25519 keypair
 
-      Options:
-        encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
+        Options:
+          encoding: Encoring of public and secret parts, can be "raw" (default), "base64" or "hex".
 
-      Output:
-        Resulting secret has two parts: public and secret, where the secret part is encrypted.
+        Output:
+          Resulting secret has two parts: public and secret, where the secret part is encrypted.
 
-      This secret format is used by e.g Wireguard VPN for peers (base64-encoded)
-    */
-    mkX25519 =
-      {
-        encoding ? null,
-      }:
-      { mkSecretGenerator }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate x25519 -p $out/public -s $out/secret \
-            ${optionalString (encoding != null) "--encoding=${encoding}"}
-        '';
-      };
+        This secret format is used by e.g Wireguard VPN for peers (base64-encoded)
+      */
+      mkX25519 =
+        {
+          encoding ? null,
+        }:
+        describedGenerator
+          (
+            { mkSecretGenerator }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate x25519 -p $out/public -s $out/secret \
+                  ${optionalString (encoding != null) "--encoding=${encoding}"}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+            parts.public.encrypted = false;
+          };
 
-    /**
-      Generate a random RSA keypair
+      /**
+        Generate a random RSA keypair
 
-      Options:
-        size: RSA key size, 4096 by default
+        Options:
+          size: RSA key size, 4096 by default
 
-      Output:
-        Resulting secret has two parts: public and secret, where the secret part is encrypted.
-        Both parts are PEM encoded.
-    */
-    mkRsa =
-      {
-        size ? 4096,
-      }:
-      {
-        openssl,
-        mkSecretGenerator,
-      }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
+        Output:
+          Resulting secret has two parts: public and secret, where the secret part is encrypted.
+          Both parts are PEM encoded.
+      */
+      mkRsa =
+        {
+          size ? 4096,
+        }:
+        describedGenerator
+          (
+            {
+              openssl,
+              mkSecretGenerator,
+            }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
 
-          ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
-          ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
+                ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
+                ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
 
-          cat rsa_private.key | gh private -o $out/secret
-          cat rsa_public.key | gh public -o $out/public
-        '';
-      };
+                cat rsa_private.key | gh private -o $out/secret
+                cat rsa_public.key | gh public -o $out/public
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+            parts.public.encrypted = false;
+          };
 
-    /**
-      Generate a random byte sequence
+      /**
+        Generate a random byte sequence
 
-      Options:
-        size: generated password length in bytes, 32 by default.
-        encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"
-        noNuls: prevent output byte sequence from containing internal \0, useful for some C applications
-                that can't handle their strings properly.
+        Options:
+          size: generated password length in bytes, 32 by default.
+          encoding: how the generated bytes should be encoded, "raw" (default), "hex" or "base64"
+          noNuls: prevent output byte sequence from containing internal \0, useful for some C applications
+                  that can't handle their strings properly.
 
-      Output:
-        Resulting secret has only part: secret, which contains encrypted bytes.
+        Output:
+          Resulting secret has only part: secret, which contains encrypted bytes.
 
-      Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)
-    */
-    mkBytes =
-      {
-        count ? 32,
-        encoding,
-        noNuls ? false,
-      }:
-      { mkSecretGenerator }:
-      mkSecretGenerator {
-        script = ''
-          mkdir $out
-          gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
-            ${optionalString noNuls "--no-nuls"}
-        '';
-      };
-    /**
-      Shorthand for `mkBytes`, which defaults to "hex" encoding
-    */
-    mkHexBytes =
-      {
-        count ? 32,
-      }:
-      mkBytes {
-        inherit count;
-        encoding = "hex";
-      };
-    /**
-      Shorthand for `mkBytes`, which defaults to "base64" encoding
-    */
-    mkBase64Bytes =
-      {
-        count ? 32,
-      }:
-      mkBytes {
-        inherit count;
-        encoding = "base64";
-      };
+        Might be used for e.g. Wireguard VPN PSK keys (base64-encoded)
+      */
+      mkBytes =
+        {
+          count ? 32,
+          encoding,
+          noNuls ? false,
+        }:
+        describedGenerator
+          (
+            { mkSecretGenerator }:
+            mkSecretGenerator {
+              script = ''
+                mkdir $out
+                gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
+                  ${optionalString noNuls "--no-nuls"}
+              '';
+            }
+          )
+          {
+            parts.secret.encrypted = true;
+          };
+      /**
+        Shorthand for `mkBytes`, which defaults to "hex" encoding
+      */
+      mkHexBytes =
+        {
+          count ? 32,
+        }:
+        mkBytes {
+          inherit count;
+          encoding = "hex";
+        };
+      /**
+        Shorthand for `mkBytes`, which defaults to "base64" encoding
+      */
+      mkBase64Bytes =
+        {
+          count ? 32,
+        }:
+        mkBytes {
+          inherit count;
+          encoding = "base64";
+        };
 
-    # Wireguard
-    # mkWireguard = {}: mkX25519 {encoding = "base64";};
-    # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
-  };
+      # Wireguard
+      # mkWireguard = {}: mkX25519 {encoding = "base64";};
+      # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
+    };
 
   inherit (secrets)
     mkPassword
modifiedmodules/nixos/secrets.nixdiffbeforeafterboth
before · modules/nixos/secrets.nix
1{2  lib,3  fleetLib,4  config,5  pkgs,6  ...7}:8let9  inherit (builtins) hashString elemAt length toJSON filter;10  inherit (lib.stringsWithDeps) stringAfter;11  inherit (lib.options) mkOption literalExpression;12  inherit (lib.lists) optional;13  inherit (lib.attrsets) mapAttrs mapAttrsToList;14  inherit (lib.modules) mkIf;15  inherit (lib.types)16    submodule17    str18    attrsOf19    nullOr20    unspecified21    lazyAttrsOf22    uniq23    functionTo24    package25    listOf26    ;27  inherit (fleetLib.strings) decodeRawSecret;2829  sysConfig = config;30  secretPartDataType = submodule {31    options = {32      raw = mkOption {33        type = str;34        internal = true;35        description = "Encoded & Encrypted secret part data, passed from fleet.nix";36      };37    };38  };39  secretDataType = submodule {40    freeformType = lazyAttrsOf secretPartDataType;41    options = {42      shared = mkOption {43        description = "Is this secret owned by this machine, or propagated from shared secrets";44        default = false;45      };46    };47  };48  secretPartType =49    secretName:50    submodule (51      { config, ... }:52      let53        partName = config._module.args.name;54      in55      {56        options = {57          hash = mkOption {58            type = str;59            description = "Hash of secret in encoded format";60          };61          path = mkOption {62            type = str;63            description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";64          };65          stablePath = mkOption {66            type = str;67            description = "Path to secret part, stable path (users are expected to watch for file changes/re-read secret on demand)";68          };69          data = mkOption {70            type = str;71            description = "Secret public data (only available for plaintext)";72          };73        };74        config =75          let76            raw = sysConfig.data.secrets.${secretName}.${partName}.raw;77          in78          {79            hash = hashString "sha1" raw;80            data = decodeRawSecret raw;81            path = "/run/secrets/${secretName}/${config.hash}-${partName}";82            stablePath = "/run/secrets/${secretName}/${partName}";83          };84      }85    );86  secretType = submodule (87    {88      config,89      loc,90      options,91      ...92    }:93    let94      secretName =95        # Due to config definition for freeformType, we can't just use _module.args due to infinite recursion, instead96        # extract the secret name the ugly way...97        let98          saLoc = options._module.specialArgs.loc;99          comp = elemAt saLoc;100        in101        assert102          (length saLoc == 2 ||103          length saLoc == 4 &&104          comp 0 == "secrets" && comp 2 == "_module" && comp 3 == "specialArgs") ||105          throw "Unexpected module structure ${toJSON saLoc}";106        if length saLoc == 2 then "documentation generator stub" else comp 1;107    in108    {109      freeformType = lazyAttrsOf (secretPartType secretName);110      options = {111        generator = mkOption {112          type = uniq (nullOr (functionTo package));113          description = "Derivation to evaluate for secret generation";114          default = null;115        };116        mode = mkOption {117          type = str;118          description = "Secret mode";119          default = "0440";120        };121        owner = mkOption {122          type = str;123          description = "Owner of the secret";124          default = "root";125        };126        group = mkOption {127          type = str;128          description = "Group of the secret";129          default = sysConfig.users.users.${config.owner}.group;130          defaultText = literalExpression "config.users.users.$${owner}.group";131        };132        expectedGenerationData = mkOption {133          type = unspecified;134          description = "Data that gets embedded into secret part";135          default = null;136        };137        expectedPrivateParts = mkOption {138          type = listOf str;139          default = [ ];140          description = "List of parts that are expected to be encrypted";141        };142        expectedPublicParts = mkOption {143          type = listOf str;144          default = [ ];145          description = "List of parts that are expected to be public";146        };147      };148      config = mapAttrs (_: _: { }) (removeAttrs (sysConfig.data.secrets.${secretName} or {}) [ "shared" ]);149    }150  );151  processPart = secretName: partName: part: {152    inherit (part) path stablePath;153    raw = config.data.secrets.${secretName}.${partName}.raw;154  };155  processSecret =156    secretName: secret:157    {158      inherit (secret) group mode owner;159    }160    // (mapAttrs (processPart secretName) (161      removeAttrs secret [162        "shared"163        "generator"164        "mode"165        "group"166        "owner"167        "expectedGenerationData"168        "expectedPrivateParts"169        "expectedPublicParts"170      ]171    ));172  secretsData = (mapAttrs (processSecret) config.secrets);173  secretsFile = pkgs.writeTextFile {174    name = "secrets.json";175    text = toJSON secretsData;176  };177  useSysusers =178    (config.systemd ? sysusers && config.systemd.sysusers.enable)179    || (config ? userborn && config.userborn.enable);180in181{182  options = {183    data.secrets = mkOption {184      type = attrsOf secretDataType;185      default = { };186      description = "Host-local secret data";187    };188    secrets = mkOption {189      type = attrsOf secretType;190      default = { };191      description = "Host-local secrets";192    };193    system.secretsData = mkOption {194      type = unspecified;195      default = {};196      description = "secrets.json contents";197    };198  };199  config = {200    system = {inherit secretsData;};201    environment.systemPackages = [ pkgs.fleet-install-secrets ];202203    warnings = filter (v: v!=null) (mapAttrsToList (204      name: secret:205      if206        secret.expectedPrivateParts == [ ]207        && secret.expectedPublicParts == [ ]208        && !(config.data.secrets.${name} or { shared = false; }).shared209      then210        "Secret ${name} has no expected parts defined, this is deprecated for better visibility"211      else212        null213    ) config.secrets);214215    systemd.services.fleet-install-secrets = mkIf useSysusers {216      wantedBy = [ "sysinit.target" ];217      after = [ "systemd-sysusers.service" ];218      restartTriggers = [219        secretsFile220      ];221      aliases = [222        "sops-install-secrets"223        "agenix-install-secrets"224      ];225226      unitConfig.DefaultDependencies = false;227228      serviceConfig = {229        Type = "oneshot";230        RemainAfterExit = true;231        ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";232      };233    };234    system.activationScripts.decryptSecrets = mkIf (!useSysusers) (235      stringAfter236        (237          [238            # secrets are owned by user/group, thus we need to refer to those239            "users"240            "groups"241            "specialfs"242          ]243          # nixos-impermanence compatibility: secrets are encrypted by host-key,244          # but with impermanence we expect that the host-key is installed by245          # persist-file activation script.246          ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")247        )248        ''249          1>&2 echo "setting up secrets"250          ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}251        ''252    );253  };254}
after · modules/nixos/secrets.nix
1{2  lib,3  fleetLib,4  config,5  pkgs,6  ...7}:8let9  inherit (builtins)10    hashString11    elemAt12    length13    toJSON14    filter15    ;16  inherit (lib.stringsWithDeps) stringAfter;17  inherit (lib.options) mkOption literalExpression;18  inherit (lib.lists) optional;19  inherit (lib.attrsets) mapAttrs mapAttrsToList;20  inherit (lib.modules) mkIf mkMerge;21  inherit (lib.types)22    submodule23    str24    attrsOf25    nullOr26    unspecified27    lazyAttrsOf28    uniq29    functionTo30    package31    listOf32    bool33    ;34  inherit (fleetLib.strings) decodeRawSecret;3536  sysConfig = config;37  secretPartDataType = submodule {38    options = {39      raw = mkOption {40        type = str;41        internal = true;42        description = "Encoded & Encrypted secret part data, passed from fleet.nix";43      };44    };45  };46  secretDataType = submodule {47    freeformType = lazyAttrsOf secretPartDataType;48    options = {49      shared = mkOption {50        description = "Is this secret owned by this machine, or propagated from shared secrets";51        default = false;52      };53    };54  };55  secretPartType =56    secretName:57    submodule (58      { config, ... }:59      let60        partName = config._module.args.name;61      in62      {63        options = {64          encrypted = mkOption {65            type = bool;66            description = "Is this secret part supposed to be encrypted?";67          };6869          hash = mkOption {70            type = str;71            description = "Hash of secret in encoded format";72          };73          path = mkOption {74            type = str;75            description = "Path to secret part, incorporating data hash (thus it will be updated on secret change)";76          };77          stablePath = mkOption {78            type = str;79            description = "Path to secret part, stable path (users are expected to watch for file changes/re-read secret on demand)";80          };81          data = mkOption {82            type = str;83            description = "Secret public data (only available for plaintext)";84          };85        };86        config =87          let88            raw = sysConfig.data.secrets.${secretName}.${partName}.raw;89          in90          {91            hash = hashString "sha1" raw;92            data = decodeRawSecret raw;93            path = "/run/secrets/${secretName}/${config.hash}-${partName}";94            stablePath = "/run/secrets/${secretName}/${partName}";95          };96      }97    );98  secretType = submodule (99    {100      config,101      ...102    }:103    let104      secretName = config._module.args.name;105    in106    {107      options = {108        parts = mkOption {109          type = lazyAttrsOf (secretPartType secretName);110          description = "Definition of secret parts";111          default = {};112        };113        generator = mkOption {114          type = uniq (nullOr (functionTo package));115          description = "Derivation to evaluate for secret generation";116          default = null;117        };118        mode = mkOption {119          type = str;120          description = "Secret mode";121          default = "0440";122        };123        owner = mkOption {124          type = str;125          description = "Owner of the secret";126          default = "root";127        };128        group = mkOption {129          type = str;130          description = "Group of the secret";131          default = sysConfig.users.users.${config.owner}.group;132          defaultText = literalExpression "config.users.users.$${owner}.group";133        };134        expectedGenerationData = mkOption {135          type = unspecified;136          description = "Data that gets embedded into secret part";137          default = null;138        };139      };140      config.parts = mkMerge [141        (mkIf (config.generator != null && config.generator ? parts) config.generator.parts)142        (mapAttrs (_: _: {}) (removeAttrs sysConfig.data.secrets.${secretName} ["shared"]))143      ];144    }145  );146  processPart = secretName: partName: part: {147    inherit (part) path stablePath;148    raw = config.data.secrets.${secretName}.${partName}.raw;149  };150  processSecret =151    secretName: secret:152    {153      inherit (secret.definition) group mode owner;154      parts = (mapAttrs (processPart secretName) (155        secret.definition.parts156      ));157    };158  secretsData = (mapAttrs (processSecret) config.secrets);159  secretsFile = pkgs.writeTextFile {160    name = "secrets.json";161    text = toJSON secretsData;162  };163  useSysusers =164    (config.systemd ? sysusers && config.systemd.sysusers.enable)165    || (config ? userborn && config.userborn.enable);166in167{168  options = {169    data.secrets = mkOption {170      type = attrsOf secretDataType;171      default = { };172      description = "Host-local secret data";173    };174    secrets = mkOption {175      type = attrsOf secretType;176      default = { };177      apply = v: (mapAttrs (_: secret: secret.parts // {definition = secret;}) v);178      description = "Host-local secrets";179    };180    system.secretsData = mkOption {181      type = unspecified;182      default = { };183      description = "secrets.json contents";184    };185  };186  config = {187    system = { inherit secretsData; };188    environment.systemPackages = [ pkgs.fleet-install-secrets ];189190    systemd.services.fleet-install-secrets = mkIf useSysusers {191      wantedBy = [ "sysinit.target" ];192      after = [ "systemd-sysusers.service" ];193      restartTriggers = [194        secretsFile195      ];196      aliases = [197        "sops-install-secrets"198        "agenix-install-secrets"199      ];200201      unitConfig.DefaultDependencies = false;202203      serviceConfig = {204        Type = "oneshot";205        RemainAfterExit = true;206        ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";207      };208    };209    system.activationScripts.decryptSecrets = mkIf (!useSysusers) (210      stringAfter211        (212          [213            # secrets are owned by user/group, thus we need to refer to those214            "users"215            "groups"216            "specialfs"217          ]218          # nixos-impermanence compatibility: secrets are encrypted by host-key,219          # but with impermanence we expect that the host-key is installed by220          # persist-file activation script.221          ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")222        )223        ''224          1>&2 echo "setting up secrets"225          ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}226        ''227    );228  };229}