#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
This commit is contained in:
sivert 2025-08-22 11:29:40 +02:00 committed by Pierre Penninckx
parent 4621a02dff
commit 2f785919d9
3 changed files with 710 additions and 0 deletions

View file

@ -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)

560
modules/services/immich.nix Normal file
View file

@ -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.
```
<subdomain>.<domain>
```
'';
example = "photos";
};
domain = mkOption {
description = ''
Domain under which Immich is served.
```
<subdomain>.<domain>
```
'';
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;
}
];
};
}

147
test/services/immich.nix Normal file
View file

@ -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;
};
}