diff --git a/nixos/lib/utils.nix b/nixos/lib/utils.nix index 35f5853e3f66..08678f5a6d00 100644 --- a/nixos/lib/utils.nix +++ b/nixos/lib/utils.nix @@ -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,38 +369,115 @@ let // set ) secretsRaw; stringOrDefault = str: def: if str == "" then def else str; - in - '' - if [[ -h '${output}' ]]; then - rm '${output}' - fi - inherit_errexit_enabled=0 - shopt -pq inherit_errexit && inherit_errexit_enabled=1 - shopt -s inherit_errexit - '' - + concatStringsSep "\n" ( - imap1 (index: name: '' - secret${toString index}=$(<'${secrets.${name}.${attr}}') - export secret${toString index} - '') (attrNames secrets) - ) - + "\n" - + "${pkgs.jq}/bin/jq >'${output}' " - + escapeShellArg ( - stringOrDefault (concatStringsSep " | " ( + # 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 + + inherit_errexit_enabled=0 + shopt -pq inherit_errexit && inherit_errexit_enabled=1 + shopt -s inherit_errexit + '' + + concatStringsSep "\n" ( imap1 ( index: name: - ''${name} = ($ENV.secret${toString index}${optionalString (!secrets.${name}.quote) " | fromjson"})'' - ) (attrNames secrets) - )) "." - ) - + '' - <<'EOF' - ${toJSON set} - EOF - (( ! inherit_errexit_enabled )) && shopt -u inherit_errexit - ''; + # 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) + ) + + "\n" + + "${pkgs.jq}/bin/jq >'${output}' " + + escapeShellArg ( + stringOrDefault (concatStringsSep " | " ( + imap1 ( + index: name: + ''${name} = ($ENV.secret${toString index}${optionalString (!secrets.${name}.quote) " | fromjson"})'' + ) (attrNames secrets) + )) "." + ) + + '' + <<'EOF' + ${toJSON set} + EOF + (( ! 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. diff --git a/nixos/modules/services/web-apps/immich.nix b/nixos/modules/services/web-apps/immich.nix index 23017629a329..7dff3515a268 100644 --- a/nixos/modules/services/web-apps/immich.nix +++ b/nixos/modules/services/web-apps/immich.nix @@ -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 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"; diff --git a/nixos/tests/web-apps/immich.nix b/nixos/tests/web-apps/immich.nix index fddc681620f1..406b51600c4e 100644 --- a/nixos/tests/web-apps/immich.nix +++ b/nixos/tests/web-apps/immich.nix @@ -17,14 +17,14 @@ services.immich = { enable = true; environment.IMMICH_LOG_LEVEL = "verbose"; - settings.backup.database = { - enabled = true; - cronExpression = "invalid"; - }; - secretSettings = { - backup.database.cronExpression = "${pkgs.writeText "cron" "0 02 * * *"}"; + settings = { + backup.database = { + enabled = true; + # Test loading secrets from files: + cronExpression._secret = "${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"; }; };