From 2f785919d943c8686e49bcd456d072080c1792b6 Mon Sep 17 00:00:00 2001 From: sivert Date: Fri, 22 Aug 2025 11:29:40 +0200 Subject: [PATCH] #100 Immich service - Declarative SSO setup via json config file - roleClaim via LDAP groups to automatically set admin privileges - SSO tested manually, but not yet in playwright test - Storage quota claim not yet implemented - Authelia rule prevents access by non-members of userGroup/adminUserGroup --- flake.nix | 3 + modules/services/immich.nix | 560 ++++++++++++++++++++++++++++++++++++ test/services/immich.nix | 147 ++++++++++ 3 files changed, 710 insertions(+) create mode 100644 modules/services/immich.nix create mode 100644 test/services/immich.nix diff --git a/flake.nix b/flake.nix index a99bd0e..77e96fb 100644 --- a/flake.nix +++ b/flake.nix @@ -78,6 +78,7 @@ self.nixosModules.${system}.forgejo self.nixosModules.${system}.grocy self.nixosModules.${system}.hledger + self.nixosModules.${system}.immich self.nixosModules.${system}.assistant self.nixosModules.${system}.jellyfin self.nixosModules.${system}.nextcloud-server @@ -112,6 +113,7 @@ nixosModules.forgejo = modules/services/forgejo.nix; nixosModules.grocy = modules/services/grocy.nix; nixosModules.hledger = modules/services/hledger.nix; + nixosModules.immich = modules/services/immich.nix; nixosModules.assistant = modules/services/home-assistant.nix; nixosModules.jellyfin = modules/services/jellyfin.nix; nixosModules.nextcloud-server = modules/services/nextcloud-server.nix; @@ -256,6 +258,7 @@ // (vm_test "forgejo" ./test/services/forgejo.nix) // (vm_test "grocy" ./test/services/grocy.nix) // (vm_test "hledger" ./test/services/hledger.nix) + // (vm_test "immich" ./test/services/immich.nix) // (vm_test "homeassistant" ./test/services/home-assistant.nix) // (vm_test "jellyfin" ./test/services/jellyfin.nix) // (vm_test "monitoring" ./test/services/monitoring.nix) diff --git a/modules/services/immich.nix b/modules/services/immich.nix new file mode 100644 index 0000000..e1ecf0a --- /dev/null +++ b/modules/services/immich.nix @@ -0,0 +1,560 @@ +{ config, pkgs, lib, ... }: + +let + cfg = config.shb.immich; + + contracts = pkgs.callPackage ../contracts {}; + shblib = pkgs.callPackage ../../lib {}; + + fqdn = "${cfg.subdomain}.${cfg.domain}"; + protocol = if !(isNull cfg.ssl) then "https" else "http"; + + roleClaim = "immich_user"; + + # TODO: Quota management, see https://github.com/ibizaman/selfhostblocks/pull/523#discussion_r2309421694 + #quotaClaim = "immich_quota"; + scopes = [ "openid" "email" "profile" "groups" "immich_scope"]; + + dataFolder = cfg.mediaLocation; + ssoFqdnWithPort = if isNull cfg.sso.port + then cfg.sso.endpoint + else "${cfg.sso.endpoint}:${toString cfg.sso.port}"; + # Generate Immich configuration file only for SHB-managed settings + shbManagedSettings = lib.optionalAttrs (cfg.settings != {}) cfg.settings + // lib.optionalAttrs (cfg.sso.enable) { + oauth = { + enabled = true; + issuerUrl = "${ssoFqdnWithPort}"; + clientId = cfg.sso.clientID; + roleClaim = roleClaim; + clientSecret = { source = cfg.sso.sharedSecret.result.path; }; + scope = builtins.concatStringsSep " " scopes; + storageLabelClaim = cfg.sso.storageLabelClaim; + #storageQuotaClaim = quotaClaim; # TODO (commented out, otherwise defaults to 0 bytes!) + defaultStorageQuota = 0; + buttonText = cfg.sso.buttonText; + autoRegister = cfg.sso.autoRegister; + autoLaunch = cfg.sso.autoLaunch; + passwordLogin = cfg.sso.passwordLogin; + mobileOverrideEnabled = false; + mobileRedirectUri = ""; + }; + } + // lib.optionalAttrs (cfg.smtp != null) { + notifications = { + smtp = { + enabled = true; + from = cfg.smtp.from; + replyTo = cfg.smtp.replyTo; + transport = { + host = cfg.smtp.host; + port = cfg.smtp.port; + username = cfg.smtp.username; + password = { source = cfg.smtp.password.result.path; }; + ignoreTLS = cfg.smtp.ignoreTLS; + secure = cfg.smtp.secure; + }; + }; + }; + }; + + configFile = "/var/lib/immich/config.json"; + + # Use SHB's replaceSecrets function for loading secrets at runtime + configSetupScript = lib.optionalString (cfg.sso.enable || cfg.smtp != null) ( + shblib.replaceSecrets { + userConfig = shbManagedSettings; + resultPath = configFile; + generator = shblib.replaceSecretsFormatAdapter (pkgs.formats.json {}); + user = "immich"; + permissions = "u=r,g=,o="; + } + ); + inherit (lib) mkEnableOption mkIf lists mkOption optionals; + inherit (lib.types) attrs attrsOf bool enum listOf nullOr port submodule str path; +in +{ + options.shb.immich = { + enable = mkEnableOption "selfhostblocks.immich"; + + subdomain = mkOption { + type = str; + description = '' + Subdomain under which Immich will be served. + + ``` + . + ``` + ''; + example = "photos"; + }; + + domain = mkOption { + description = '' + Domain under which Immich is served. + + ``` + . + ``` + ''; + type = str; + example = "example.com"; + }; + + port = mkOption { + description = '' + Port under which Immich will listen. + ''; + type = port; + default = 2283; + }; + + ssl = mkOption { + description = "Path to SSL files"; + type = nullOr contracts.ssl.certs; + default = null; + }; + + mediaLocation = mkOption { + description = "Directory where Immich will store media files."; + type = str; + default = "/var/lib/immich"; + }; + + jwtSecretFile = mkOption { + description = '' + File containing Immich's JWT secret key for sessions. + This is required for secure session management. + ''; + type = nullOr (submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + owner = "immich"; + restartUnits = [ "immich-server.service" ]; + }; + }); + default = null; + }; + + mount = mkOption { + type = contracts.mount; + description = '' + Mount configuration. This is an output option. + + Use it to initialize a block implementing the "mount" contract. + For example, with a zfs dataset: + + ``` + shb.zfs.datasets."immich" = { + poolName = "root"; + } // config.shb.immich.mount; + ``` + ''; + readOnly = true; + default = { path = dataFolder; }; + }; + + backup = mkOption { + description = '' + Backup configuration for Immich media files and database. + ''; + default = {}; + type = submodule { + options = contracts.backup.mkRequester { + user = "immich"; + sourceDirectories = [ + dataFolder + ]; + excludePatterns = [ + "*.tmp" + "cache/*" + "encoded-video/*" + ]; + }; + }; + }; + + accelerationDevices = mkOption { + description = '' + Hardware acceleration devices for Immich. + Set to null to allow access to all devices. + Set to empty list to disable hardware acceleration. + ''; + type = nullOr (listOf path); + default = null; + example = [ "/dev/dri" ]; + }; + + machineLearning = mkOption { + description = "Machine learning configuration."; + default = {}; + type = submodule { + options = { + enable = mkOption { + description = "Enable machine learning features."; + type = bool; + default = true; + }; + + environment = mkOption { + description = "Extra environment variables for machine learning service."; + type = attrsOf str; + default = {}; + example = { + MACHINE_LEARNING_WORKERS = "2"; + MACHINE_LEARNING_WORKER_TIMEOUT = "180"; + }; + }; + }; + }; + }; + + sso = mkOption { + description = '' + Setup SSO integration. + ''; + default = {}; + type = submodule { + options = { + enable = mkEnableOption "SSO integration."; + + provider = mkOption { + type = enum [ "Authelia" "Keycloak" "Generic" ]; + description = "OIDC provider name, used for display."; + default = "Authelia"; + }; + + endpoint = mkOption { + type = str; + description = "OIDC endpoint for SSO."; + example = "https://authelia.example.com"; + }; + + clientID = mkOption { + type = str; + description = "Client ID for the OIDC endpoint."; + default = "immich"; + }; + + adminUserGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC admin group"; + default = "immich_admin"; + }; + + userGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC user group"; + default = "immich_user"; + }; + + port = mkOption { + description = "If given, adds a port to the endpoint."; + type = nullOr port; + default = null; + }; + + storageLabelClaim = mkOption { + type = str; + description = "Claim to use for user storage label."; + default = "preferred_username"; + }; + + buttonText = mkOption { + type = str; + description = "Text to display on the SSO login button."; + default = "Login with SSO"; + }; + + autoRegister = mkOption { + type = bool; + description = "Automatically register new users from SSO provider."; + default = true; + }; + + autoLaunch = mkOption { + type = bool; + description = "Automatically redirect to SSO provider."; + default = true; + }; + + passwordLogin = mkOption { + type = bool; + description = "Enable password login."; + default = true; + }; + + sharedSecret = mkOption { + description = "OIDC shared secret for Immich."; + type = submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + owner = "immich"; + group = "immich"; + restartUnits = [ "immich-server.service" ]; + }; + }; + }; + + sharedSecretForAuthelia = mkOption { + description = "OIDC shared secret for Authelia. Content must be the same as `sharedSecret` option."; + type = submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + owner = "authelia"; + }; + }; + default = null; + }; + + authorization_policy = mkOption { + type = enum [ "one_factor" "two_factor" ]; + description = "Require one factor (password) or two factor (device) authentication."; + default = "one_factor"; + }; + }; + }; + }; + + settings = mkOption { + type = attrs; + description = '' + Immich configuration settings. + Only specify settings that you want SHB to manage declaratively. + Other settings can be configured through Immich's admin UI. + + See https://immich.app/docs/install/config-file/ for available options. + ''; + default = {}; + example = { + ffmpeg.crf = 23; + job.backgroundTask.concurrency = 5; + storageTemplate = { + enabled = true; + template = "{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}"; + }; + }; + }; + + smtp = mkOption { + description = '' + SMTP configuration for sending notifications. + ''; + default = null; + type = nullOr (submodule { + options = { + from = mkOption { + type = str; + description = "SMTP address from which the emails originate."; + example = "noreply@example.com"; + }; + + replyTo = mkOption { + type = str; + description = "Reply-to address for emails."; + example = "support@example.com"; + }; + + host = mkOption { + type = str; + description = "SMTP host to send the emails to."; + example = "smtp.example.com"; + }; + + port = mkOption { + type = port; + description = "SMTP port to send the emails to."; + default = 587; + }; + + username = mkOption { + type = str; + description = "Username to connect to the SMTP host."; + example = "smtp-user"; + }; + + password = mkOption { + description = "File containing the password to connect to the SMTP host."; + type = submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + owner = "immich"; + restartUnits = [ "immich-server.service" ]; + }; + }; + }; + + ignoreTLS = mkOption { + type = bool; + description = "Ignore TLS certificate errors."; + default = false; + }; + + secure = mkOption { + type = bool; + description = "Use secure connection (SSL/TLS)."; + default = false; + }; + }; + }); + }; + + debug = mkOption { + type = bool; + description = "Set to true to enable debug logging."; + default = false; + example = true; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key); + message = "SSL is enabled for Immich but no cert or key is provided."; + } + { + assertion = cfg.sso.enable -> cfg.ssl != null; + message = "To integrate SSO, SSL must be enabled, set the shb.immich.ssl option."; + } + ]; + + # Configure Immich service + services.immich = { + enable = true; + host = "127.0.0.1"; + port = cfg.port; + mediaLocation = cfg.mediaLocation; + + # Hardware acceleration configuration + accelerationDevices = cfg.accelerationDevices; + + # Database configuration defaults to Unix socket /run/postgresql + + # Database configuration + database = { + # Disable pgvecto.rs, as it was deprecated before SHB integration + enableVectors = false; + }; + + # Machine learning configuration + machine-learning = mkIf cfg.machineLearning.enable { + enable = true; + environment = cfg.machineLearning.environment; + }; + + # Environment configuration + environment = { + IMMICH_LOG_LEVEL = if cfg.debug then "debug" else "log"; + REDIS_HOSTNAME = "127.0.0.1"; + REDIS_PORT = "6379"; + REDIS_DBINDEX = "0"; + } // lib.optionalAttrs (cfg.jwtSecretFile != null) { + JWT_SECRET_FILE = cfg.jwtSecretFile.result.path; + } // lib.optionalAttrs (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null) { + IMMICH_CONFIG_FILE = configFile; + }; + }; + + # Create basic directories for Immich + systemd.tmpfiles.rules = [ + "d /var/lib/immich 0700 immich immich" + ]; + + # Configuration setup service - generates config only for SHB-managed settings + systemd.services.immich-setup-config = mkIf (cfg.enable && (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null)) { + description = "Setup Immich configuration for SHB-managed settings"; + wantedBy = [ "multi-user.target" ]; + before = [ "immich-server.service" ]; + after = [ "network.target" ]; + serviceConfig = { + Type = "oneshot"; + User = "immich"; + Group = "immich"; + }; + script = '' + mkdir -p ${dataFolder} + + # Generate config file with only SHB-managed settings + ${configSetupScript} + ''; + }; + + # Add immich user to video and render groups for hardware acceleration + users.users.immich.extraGroups = optionals (cfg.accelerationDevices != []) [ "video" "render" ]; + + # PostgreSQL extensions are automatically handled by the Immich service + + # Redis is automatically configured by the Immich service + + # Configure Nginx reverse proxy + shb.nginx.vhosts = [ + { + inherit (cfg) subdomain domain ssl; + upstream = "http://127.0.0.1:${toString cfg.port}"; + autheliaRules = lib.mkIf (cfg.sso.enable) [ + { + domain = fqdn; + policy = cfg.sso.authorization_policy; + subject = ["group:immich_user" "group:immich_admin"]; + } + ]; + authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint; + extraConfig = '' + client_max_body_size 50000M; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + send_timeout 600s; + proxy_buffering off; + ''; + } + ]; + + # Ensure services start in correct order + systemd.services.immich-server = { + after = [ "postgresql.service" "redis-immich.service" ] + ++ optionals (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null) [ "immich-setup-config.service" ]; + requires = [ "postgresql.service" "redis-immich.service" ] + ++ optionals (cfg.settings != {} || cfg.sso.enable || cfg.smtp != null) [ "immich-setup-config.service" ]; + }; + + systemd.services.immich-machine-learning = mkIf cfg.machineLearning.enable { + after = [ "immich-server.service" ]; + }; + + # Authelia integration for SSO + shb.authelia.extraDefinitions = { + # Immich expects all users that get a token to be granted access. So users can either be part of the + # "admin" group or the "user" group. Users that are not part of either should be blocked by + # the ID provider (Authelia). + user_attributes.${roleClaim}.expression = ''"${cfg.sso.adminUserGroup}" in groups ? "admin" : "user"''; + }; + shb.authelia.extraOidcClaimsPolicies.immich_policy = { + custom_claims = { + ${roleClaim} = {}; + }; + }; + shb.authelia.extraOidcScopes.immich_scope = { + claims = [ roleClaim ]; + }; + + + shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [ + { + client_id = cfg.sso.clientID; + client_name = "Immich"; + client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; + public = false; + authorization_policy = cfg.sso.authorization_policy; + claims_policy = "immich_policy"; + token_endpoint_auth_method = "client_secret_post"; + redirect_uris = [ + "${protocol}://${fqdn}/auth/login" + "${protocol}://${fqdn}/user-settings" + "app.immich:///oauth-callback" + ]; + inherit scopes; + } + ]; + }; +} diff --git a/test/services/immich.nix b/test/services/immich.nix new file mode 100644 index 0000000..a249296 --- /dev/null +++ b/test/services/immich.nix @@ -0,0 +1,147 @@ +{ pkgs, lib }: +let + testLib = pkgs.callPackage ../common.nix {}; + + subdomain = "i"; + domain = "example.com"; + + commonTestScript = testLib.accessScript { + hasSSL = { node, ... }: !(isNull node.config.shb.immich.ssl); + waitForServices = { ... }: [ "immich-server.service" "postgresql.service" "nginx.service" ]; + waitForPorts = { ... }: [ 2283 80 ]; + waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ]; + }; + + base = { config, ... }: { + imports = [ + testLib.baseModule + ../../modules/services/immich.nix + ]; + + virtualisation.memorySize = 4096; + virtualisation.cores = 2; + + test = { + inherit subdomain domain; + }; + + shb.immich = { + enable = true; + inherit subdomain domain; + + debug = true; + }; + + # Required for tests + environment.systemPackages = [ pkgs.curl ]; + }; + + basic = { config, ... }: { + imports = [ base ]; + + test.hasSSL = false; + }; + + https = { config, ... }: { + imports = [ + base + testLib.certs + ]; + + test.hasSSL = true; + shb.immich.ssl = config.shb.certs.certs.selfsigned.n; + }; + + backup = { config, ... }: { + imports = [ + https + (testLib.backup config.shb.immich.backup) + ]; + }; + + sso = { config, ... }: { + imports = [ + https + testLib.ldap + (testLib.sso config.shb.certs.certs.selfsigned.n) + ]; + + shb.immich.sso = { + enable = true; + provider = "Authelia"; + endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + clientID = "immich"; + autoLaunch = true; + sharedSecret.result = config.shb.hardcodedsecret.immichSSOSecret.result; + sharedSecretForAuthelia.result = config.shb.hardcodedsecret.immichSSOSecretAuthelia.result; + }; + + shb.hardcodedsecret.immichSSOSecret = { + request = config.shb.immich.sso.sharedSecret.request; + settings.content = "immichSSOSecret"; + }; + + shb.hardcodedsecret.immichSSOSecretAuthelia = { + request = config.shb.immich.sso.sharedSecretForAuthelia.request; + settings.content = "immichSSOSecret"; + }; + + # Configure LDAP groups for group-based access control + shb.lldap.ensureGroups.immich_user = {}; + + shb.lldap.ensureUsers.immich_test_user = { + email = "immich_user@example.com"; + groups = [ "immich_user" ]; + password.result = config.shb.hardcodedsecret.ldapImmichUserPassword.result; + }; + + shb.lldap.ensureUsers.regular_test_user = { + email = "regular_user@example.com"; + groups = [ ]; + password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result; + }; + + shb.hardcodedsecret.ldapImmichUserPassword = { + request = config.shb.lldap.ensureUsers.immich_test_user.password.request; + settings.content = "immich_user_password"; + }; + + shb.hardcodedsecret.ldapRegularUserPassword = { + request = config.shb.lldap.ensureUsers.regular_test_user.password.request; + settings.content = "regular_user_password"; + }; + }; +in +{ + basic = pkgs.nixosTest { + name = "immich-basic"; + + nodes.server = basic; + nodes.client = {}; + + testScript = commonTestScript; + }; + + https = pkgs.nixosTest { + name = "immich-https"; + + nodes.server = https; + nodes.client = {}; + + testScript = commonTestScript; + }; + + backup = pkgs.nixosTest { + name = "immich-backup"; + + nodes.server = backup; + nodes.client = {}; + + testScript = (testLib.mkScripts { + hasSSL = args: !(isNull args.node.config.shb.immich.ssl); + waitForServices = args: [ "immich-server.service" "postgresql.service" "nginx.service" ]; + waitForPorts = args: [ 2283 80 ]; + waitForUrls = args: [ "${args.proto_fqdn}" ]; + }).backup; + }; +}