From fa87855ee574aac4247b0d1aa24146a1fb2b7176 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Sun, 13 Oct 2024 21:43:49 +0200 Subject: [PATCH] switch jellyfin to new secrets contract This rabbit hole of a task lead me to: - Introduce a hardcoded secret module that is a secret provider for tests. - Update LDAP and SSO modules to use the secret contract. - Refactor the replaceSecrets library function to correctly fail when a secret file could not be read. --- flake.nix | 1 + lib/default.nix | 37 ++++++++----- modules/blocks/hardcodedsecret.nix | 84 ++++++++++++++++++++++++++++++ modules/services/jellyfin.nix | 49 +++++++++++------ test/common.nix | 18 ++++--- test/services/jellyfin.nix | 17 +++++- 6 files changed, 168 insertions(+), 38 deletions(-) create mode 100644 modules/blocks/hardcodedsecret.nix diff --git a/flake.nix b/flake.nix index 9a07658..3409529 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,7 @@ allModules = [ modules/blocks/authelia.nix modules/blocks/davfs.nix + modules/blocks/hardcodedsecret.nix modules/blocks/ldap.nix modules/blocks/monitoring.nix modules/blocks/nginx.nix diff --git a/lib/default.nix b/lib/default.nix index d55debf..9e9b004 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,7 +1,7 @@ { pkgs, lib }: let inherit (builtins) isAttrs hasAttr; - inherit (lib) concatStringsSep; + inherit (lib) concatMapStringsSep concatStringsSep mapAttrsToList; in rec { # Replace secrets in a file. @@ -34,14 +34,30 @@ rec { replaceSecretsScript = { file, resultPath, replacements, user ? null, permissions ? "u=r,g=r,o=" }: let templatePath = resultPath + ".template"; - sedPatterns = lib.strings.concatStringsSep " " (lib.attrsets.mapAttrsToList (from: to: "-e \"s|${from}|${to}|\"") replacements); - sedCmd = if replacements == {} + + t = { transform ? null, ... }: if isNull transform then x: x else transform; + + genReplacement = secret: + lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})"); + + # We check that the files containing the secrets have the + # correct permissions for us to read them in this separate + # step. Otherwise, the $(cat ...) commands inside the sed + # replacements could fail but not fail individually but + # not fail the whole script. + checkPermissions = concatMapStringsSep "\n" (pattern: "cat ${pattern.source} > /dev/null") replacements; + + sedPatterns = concatMapStringsSep " " (pattern: "-e \"s|${pattern.name}|${pattern.value}|\"") (map genReplacement replacements); + + sedCmd = if replacements == [] then "cat" else "${pkgs.gnused}/bin/sed ${sedPatterns}"; in '' set -euo pipefail + ${checkPermissions} + mkdir -p $(dirname ${templatePath}) ln -fs ${file} ${templatePath} rm -f ${resultPath} @@ -71,8 +87,8 @@ rec { }; }; - secretName = name: - "%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) name)}%"; + secretName = names: + "%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%"; withReplacements = attrs: let @@ -91,15 +107,8 @@ rec { else value // { name = name; }; secretsWithName = mapAttrsRecursiveCond (v: ! v ? "source") addNameField attrs; - - allSecrets = collect (v: builtins.isAttrs v && v ? "source") secretsWithName; - - t = { transform ? null, ... }: if isNull transform then x: x else transform; - - genReplacement = secret: - lib.attrsets.nameValuePair (secretName secret.name) ((t secret) "$(cat ${toString secret.source})"); in - lib.attrsets.listToAttrs (map genReplacement allSecrets); + collect (v: builtins.isAttrs v && v ? "source") secretsWithName; # Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists. mapAttrsRecursiveCond = @@ -238,7 +247,7 @@ rec { results = pkgs.lib.runTests tests; in if results != [ ] then - builtins.throw (builtins.concatStringsSep "\n" (map resultToString (lib.traceValSeqN 3 results))) + builtins.throw (concatStringsSep "\n" (map resultToString (lib.traceValSeqN 3 results))) else pkgs.runCommand "nix-flake-tests-success" { } "echo > $out"; diff --git a/modules/blocks/hardcodedsecret.nix b/modules/blocks/hardcodedsecret.nix new file mode 100644 index 0000000..b53136b --- /dev/null +++ b/modules/blocks/hardcodedsecret.nix @@ -0,0 +1,84 @@ +{ config, options, lib, pkgs, ... }: +let + cfg = config.shb.hardcodedsecret; + opt = options.shb.hardcodedsecret; + + inherit (lib) mapAttrs' mkOption nameValuePair; + inherit (lib.types) attrsOf listOf path str submodule; + inherit (pkgs) writeText; +in +{ + options.shb.hardcodedsecret = mkOption { + default = {}; + type = attrsOf (submodule ({ name, ... }: { + options = { + mode = mkOption { + description = '' + Mode of the secret file. + ''; + type = str; + default = "0400"; + }; + + owner = mkOption { + description = '' + Linux user owning the secret file. + ''; + type = str; + default = "root"; + }; + + group = mkOption { + description = '' + Linux group owning the secret file. + ''; + type = str; + default = "root"; + }; + + restartUnits = mkOption { + description = '' + Systemd units to restart after the secret is updated. + ''; + type = listOf str; + default = []; + }; + + path = mkOption { + type = path; + description = '' + Path to the file containing the secret generated out of band. + + This path will exist after deploying to a target host, + it is not available through the nix store. + ''; + default = "/run/hardcodedsecrets/hardcodedsecret_${name}"; + }; + + content = mkOption { + type = str; + description = '' + Content of the secret. + + This will be stored in the nix store and should only be used for testing or maybe in dev. + ''; + }; + }; + })); + }; + + config = { + system.activationScripts = mapAttrs' (n: cfg': + let + content' = writeText "hardcodedsecret_${n}_content" cfg'.content; + in + nameValuePair "hardcodedsecret_${n}" '' + mkdir -p "$(dirname "${cfg'.path}")" + touch "${cfg'.path}" + chmod ${cfg'.mode} "${cfg'.path}" + chown ${cfg'.owner}:${cfg'.group} "${cfg'.path}" + cp ${content'} "${cfg'.path}" + '' + ) cfg; + }; +} diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index 74c048e..c537d17 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -67,9 +67,12 @@ in default = "jellyfin_admin"; }; - passwordFile = lib.mkOption { - type = lib.types.path; - description = "File containing the LDAP admin password."; + adminPassword = contracts.secret.mkOption { + description = "LDAP admin password."; + mode = "0440"; + owner = "jellyfin"; + group = "jellyfin"; + restartUnits = [ "jellyfin.service" ]; }; }; }; @@ -118,9 +121,18 @@ in default = "one_factor"; }; - secretFile = lib.mkOption { - type = lib.types.path; - description = "File containing the OIDC shared secret."; + sharedSecret = contracts.secret.mkOption { + description = "OIDC shared secret for Jellyfin."; + mode = "0440"; + owner = "jellyfin"; + group = "jellyfin"; + restartUnits = [ "jellyfin.service" ]; + }; + + sharedSecretForAuthelia = contracts.secret.mkOption { + description = "OIDC shared secret for Authelia."; + mode = "0400"; + owner = config.shb.authelia.autheliaUser; }; }; }; @@ -400,30 +412,35 @@ in lib.strings.optionalString cfg.ldap.enable (shblib.replaceSecretsScript { file = ldapConfig; resultPath = "/var/lib/jellyfin/plugins/configurations/LDAP-Auth.xml"; - replacements = { - "%LDAP_PASSWORD%" = "$(cat ${cfg.ldap.passwordFile})"; - }; + replacements = [ + { + name = [ "%LDAP_PASSWORD%" ]; + source = cfg.ldap.adminPassword.result.path; + } + ]; }) + lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript { file = ssoConfig; resultPath = "/var/lib/jellyfin/plugins/configurations/SSO-Auth.xml"; - replacements = { - "%SSO_SECRET%" = "$(cat ${cfg.sso.secretFile})"; - }; + replacements = [ + { + name = [ "%SSO_SECRET%" ]; + source = cfg.sso.sharedSecret.result.path; + } + ]; }) + lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript { file = brandingConfig; resultPath = "/var/lib/jellyfin/config/branding.xml"; - replacements = { - "%a%" = "%a%"; - }; + replacements = [ + ]; }); shb.authelia.oidcClients = lib.lists.optionals (!(isNull cfg.sso)) [ { client_id = cfg.sso.clientID; client_name = "Jellyfin"; - client_secret.source = cfg.sso.secretFile; + client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; public = false; authorization_policy = cfg.sso.authorization_policy; redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}" ]; diff --git a/test/common.nix b/test/common.nix index 40d512f..24f6ab8 100644 --- a/test/common.nix +++ b/test/common.nix @@ -1,6 +1,4 @@ -{ - lib, -}: +{ lib }: let baseImports = pkgs: [ (pkgs.path + "/nixos/modules/profiles/headless.nix") @@ -109,6 +107,7 @@ in ../modules/blocks/postgresql.nix ../modules/blocks/authelia.nix ../modules/blocks/nginx.nix + ../modules/blocks/hardcodedsecret.nix ] ++ additionalModules; @@ -138,7 +137,7 @@ in systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; }; - ldap = domain: pkgs: { + ldap = domain: pkgs: { config, ... }: { imports = [ ../modules/blocks/ldap.nix ]; @@ -147,6 +146,13 @@ in "127.0.0.1" = [ "ldap.${domain}" ]; }; + shb.hardcodedsecret.ldapUserPassword = config.shb.ldap.ldapUserPassword.request // { + content = "ldapUserPassword"; + }; + shb.hardcodedsecret.jwtSecret = config.shb.ldap.ldapUserPassword.request // { + content = "jwtSecrets"; + }; + shb.ldap = { enable = true; inherit domain; @@ -154,8 +160,8 @@ in ldapPort = 3890; webUIListenPort = 17170; dcdomain = "dc=example,dc=com"; - ldapUserPassword.result.path = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; - jwtSecret.result.path = pkgs.writeText "jwtSecret" "jwtSecret"; + ldapUserPassword.result.path = config.shb.hardcodedsecret.ldapUserPassword.path; + jwtSecret.result.path = config.shb.hardcodedsecret.jwtSecret.path; }; }; diff --git a/test/services/jellyfin.nix b/test/services/jellyfin.nix index 2561456..8af4a96 100644 --- a/test/services/jellyfin.nix +++ b/test/services/jellyfin.nix @@ -43,9 +43,13 @@ let host = "127.0.0.1"; port = config.shb.ldap.ldapPort; dcdomain = config.shb.ldap.dcdomain; - passwordFile = config.shb.ldap.ldapUserPassword.result.path; + adminPassword.result.path = config.shb.hardcodedsecret.jellyfinLdapUserPassword.path; }; }; + + shb.hardcodedsecret.jellyfinLdapUserPassword = config.shb.jellyfin.ldap.adminPassword.request // { + content = "ldapUserPassword"; + }; }; sso = { config, ... }: { @@ -53,9 +57,18 @@ let sso = { enable = true; endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; - secretFile = pkgs.writeText "ssoSecretFile" "ssoSecretFile"; + sharedSecret.result.path = config.shb.hardcodedsecret.jellyfinSSOPassword.path; + sharedSecretForAuthelia.result.path = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.path; }; }; + + shb.hardcodedsecret.jellyfinSSOPassword = config.shb.jellyfin.sso.sharedSecret.request // { + content = "ssoPassword"; + }; + + shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = config.shb.jellyfin.sso.sharedSecretForAuthelia.request // { + content = "ssoPassword"; + }; }; in {