Unverified Commit ebb6e6a8 authored by dotlambda's avatar dotlambda Committed by GitHub
Browse files

nixos/utils: Add support for LoadCredential= with genJqSecretsReplacementSnippet (#453185)

parents 3de3828b c01c5279
Loading
Loading
Loading
Loading
+137 −38
Original line number Diff line number Diff line
@@ -229,7 +229,7 @@ let
      listToAttrs (flatten (recurse "." item));

    /*
      Takes an attrset and a file path and generates a bash snippet that
      Takes some options, an attrset and a file path and generates a bash snippet that
      outputs a JSON file at the file path with all instances of

      { _secret = "/path/to/secret" }
@@ -237,6 +237,28 @@ let
      in the attrset replaced with the contents of the file
      "/path/to/secret" in the output JSON.

      The first argument exposes the following options:

      - attr: The name of the secret attribute that will be processed, defaults to "_secret"
      - loadCredential: A boolean determining whether the script should load secrets directly (false)
        or load them from $CREDENTIALS_DIRECTORY (true). In the latter case the output attribute set
        will contain a .credentials attribute with the necessary credential list that can be passed
        to systemd's `LoadCredential=` option.

      The output of this utility is an attribute set containing the main script and optionally
      a list of credentials:

      {
        # The main script
        script = "...";

        # If the loadCredential option was set:
        credentials = [
          "secret1:/path/to/secret1"
          #...
        ];
      }

      When a configuration option accepts an attrset that is finally
      converted to JSON, this makes it possible to let the user define
      arbitrary secret values.
@@ -245,7 +267,7 @@ let
        If the file "/path/to/secret" contains the string
        "topsecretpassword1234",

        genJqSecretsReplacementSnippet {
        genJqSecretsReplacement { } {
          example = [
            {
              irrelevant = "not interesting";
@@ -293,7 +315,7 @@ let
          { "b": "topsecretpassword5678" }
        ]

        genJqSecretsReplacementSnippet {
        genJqSecretsReplacement { } {
          example = [
            {
              irrelevant = "not interesting";
@@ -330,12 +352,12 @@ let
          ]
        }
    */
    genJqSecretsReplacementSnippet = genJqSecretsReplacementSnippet' "_secret";

    # Like genJqSecretsReplacementSnippet, but allows the name of the
    # attr which identifies the secret to be changed.
    genJqSecretsReplacementSnippet' =
      attr: set: output:
    genJqSecretsReplacement =
      {
        attr ? "_secret",
        loadCredential ? false,
      }:
      set: output:
      let
        secretsRaw = recursiveGetAttrsetWithJqPrefix set attr;
        # Set default option values
@@ -347,8 +369,23 @@ let
          // set
        ) secretsRaw;
        stringOrDefault = str: def: if str == "" then def else str;

        # Sanitize path to create a valid credential tag (same as in genLoadCredentialForJqSecretsReplacementSnippet)
        sanitizePath =
          path: lib.stringAsChars (c: if builtins.match "[a-zA-Z0-9_.#=!-]" c != null then c else "_") path;

        # Generate credential tag for a given index and path
        credentialTag = index: path: "${toString index}_${sanitizePath (secrets.${path}.${attr})}";

        credentialPath =
          index: name:
          if loadCredential then
            ''"$CREDENTIALS_DIRECTORY/${credentialTag index name}"''
          else
            "'${secrets.${name}.${attr}}'";
      in
      ''
      {
        script = ''
          if [[ -h '${output}' ]]; then
            rm '${output}'
          fi
@@ -358,8 +395,12 @@ let
          shopt -s inherit_errexit
        ''
        + concatStringsSep "\n" (
        imap1 (index: name: ''
          secret${toString index}=$(<'${secrets.${name}.${attr}}')
          imap1 (
            index: name:
            # We keep variable assignment and export separated to avoid masking the return code of the file access.
            # With `set -e` this will now fail if a file doesn't exist.
            ''
              secret${toString index}=$(<${credentialPath index name})
              export secret${toString index}
            '') (attrNames secrets)
        )
@@ -380,6 +421,64 @@ let
          (( ! inherit_errexit_enabled )) && shopt -u inherit_errexit
        '';

        /*
          Generates a list of systemd LoadCredential entries if loadCredential was set,
          otherwise returns null.

          The tag is sanitized to only contain characters a-zA-Z0-9_-.#=! and prefixed
          with an index to ensure uniqueness.

          Example:
            genLoadCredentialForJqSecretsReplacementSnippet { } {
              example = {
                secret1 = { _secret = "/path/to/secret"; };
                secret2 = { _secret = "/another/secret"; };
              };
            }
            -> [ "0_path_to_secret:/path/to/secret" "1_another_secret:/another/secret" ]
        */
        credentials =
          if loadCredential then
            imap1 (
              index: path:
              "${toString index}_${sanitizePath (secretsRaw.${path}.${attr})}:${secretsRaw.${path}.${attr}}"
            ) (attrNames secretsRaw)
          else
            null;
      };

    /*
      A convenience function around `genJqSecretsReplacement` without any additional
      settings that returns just the script that does the secret replacing. Make sure
      to have a look at `genJqSecretsReplacement` first to decide whether you need
      the additional functionality.

      Example:
        If the file "/path/to/secret" contains the string
        "topsecretpassword1234",

        genJqSecretsReplacementSnippet {
          example = [
            {
              irrelevant = "not interesting";
            }
            {
              ignored = "ignored attr";
              relevant = {
                secret = {
                  _secret = "/path/to/secret";
                };
              };
            }
          ];
        } "/path/to/output.json"

        will return a set of bash commands that replaces the secret values
        in the given attrset with values from the respective files and saves the result
        as a JSON file.
    */
    genJqSecretsReplacementSnippet = set: output: (genJqSecretsReplacement { } set output).script;

    /*
      Remove packages of packagesToRemove from packages, based on their names.
      Relies on package names and has quadratic complexity so use with caution!
+23 −41
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@
  config,
  lib,
  pkgs,
  utils,
  ...
}:
let
@@ -9,10 +10,9 @@ let
  format = pkgs.formats.json { };
  isPostgresUnixSocket = lib.hasPrefix "/" cfg.database.host;
  isRedisUnixSocket = lib.hasPrefix "/" cfg.redis.host;

  # convert a Nix attribute path to jq object identifier-index:
  # https://jqlang.org/manual/#object-identifier-index
  attrPathToIndex = attrPath: "." + lib.concatStringsSep "." attrPath;
  secretsReplacement = utils.genJqSecretsReplacement {
    loadCredential = true;
  } cfg.settings "/run/immich/config.json";

  commonServiceConfig = {
    Type = "simple";
@@ -55,6 +55,22 @@ let
    if cfg.database.enable then config.services.postgresql.package else pkgs.postgresql;
in
{
  imports = [
    (lib.mkRemovedOptionModule
      [
        "services"
        "immich"
        "secretSettings"
      ]
      ''
        `secretSettings` has been deprecated as secrets can now be specified
        directly in `settings`. To do so, set `_secret` of the desired
        attribute to a file path, for example:
          `services.immich.settings.oauth.clientSecret._secret = "/path/to/secret/file";`
      ''
    )
  ];

  options.services.immich = {
    enable = mkEnableOption "Immich";
    package = lib.mkPackageOption pkgs "immich" { };
@@ -128,6 +144,7 @@ in
        <https://my.immich.app/admin/system-settings> for
        options and defaults.
        Setting it to `null` allows configuring Immich in the web interface.
        You can load secret values from a file in this configuration by setting `somevalue._secret = "/path/to/file"` instead of setting `somevalue` directly.
      '';
      type = types.nullOr (
        types.submodule {
@@ -151,27 +168,6 @@ in
      );
    };

    secretSettings = mkOption {
      default = { };
      description = ''
        Secrets to to be added to the JSON file generated from {option}`settings`, read from files.
      '';
      example = lib.literalExpression ''
        {
          notifications.smtp.transport.password = "/path/to/secret";
          oauth.clientSecret = "/path/to/other/secret";
        }
      '';
      type =
        let
          inherit (types) attrsOf either path;
          recursiveType = either (attrsOf recursiveType) path // {
            description = "nested " + (attrsOf path).description;
          };
        in
        recursiveType;
    };

    machine-learning = {
      enable =
        mkEnableOption "immich's machine-learning functionality to detect faces and search for objects"
@@ -424,24 +420,10 @@ in
        postgresqlPackage
      ];

      preStart = mkIf (cfg.settings != null) (
        ''
          cat '${format.generate "immich-config.json" cfg.settings}' > /run/immich/config.json
        ''
        + lib.concatStrings (
          lib.mapAttrsToListRecursive (attrPath: _: ''
            tmp="$(mktemp)"
            ${lib.getExe pkgs.jq} --rawfile secret "$CREDENTIALS_DIRECTORY/${attrPathToIndex attrPath}" \
              '${attrPathToIndex attrPath} = ($secret | rtrimstr("\n"))' /run/immich/config.json > "$tmp"
            mv "$tmp" /run/immich/config.json
          '') cfg.secretSettings
        )
      );
      preStart = mkIf (cfg.settings != null) secretsReplacement.script;

      serviceConfig = commonServiceConfig // {
        LoadCredential = lib.mapAttrsToListRecursive (
          attrPath: file: "${attrPathToIndex attrPath}:${file}"
        ) cfg.secretSettings;
        LoadCredential = secretsReplacement.credentials;
        ExecStart = lib.getExe cfg.package;
        EnvironmentFile = mkIf (cfg.secretsFile != null) cfg.secretsFile;
        Slice = "system-immich.slice";
+7 −7
Original line number Diff line number Diff line
@@ -17,14 +17,14 @@
      services.immich = {
        enable = true;
        environment.IMMICH_LOG_LEVEL = "verbose";
        settings.backup.database = {
        settings = {
          backup.database = {
            enabled = true;
          cronExpression = "invalid";
            # Test loading secrets from files:
            cronExpression._secret = "${pkgs.writeText "cron" "0 02 * * *"}";
          };
        secretSettings = {
          backup.database.cronExpression = "${pkgs.writeText "cron" "0 02 * * *"}";
          # thanks to LoadCredential files only readable by root should work
          notifications.smtp.transport.password = "/etc/shadow";
          notifications.smtp.transport.password._secret = "/etc/shadow";
        };
      };