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.
This commit is contained in:
parent
b405988e60
commit
fa87855ee5
6 changed files with 168 additions and 38 deletions
|
@ -33,6 +33,7 @@
|
||||||
allModules = [
|
allModules = [
|
||||||
modules/blocks/authelia.nix
|
modules/blocks/authelia.nix
|
||||||
modules/blocks/davfs.nix
|
modules/blocks/davfs.nix
|
||||||
|
modules/blocks/hardcodedsecret.nix
|
||||||
modules/blocks/ldap.nix
|
modules/blocks/ldap.nix
|
||||||
modules/blocks/monitoring.nix
|
modules/blocks/monitoring.nix
|
||||||
modules/blocks/nginx.nix
|
modules/blocks/nginx.nix
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{ pkgs, lib }:
|
{ pkgs, lib }:
|
||||||
let
|
let
|
||||||
inherit (builtins) isAttrs hasAttr;
|
inherit (builtins) isAttrs hasAttr;
|
||||||
inherit (lib) concatStringsSep;
|
inherit (lib) concatMapStringsSep concatStringsSep mapAttrsToList;
|
||||||
in
|
in
|
||||||
rec {
|
rec {
|
||||||
# Replace secrets in a file.
|
# Replace secrets in a file.
|
||||||
|
@ -34,14 +34,30 @@ rec {
|
||||||
replaceSecretsScript = { file, resultPath, replacements, user ? null, permissions ? "u=r,g=r,o=" }:
|
replaceSecretsScript = { file, resultPath, replacements, user ? null, permissions ? "u=r,g=r,o=" }:
|
||||||
let
|
let
|
||||||
templatePath = resultPath + ".template";
|
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"
|
then "cat"
|
||||||
else "${pkgs.gnused}/bin/sed ${sedPatterns}";
|
else "${pkgs.gnused}/bin/sed ${sedPatterns}";
|
||||||
in
|
in
|
||||||
''
|
''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
${checkPermissions}
|
||||||
|
|
||||||
mkdir -p $(dirname ${templatePath})
|
mkdir -p $(dirname ${templatePath})
|
||||||
ln -fs ${file} ${templatePath}
|
ln -fs ${file} ${templatePath}
|
||||||
rm -f ${resultPath}
|
rm -f ${resultPath}
|
||||||
|
@ -71,8 +87,8 @@ rec {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
secretName = name:
|
secretName = names:
|
||||||
"%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) name)}%";
|
"%SECRET${lib.strings.toUpper (lib.strings.concatMapStrings (s: "_" + s) names)}%";
|
||||||
|
|
||||||
withReplacements = attrs:
|
withReplacements = attrs:
|
||||||
let
|
let
|
||||||
|
@ -91,15 +107,8 @@ rec {
|
||||||
else value // { name = name; };
|
else value // { name = name; };
|
||||||
|
|
||||||
secretsWithName = mapAttrsRecursiveCond (v: ! v ? "source") addNameField attrs;
|
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
|
in
|
||||||
lib.attrsets.listToAttrs (map genReplacement allSecrets);
|
collect (v: builtins.isAttrs v && v ? "source") secretsWithName;
|
||||||
|
|
||||||
# Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists.
|
# Inspired lib.attrsets.mapAttrsRecursiveCond but also recurses on lists.
|
||||||
mapAttrsRecursiveCond =
|
mapAttrsRecursiveCond =
|
||||||
|
@ -238,7 +247,7 @@ rec {
|
||||||
results = pkgs.lib.runTests tests;
|
results = pkgs.lib.runTests tests;
|
||||||
in
|
in
|
||||||
if results != [ ] then
|
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
|
else
|
||||||
pkgs.runCommand "nix-flake-tests-success" { } "echo > $out";
|
pkgs.runCommand "nix-flake-tests-success" { } "echo > $out";
|
||||||
|
|
||||||
|
|
84
modules/blocks/hardcodedsecret.nix
Normal file
84
modules/blocks/hardcodedsecret.nix
Normal file
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -67,9 +67,12 @@ in
|
||||||
default = "jellyfin_admin";
|
default = "jellyfin_admin";
|
||||||
};
|
};
|
||||||
|
|
||||||
passwordFile = lib.mkOption {
|
adminPassword = contracts.secret.mkOption {
|
||||||
type = lib.types.path;
|
description = "LDAP admin password.";
|
||||||
description = "File containing the LDAP admin password.";
|
mode = "0440";
|
||||||
|
owner = "jellyfin";
|
||||||
|
group = "jellyfin";
|
||||||
|
restartUnits = [ "jellyfin.service" ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -118,9 +121,18 @@ in
|
||||||
default = "one_factor";
|
default = "one_factor";
|
||||||
};
|
};
|
||||||
|
|
||||||
secretFile = lib.mkOption {
|
sharedSecret = contracts.secret.mkOption {
|
||||||
type = lib.types.path;
|
description = "OIDC shared secret for Jellyfin.";
|
||||||
description = "File containing the OIDC shared secret.";
|
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 {
|
lib.strings.optionalString cfg.ldap.enable (shblib.replaceSecretsScript {
|
||||||
file = ldapConfig;
|
file = ldapConfig;
|
||||||
resultPath = "/var/lib/jellyfin/plugins/configurations/LDAP-Auth.xml";
|
resultPath = "/var/lib/jellyfin/plugins/configurations/LDAP-Auth.xml";
|
||||||
replacements = {
|
replacements = [
|
||||||
"%LDAP_PASSWORD%" = "$(cat ${cfg.ldap.passwordFile})";
|
{
|
||||||
};
|
name = [ "%LDAP_PASSWORD%" ];
|
||||||
|
source = cfg.ldap.adminPassword.result.path;
|
||||||
|
}
|
||||||
|
];
|
||||||
})
|
})
|
||||||
+ lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript {
|
+ lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript {
|
||||||
file = ssoConfig;
|
file = ssoConfig;
|
||||||
resultPath = "/var/lib/jellyfin/plugins/configurations/SSO-Auth.xml";
|
resultPath = "/var/lib/jellyfin/plugins/configurations/SSO-Auth.xml";
|
||||||
replacements = {
|
replacements = [
|
||||||
"%SSO_SECRET%" = "$(cat ${cfg.sso.secretFile})";
|
{
|
||||||
};
|
name = [ "%SSO_SECRET%" ];
|
||||||
|
source = cfg.sso.sharedSecret.result.path;
|
||||||
|
}
|
||||||
|
];
|
||||||
})
|
})
|
||||||
+ lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript {
|
+ lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript {
|
||||||
file = brandingConfig;
|
file = brandingConfig;
|
||||||
resultPath = "/var/lib/jellyfin/config/branding.xml";
|
resultPath = "/var/lib/jellyfin/config/branding.xml";
|
||||||
replacements = {
|
replacements = [
|
||||||
"%a%" = "%a%";
|
];
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
shb.authelia.oidcClients = lib.lists.optionals (!(isNull cfg.sso)) [
|
shb.authelia.oidcClients = lib.lists.optionals (!(isNull cfg.sso)) [
|
||||||
{
|
{
|
||||||
client_id = cfg.sso.clientID;
|
client_id = cfg.sso.clientID;
|
||||||
client_name = "Jellyfin";
|
client_name = "Jellyfin";
|
||||||
client_secret.source = cfg.sso.secretFile;
|
client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path;
|
||||||
public = false;
|
public = false;
|
||||||
authorization_policy = cfg.sso.authorization_policy;
|
authorization_policy = cfg.sso.authorization_policy;
|
||||||
redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}" ];
|
redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}" ];
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
{
|
{ lib }:
|
||||||
lib,
|
|
||||||
}:
|
|
||||||
let
|
let
|
||||||
baseImports = pkgs: [
|
baseImports = pkgs: [
|
||||||
(pkgs.path + "/nixos/modules/profiles/headless.nix")
|
(pkgs.path + "/nixos/modules/profiles/headless.nix")
|
||||||
|
@ -109,6 +107,7 @@ in
|
||||||
../modules/blocks/postgresql.nix
|
../modules/blocks/postgresql.nix
|
||||||
../modules/blocks/authelia.nix
|
../modules/blocks/authelia.nix
|
||||||
../modules/blocks/nginx.nix
|
../modules/blocks/nginx.nix
|
||||||
|
../modules/blocks/hardcodedsecret.nix
|
||||||
]
|
]
|
||||||
++ additionalModules;
|
++ additionalModules;
|
||||||
|
|
||||||
|
@ -138,7 +137,7 @@ in
|
||||||
systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ];
|
systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ];
|
||||||
};
|
};
|
||||||
|
|
||||||
ldap = domain: pkgs: {
|
ldap = domain: pkgs: { config, ... }: {
|
||||||
imports = [
|
imports = [
|
||||||
../modules/blocks/ldap.nix
|
../modules/blocks/ldap.nix
|
||||||
];
|
];
|
||||||
|
@ -147,6 +146,13 @@ in
|
||||||
"127.0.0.1" = [ "ldap.${domain}" ];
|
"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 = {
|
shb.ldap = {
|
||||||
enable = true;
|
enable = true;
|
||||||
inherit domain;
|
inherit domain;
|
||||||
|
@ -154,8 +160,8 @@ in
|
||||||
ldapPort = 3890;
|
ldapPort = 3890;
|
||||||
webUIListenPort = 17170;
|
webUIListenPort = 17170;
|
||||||
dcdomain = "dc=example,dc=com";
|
dcdomain = "dc=example,dc=com";
|
||||||
ldapUserPassword.result.path = pkgs.writeText "ldapUserPassword" "ldapUserPassword";
|
ldapUserPassword.result.path = config.shb.hardcodedsecret.ldapUserPassword.path;
|
||||||
jwtSecret.result.path = pkgs.writeText "jwtSecret" "jwtSecret";
|
jwtSecret.result.path = config.shb.hardcodedsecret.jwtSecret.path;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -43,9 +43,13 @@ let
|
||||||
host = "127.0.0.1";
|
host = "127.0.0.1";
|
||||||
port = config.shb.ldap.ldapPort;
|
port = config.shb.ldap.ldapPort;
|
||||||
dcdomain = config.shb.ldap.dcdomain;
|
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, ... }: {
|
sso = { config, ... }: {
|
||||||
|
@ -53,9 +57,18 @@ let
|
||||||
sso = {
|
sso = {
|
||||||
enable = true;
|
enable = true;
|
||||||
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
|
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
|
in
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue