From e80cc0d3aad5301ec7a57869e5bc2f36c6b0871d Mon Sep 17 00:00:00 2001 From: ibizaman Date: Sat, 2 Mar 2024 22:58:36 -0800 Subject: [PATCH] add vm tests for jellyfin and regroup ldap and sso options --- flake.nix | 1 + lib/default.nix | 2 +- modules/services/jellyfin.nix | 188 ++++++++++++-------- test/vm/jellyfin.nix | 326 ++++++++++++++++++++++++++++++++++ 4 files changed, 439 insertions(+), 78 deletions(-) create mode 100644 test/vm/jellyfin.nix diff --git a/flake.nix b/flake.nix index f91c79b..2c171bb 100644 --- a/flake.nix +++ b/flake.nix @@ -100,6 +100,7 @@ }; } // (vm_test "authelia" ./test/vm/authelia.nix) + // (vm_test "jellyfin" ./test/vm/jellyfin.nix) // (vm_test "ldap" ./test/vm/ldap.nix) // (vm_test "lib" ./test/vm/lib.nix) // (vm_test "postgresql" ./test/vm/postgresql.nix) diff --git a/lib/default.nix b/lib/default.nix index c47643f..4846a3e 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -22,9 +22,9 @@ rec { '' set -euo pipefail set -x + mkdir -p $(dirname ${templatePath}) ln -fs ${file} ${templatePath} rm -f ${resultPath} - ${pkgs.gnused}/bin/sed ${sedPatterns} ${templatePath} ${pkgs.gnused}/bin/sed ${sedPatterns} ${templatePath} > ${resultPath} ''; diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index 1571f12..dcc1450 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -30,62 +30,94 @@ in default = null; }; - ldapHost = lib.mkOption { - type = lib.types.str; - description = "host serving the LDAP server"; - example = "127.0.0.1"; + ldap = lib.mkOption { + description = "LDAP configuration."; + default = {}; + type = lib.types.submodule { + options = { + enable = lib.mkEnableOption "LDAP"; + + host = lib.mkOption { + type = lib.types.str; + description = "Host serving the LDAP server."; + example = "127.0.0.1"; + }; + + port = lib.mkOption { + type = lib.types.int; + description = "Port where the LDAP server is listening."; + example = 389; + }; + + dcdomain = lib.mkOption { + type = lib.types.str; + description = "DC domain for LDAP."; + example = "dc=mydomain,dc=com"; + }; + + userGroup = lib.mkOption { + type = lib.types.str; + description = "LDAP user group"; + default = "jellyfin_user"; + }; + + adminGroup = lib.mkOption { + type = lib.types.str; + description = "LDAP admin group"; + default = "jellyfin_admin"; + }; + + passwordFile = lib.mkOption { + type = lib.types.path; + description = "File containing the LDAP admin password."; + }; + }; + }; }; - ldapPort = lib.mkOption { - type = lib.types.int; - description = "port where the LDAP server is listening"; - example = 389; - }; + sso = lib.mkOption { + description = "SSO configuration."; + default = {}; + type = lib.types.submodule { + options = { + enable = lib.mkEnableOption "SSO"; - dcdomain = lib.mkOption { - type = lib.types.str; - description = "dc domain for ldap"; - example = "dc=mydomain,dc=com"; - }; + provider = lib.mkOption { + type = lib.types.str; + description = "OIDC provider name"; + default = "Authelia"; + }; - oidcProvider = lib.mkOption { - type = lib.types.str; - description = "OIDC provider name"; - default = "Authelia"; - }; + endpoint = lib.mkOption { + type = lib.types.str; + description = "OIDC endpoint for SSO"; + example = "https://authelia.example.com"; + }; - authEndpoint = lib.mkOption { - type = lib.types.str; - description = "OIDC endpoint for SSO"; - example = "https://authelia.example.com"; - }; + clientID = lib.mkOption { + type = lib.types.str; + description = "Client ID for the OIDC endpoint"; + default = "jellyfin"; + }; - oidcClientID = lib.mkOption { - type = lib.types.str; - description = "Client ID for the OIDC endpoint"; - default = "jellyfin"; - }; + adminUserGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC admin group"; + default = "jellyfin_admin"; + }; - oidcAdminUserGroup = lib.mkOption { - type = lib.types.str; - description = "OIDC admin group"; - default = "jellyfin_admin"; - }; + userGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC user group"; + default = "jellyfin_user"; + }; - oidcUserGroup = lib.mkOption { - type = lib.types.str; - description = "OIDC user group"; - default = "jellyfin_user"; - }; - - ldapPasswordFile = lib.mkOption { - type = lib.types.path; - description = "File containing the LDAP admin password."; - }; - - ssoSecretFile = lib.mkOption { - type = lib.types.path; - description = "File containing the SSO shared secret."; + secretFile = lib.mkOption { + type = lib.types.path; + description = "File containing the OIDC shared secret."; + }; + }; + }; }; }; @@ -107,6 +139,8 @@ in }; }; + services.nginx.enable = true; + # Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex services.nginx.virtualHosts."${fqdn}" = { forceSSL = !(isNull cfg.ssl); @@ -238,17 +272,17 @@ in ldapConfig = pkgs.writeText "LDAP-Auth.xml" '' - ${cfg.ldapHost} - ${builtins.toString cfg.ldapPort} + ${cfg.ldap.host} + ${builtins.toString cfg.ldap.port} false false false - uid=admin,ou=people,${cfg.dcdomain} + uid=admin,ou=people,${cfg.ldap.dcdomain} %LDAP_PASSWORD% - ou=people,${cfg.dcdomain} - (memberof=cn=jellyfin_user,ou=groups,${cfg.dcdomain}) - ou=people,${cfg.dcdomain} - (memberof=cn=jellyfin_admin,ou=groups,${cfg.dcdomain}) + ou=people,${cfg.ldap.dcdomain} + (memberof=cn=${cfg.ldap.userGroup},ou=groups,${cfg.ldap.dcdomain}) + ou=people,${cfg.ldap.dcdomain} + (memberof=cn=${cfg.ldap.adminGroup},ou=groups,${cfg.ldap.dcdomain}) false uid, cn, mail, displayName @@ -271,22 +305,22 @@ in - ${cfg.oidcProvider} + ${cfg.sso.provider} - ${cfg.authEndpoint} - ${cfg.oidcClientID} + ${cfg.sso.endpoint} + ${cfg.sso.clientID} %SSO_SECRET% true true true - ${cfg.oidcAdminUserGroup} + ${cfg.sso.adminUserGroup} - ${cfg.oidcUserGroup} + ${cfg.sso.userGroup} false @@ -305,15 +339,15 @@ in brandingConfig = pkgs.writeText "branding.xml" '' - <a href="https://${cfg.subdomain}.${cfg.domain}/SSO/OID/p/${cfg.oidcProvider}" class="raised cancel block emby-button authentik-sso"> - Sign in with ${cfg.oidcProvider}&nbsp; + <a href="https://${cfg.subdomain}.${cfg.domain}/SSO/OID/p/${cfg.sso.provider}" class="raised cancel block emby-button authentik-sso"> + Sign in with ${cfg.sso.provider}&nbsp; <img alt="OpenID Connect (authentik)" title="OpenID Connect (authentik)" class="oauth-login-image" src="https://raw.githubusercontent.com/goauthentik/authentik/master/web/icons/icon.png"> </a> <a href="https://${cfg.subdomain}.${cfg.domain}/SSOViews/linking" class="raised cancel block emby-button authentik-sso"> - Link ${cfg.oidcProvider} config&nbsp; + Link ${cfg.sso.provider} config&nbsp; </a> - <a href="${cfg.authEndpoint}" class="raised cancel block emby-button authentik-sso"> - ${cfg.oidcProvider} config&nbsp; + <a href="${cfg.sso.endpoint}" class="raised cancel block emby-button authentik-sso"> + ${cfg.sso.provider} config&nbsp; </a> @@ -348,36 +382,36 @@ in ''; in - shblib.replaceSecretsScript { + lib.strings.optionalString cfg.ldap.enable (shblib.replaceSecretsScript { file = ldapConfig; resultPath = "/var/lib/jellyfin/plugins/configurations/LDAP-Auth.xml"; replacements = { - "%LDAP_PASSWORD%" = "$(cat ${cfg.ldapPasswordFile})"; + "%LDAP_PASSWORD%" = "$(cat ${cfg.ldap.passwordFile})"; }; - } - + shblib.replaceSecretsScript { + }) + + lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript { file = ssoConfig; resultPath = "/var/lib/jellyfin/plugins/configurations/SSO-Auth.xml"; replacements = { - "%SSO_SECRET%" = "$(cat ${cfg.ssoSecretFile})"; + "%SSO_SECRET%" = "$(cat ${cfg.sso.secretFile})"; }; - } - + shblib.replaceSecretsScript { + }) + + lib.strings.optionalString cfg.sso.enable (shblib.replaceSecretsScript { file = brandingConfig; resultPath = "/var/lib/jellyfin/config/branding.xml"; replacements = { "%a%" = "%a%"; }; - }; + }); - shb.authelia.oidcClients = [ + shb.authelia.oidcClients = lib.lists.optionals (!(isNull cfg.sso)) [ { - id = cfg.oidcClientID; + id = cfg.sso.clientID; description = "Jellyfin"; - secret.source = cfg.ssoSecretFile; + secret.source = cfg.sso.secretFile; public = false; authorization_policy = "one_factor"; - redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.oidcProvider}" ]; + redirect_uris = [ "https://${cfg.subdomain}.${cfg.domain}/sso/OID/r/${cfg.sso.provider}" ]; } ]; diff --git a/test/vm/jellyfin.nix b/test/vm/jellyfin.nix new file mode 100644 index 0000000..0cdef24 --- /dev/null +++ b/test/vm/jellyfin.nix @@ -0,0 +1,326 @@ +{ pkgs, lib, ... }: +{ + basic = pkgs.nixosTest { + name = "jellyfin-basic"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/services/jellyfin.nix + ]; + + shb.jellyfin = { + enable = true; + domain = "example.com"; + subdomain = "j"; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 ]; + }; + + nodes.client = {}; + + # TODO: Test login + testScript = { nodes, ... }: '' + import json + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to j.example.com:443:server:443" + + " --connect-to j.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("jellyfin.service") + server.wait_for_unit("nginx.service") + server.wait_for_open_port(8096) + + response = curl(client, """{"code":%{response_code}}""", "http://j.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; + + ldap = pkgs.nixosTest { + name = "jellyfin-ldap"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/blocks/ldap.nix + ../../modules/services/jellyfin.nix + ]; + + shb.ldap = { + enable = true; + domain = "example.com"; + subdomain = "ldap"; + ldapPort = 3890; + webUIListenPort = 17170; + dcdomain = "dc=example,dc=com"; + ldapUserPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + }; + + shb.jellyfin = { + enable = true; + domain = "example.com"; + subdomain = "j"; + + ldap = { + enable = true; + host = "127.0.0.1"; + port = config.shb.ldap.ldapPort; + dcdomain = config.shb.ldap.dcdomain; + passwordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + }; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 ]; + }; + + nodes.client = {}; + + # TODO: Test login with ldap user + testScript = { nodes, ... }: '' + import json + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to j.example.com:443:server:443" + + " --connect-to j.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("jellyfin.service") + server.wait_for_unit("nginx.service") + server.wait_for_unit("lldap.service") + server.wait_for_open_port(8096) + + response = curl(client, """{"code":%{response_code}}""", "http://j.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; + + cert = pkgs.nixosTest { + name = "jellyfin_cert"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/blocks/nginx.nix + ../../modules/blocks/postgresql.nix + ../../modules/blocks/ssl.nix + ../../modules/services/jellyfin.nix + ]; + + shb.certs = { + cas.selfsigned.myca = { + name = "My CA"; + }; + certs.selfsigned = { + n = { + ca = config.shb.certs.cas.selfsigned.myca; + domain = "*.example.com"; + group = "nginx"; + }; + }; + }; + + systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + + shb.jellyfin = { + enable = true; + domain = "example.com"; + subdomain = "j"; + ssl = config.shb.certs.certs.selfsigned.n; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 443 ]; + + shb.nginx.accessLog = true; + }; + + nodes.client = {}; + + # TODO: Test login + testScript = { nodes, ... }: '' + import json + import os + import pathlib + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to j.example.com:443:server:443" + + " --connect-to j.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("jellyfin.service") + server.wait_for_unit("nginx.service") + server.wait_for_open_port(8096) + + server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") + client.succeed("rm -r /etc/ssl/certs") + client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt") + + response = curl(client, """{"code":%{response_code}}""", "https://j.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; + + sso = pkgs.nixosTest { + name = "jellyfin_sso"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + }; + } + ../../modules/blocks/authelia.nix + ../../modules/blocks/ldap.nix + ../../modules/blocks/postgresql.nix + ../../modules/blocks/ssl.nix + ../../modules/services/jellyfin.nix + ]; + + shb.ldap = { + enable = true; + domain = "example.com"; + subdomain = "ldap"; + ldapPort = 3890; + webUIListenPort = 17170; + dcdomain = "dc=example,dc=com"; + ldapUserPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + }; + + shb.certs = { + cas.selfsigned.myca = { + name = "My CA"; + }; + certs.selfsigned = { + n = { + ca = config.shb.certs.cas.selfsigned.myca; + domain = "*.example.com"; + group = "nginx"; + }; + }; + }; + + systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + + shb.authelia = { + enable = true; + domain = "example.com"; + subdomain = "auth"; + ssl = config.shb.certs.certs.selfsigned.n; + + ldapEndpoint = "ldap://127.0.0.1:${builtins.toString config.shb.ldap.ldapPort}"; + dcdomain = config.shb.ldap.dcdomain; + + secrets = { + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + ldapAdminPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + sessionSecretFile = pkgs.writeText "sessionSecret" "sessionSecret"; + storageEncryptionKeyFile = pkgs.writeText "storageEncryptionKey" "storageEncryptionKey"; + identityProvidersOIDCHMACSecretFile = pkgs.writeText "identityProvidersOIDCHMACSecret" "identityProvidersOIDCHMACSecret"; + identityProvidersOIDCIssuerPrivateKeyFile = (pkgs.runCommand "gen-private-key" {} '' + mkdir $out + ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096 + '') + "/private.pem"; + }; + }; + + shb.jellyfin = { + enable = true; + domain = "example.com"; + subdomain = "j"; + ssl = config.shb.certs.certs.selfsigned.n; + + ldap = { + enable = true; + host = "127.0.0.1"; + port = config.shb.ldap.ldapPort; + dcdomain = config.shb.ldap.dcdomain; + passwordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + }; + + sso = { + enable = true; + endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + secretFile = pkgs.writeText "ssoSecretFile" "ssoSecretFile"; + }; + }; + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 443 ]; + }; + + nodes.client = {}; + + # TODO: Test login with ldap user + testScript = { nodes, ... }: '' + import json + import os + import pathlib + + def curl(target, format, endpoint): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to j.example.com:443:server:443" + + " --connect-to j.example.com:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + start_all() + server.wait_for_unit("jellyfin.service") + server.wait_for_unit("nginx.service") + server.wait_for_unit("lldap.service") + server.wait_for_unit("authelia-auth.example.com.service") + server.wait_for_open_port(8096) + + server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") + client.succeed("rm -r /etc/ssl/certs") + client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt") + + response = curl(client, """{"code":%{response_code}}""", "https://j.example.com") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + }; +}