Skip to content

Commit

Permalink
switch jellyfin to new secrets contract
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
ibizaman committed Oct 13, 2024
1 parent 5a0ae36 commit 4879bd5
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 38 deletions.
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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
Expand Down
37 changes: 23 additions & 14 deletions lib/default.nix
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand Down Expand Up @@ -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";

Expand Down
83 changes: 83 additions & 0 deletions modules/blocks/hardcodedsecret.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{ 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 {
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;
};
}
49 changes: 33 additions & 16 deletions modules/services/jellyfin.nix
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,12 @@ in
default = "jellyfin_admin";
};

passwordFile = lib.mkOption {
type = lib.types.path;
description = "File containing the LDAP admin password.";
passwordFile = contracts.secret.mkOption {
description = "LDAP admin password.";
mode = "0440";
owner = "jellyfin";
group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};
};
};
Expand Down Expand Up @@ -118,9 +121,18 @@ in
default = "one_factor";
};

secretFile = lib.mkOption {
type = lib.types.path;
description = "File containing the OIDC shared secret.";
secretFile = contracts.secret.mkOption {
description = "OIDC shared secret for Jellyfin.";
mode = "0440";
owner = "jellyfin";
group = "jellyfin";
restartUnits = [ "jellyfin.service" ];
};

secretFileAuthelia = contracts.secret.mkOption {
description = "OIDC shared secret for Authelia.";
mode = "0400";
owner = config.shb.authelia.autheliaUser;
};
};
};
Expand Down Expand Up @@ -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.passwordFile.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.secretFile.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.secretFileAuthelia.result.path;
public = false;
authorization_policy = cfg.sso.authorization_policy;
redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}" ];
Expand Down
18 changes: 12 additions & 6 deletions test/common.nix
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
{
lib,
}:
{ lib }:
let
baseImports = pkgs: [
(pkgs.path + "/nixos/modules/profiles/headless.nix")
Expand Down Expand Up @@ -109,6 +107,7 @@ in
../modules/blocks/postgresql.nix
../modules/blocks/authelia.nix
../modules/blocks/nginx.nix
../modules/blocks/hardcodedsecret.nix
]
++ additionalModules;

Expand Down Expand Up @@ -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
];
Expand All @@ -147,15 +146,22 @@ 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;
subdomain = "ldap";
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;
};
};

Expand Down
17 changes: 15 additions & 2 deletions test/services/jellyfin.nix
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,32 @@ let
host = "127.0.0.1";
port = config.shb.ldap.ldapPort;
dcdomain = config.shb.ldap.dcdomain;
passwordFile = config.shb.ldap.ldapUserPassword.result.path;
passwordFile.result.path = config.shb.hardcodedsecret.jellyfinLdapUserPassword.path;
};
};

shb.hardcodedsecret.jellyfinLdapUserPassword = config.shb.jellyfin.ldap.passwordFile.request // {
content = "ldapUserPassword";
};
};

sso = { config, ... }: {
shb.jellyfin = {
sso = {
enable = true;
endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}";
secretFile = pkgs.writeText "ssoSecretFile" "ssoSecretFile";
secretFile.result.path = config.shb.hardcodedsecret.jellyfinSSOPassword.path;
secretFileAuthelia.result.path = config.shb.hardcodedsecret.jellyfinSSOPasswordAuthelia.path;
};
};

shb.hardcodedsecret.jellyfinSSOPassword = config.shb.jellyfin.sso.secretFile.request // {
content = "ssoPassword";
};

shb.hardcodedsecret.jellyfinSSOPasswordAuthelia = config.shb.jellyfin.sso.secretFileAuthelia.request // {
content = "ssoPassword";
};
};
in
{
Expand Down

0 comments on commit 4879bd5

Please sign in to comment.