diff --git a/flake.nix b/flake.nix index b26b88a..ac392b7 100644 --- a/flake.nix +++ b/flake.nix @@ -302,6 +302,7 @@ // (vm_test "karakeep" ./test/services/karakeep.nix) // (vm_test "nextcloud" ./test/services/nextcloud.nix) // (vm_test "open-webui" ./test/services/open-webui.nix) + // (vm_test "paperless" ./test/services/paperless.nix) // (vm_test "pinchflat" ./test/services/pinchflat.nix) // (vm_test "vaultwarden" ./test/services/vaultwarden.nix) @@ -410,6 +411,7 @@ self.nixosModules.nextcloud-server self.nixosModules.open-webui self.nixosModules.pinchflat + self.nixosModules.paperless self.nixosModules.vaultwarden ]; }; @@ -442,6 +444,7 @@ nixosModules.karakeep = modules/services/karakeep.nix; nixosModules.nextcloud-server = modules/services/nextcloud-server.nix; nixosModules.open-webui = modules/services/open-webui.nix; + nixosModules.paperless = modules/services/paperless.nix; nixosModules.pinchflat = modules/services/pinchflat.nix; nixosModules.vaultwarden = modules/services/vaultwarden.nix; }; diff --git a/modules/services/paperless.nix b/modules/services/paperless.nix new file mode 100644 index 0000000..bc2d807 --- /dev/null +++ b/modules/services/paperless.nix @@ -0,0 +1,447 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + cfg = config.shb.paperless; + contracts = pkgs.callPackage ../contracts { }; + dataFolder = cfg.dataDir; + fqdn = "${cfg.subdomain}.${cfg.domain}"; + protocol = if !(isNull cfg.ssl) then "https" else "http"; + ssoFqdnWithPort = + if isNull cfg.sso.port then cfg.sso.endpoint else "${cfg.sso.endpoint}:${toString cfg.sso.port}"; + + ssoClientSettings = { + openid_connect = { + SCOPE = [ + "openid" + "profile" + "email" + "groups" + ]; + OAUTH_PKCE_ENABLED = true; + APPS = [ + { + provider_id = "${cfg.sso.provider}"; + name = "${cfg.sso.provider}"; + client_id = "${cfg.sso.clientID}"; + secret = "%SECRET_CLIENT_SECRET_PLACEHOLDER%"; + settings = { + server_url = ssoFqdnWithPort; + token_auth_method = "client_secret_basic"; + }; + } + ]; + }; + }; + ssoClientSettingsFile = pkgs.writeText "paperless-sso-client.env" '' + PAPERLESS_SOCIALACCOUNT_PROVIDERS=${builtins.toJSON ssoClientSettings} + ''; + replacements = [ + { + # Note: replaceSecretsScript prepends '%SECRET_' and appends '%' + # when doing the replacement + name = [ "CLIENT_SECRET_PLACEHOLDER" ]; + source = cfg.sso.sharedSecret.result.path; + } + ]; + replaceSecretsScript = lib.shb.replaceSecretsScript { + file = ssoClientSettingsFile; + resultPath = "/run/paperless/paperless-sso-client.env"; + inherit replacements; + user = "paperless"; + }; + inherit (lib) + mkEnableOption + mkIf + lists + mkOption + optionals + ; + inherit (lib.types) + attrs + attrsOf + bool + enum + listOf + nullOr + port + submodule + str + path + ; + +in +{ + imports = [ + ../blocks/nginx.nix + ]; + + options.shb.paperless = { + enable = mkEnableOption "selfhostblocks.paperless"; + + subdomain = mkOption { + type = str; + description = '' + Subdomain under which paperless will be served. + + ``` + . + ``` + ''; + example = "photos"; + }; + + domain = mkOption { + description = '' + Domain under which paperless is served. + + ``` + . + ``` + ''; + type = str; + example = "example.com"; + }; + + port = mkOption { + description = '' + Port under which paperless will listen. + ''; + type = port; + default = 28981; + }; + + ssl = mkOption { + description = "Path to SSL files"; + type = nullOr contracts.ssl.certs; + default = null; + }; + + dataDir = mkOption { + description = "Directory where paperless will store data files."; + type = str; + default = "/var/lib/paperless"; + }; + + mediaDir = mkOption { + description = "Directory where paperless will store documents."; + type = str; + defaultText = lib.literalExpression ''"''${dataDir}/media"''; + default = "${cfg.dataDir}/media"; + }; + + consumptionDir = mkOption { + description = "Directory from which new documents are imported."; + type = str; + defaultText = lib.literalExpression ''"''${dataDir}/consume"''; + default = "${cfg.dataDir}/consume"; + }; + + configureTika = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to configure Tika and Gotenberg to process Office and e-mail files with OCR. + ''; + }; + + adminPassword = mkOption { + description = "Secret containing the superuser (admin) password."; + type = submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + owner = "paperless"; + group = "paperless"; + restartUnits = [ "paperless-server.service" ]; + }; + }; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = + with lib.types; + attrsOf ( + let + typeList = [ + bool + float + int + str + path + package + ]; + in + oneOf ( + typeList + ++ [ + (listOf (oneOf typeList)) + (attrsOf (oneOf typeList)) + ] + ) + ); + }; + default = { }; + description = '' + Extra paperless config options. + + See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options. + + Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values. + Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience. + ''; + example = { + PAPERLESS_OCR_LANGUAGE = "deu+eng"; + PAPERLESS_CONSUMER_IGNORE_PATTERN = [ + ".DS_STORE/*" + "desktop.ini" + ]; + PAPERLESS_OCR_USER_ARGS = { + optimize = 1; + pdfa_image_compression = "lossless"; + }; + }; + }; + + 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."paperless" = { + poolName = "root"; + } // config.shb.paperless.mount; + ``` + ''; + readOnly = true; + default = { + path = dataFolder; + }; + }; + + backup = mkOption { + description = '' + Backup configuration for paperless media files and database. + ''; + default = { }; + type = submodule { + options = contracts.backup.mkRequester { + user = "paperless"; + sourceDirectories = [ + dataFolder + ]; + excludePatterns = [ + ]; + }; + }; + }; + + 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 = "paperless"; + }; + + adminUserGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC admin group"; + default = "paperless_admin"; + }; + + userGroup = lib.mkOption { + type = lib.types.str; + description = "OIDC user group"; + default = "paperless_user"; + }; + + port = mkOption { + description = "If given, adds a port to the endpoint."; + type = nullOr port; + default = null; + }; + + 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 paperless."; + type = submodule { + options = contracts.secret.mkRequester { + mode = "0400"; + owner = "paperless"; + group = "paperless"; + restartUnits = [ "paperless-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"; + }; + }; + }; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(isNull cfg.ssl) -> !(isNull cfg.ssl.paths.cert) && !(isNull cfg.ssl.paths.key); + message = "SSL is enabled for paperless 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.paperless.ssl option."; + } + ]; + + # Configure paperless service + services.paperless = { + enable = true; + address = "127.0.0.1"; + port = cfg.port; + consumptionDirIsPublic = true; + dataDir = cfg.dataDir; + mediaDir = cfg.mediaDir; + consumptionDir = cfg.consumptionDir; + configureTika = cfg.configureTika; + settings = { + PAPERLESS_URL = "${protocol}://${fqdn}"; + } + // cfg.settings + // lib.optionalAttrs (cfg.sso.enable) { + PAPERLESS_APPS = "allauth.socialaccount.providers.openid_connect"; + PAPERLESS_SOCIAL_AUTO_SIGNUP = cfg.sso.autoRegister; + PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS = true; + PAPERLESS_DISABLE_REGULAR_LOGIN = !cfg.sso.passwordLogin; + }; + } + // lib.optionalAttrs (cfg.sso.enable) { + environmentFile = "/run/paperless/paperless-sso-client.env"; + }; + + # Database defaults to local sqlite + + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir} 0700 paperless paperless" + "d ${cfg.consumptionDir} 0700 paperless paperless" + "d ${cfg.mediaDir} 0700 paperless paperless" + ] + ++ lib.optionals cfg.sso.enable [ "d '/run/paperless' 0750 root root - -" ]; + + systemd.services.paperless-pre = lib.mkIf cfg.sso.enable { + script = replaceSecretsScript; + serviceConfig.Type = "oneshot"; + wantedBy = [ "multi-user.target" ]; + before = [ "paperless-scheduler.service" ]; + requiredBy = [ "paperless-scheduler.service" ]; + }; + + 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:paperless_user" + "group:paperless_admin" + ]; + } + ]; + authEndpoint = lib.mkIf (cfg.sso.enable) cfg.sso.endpoint; + extraConfig = '' + # See https://github.com/paperless-ngx/paperless-ngx/wiki/Using-a-Reverse-Proxy-with-Paperless-ngx#nginx + proxy_redirect off; + proxy_set_header X-Forwarded-Host $server_name; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + ''; + } + ]; + + # Allow large uploads + services.nginx.virtualHosts."${fqdn}".extraConfig = '' + client_max_body_size 500M; + ''; + + shb.authelia.oidcClients = lists.optionals (cfg.sso.enable && cfg.sso.provider == "Authelia") [ + { + client_id = cfg.sso.clientID; + client_name = "paperless"; + client_secret.source = cfg.sso.sharedSecretForAuthelia.result.path; + public = false; + authorization_policy = cfg.sso.authorization_policy; + token_endpoint_auth_method = "client_secret_basic"; + redirect_uris = [ + "${protocol}://${fqdn}/accounts/oidc/${cfg.sso.provider}/login/callback/" + ]; + } + ]; + }; +} diff --git a/test/services/paperless.nix b/test/services/paperless.nix new file mode 100644 index 0000000..1c9f622 --- /dev/null +++ b/test/services/paperless.nix @@ -0,0 +1,180 @@ +{ pkgs, lib }: +let + subdomain = "p"; + domain = "example.com"; + + commonTestScript = lib.shb.accessScript { + hasSSL = { node, ... }: !(isNull node.config.shb.paperless.ssl); + waitForServices = + { ... }: + [ + "paperless-web.service" + "nginx.service" + ]; + waitForPorts = + { ... }: + [ + 28981 + 80 + ]; + waitForUrls = { proto_fqdn, ... }: [ "${proto_fqdn}" ]; + }; + + base = + { config, ... }: + { + imports = [ + lib.shb.baseModule + ../../modules/services/paperless.nix + ]; + + virtualisation.memorySize = 4096; + virtualisation.cores = 2; + + test = { + inherit subdomain domain; + }; + + shb.paperless = { + enable = true; + inherit subdomain domain; + }; + + # Required for tests + environment.systemPackages = [ pkgs.curl ]; + }; + + basic = + { config, ... }: + { + imports = [ base ]; + + test.hasSSL = false; + }; + + https = + { config, ... }: + { + imports = [ + base + lib.shb.certs + ]; + + test.hasSSL = true; + shb.paperless.ssl = config.shb.certs.certs.selfsigned.n; + }; + + backup = + { config, ... }: + { + imports = [ + https + (lib.shb.backup config.shb.paperless.backup) + ]; + }; + + sso = + { config, ... }: + { + imports = [ + https + lib.shb.ldap + (lib.shb.sso config.shb.certs.certs.selfsigned.n) + ]; + + shb.paperless.sso = { + enable = true; + provider = "Authelia"; + endpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + clientID = "paperless"; + autoLaunch = true; + sharedSecret.result = config.shb.hardcodedsecret.paperlessSSOSecret.result; + sharedSecretForAuthelia.result = config.shb.hardcodedsecret.paperlessSSOSecretAuthelia.result; + }; + + shb.hardcodedsecret.paperlessSSOSecret = { + request = config.shb.paperless.sso.sharedSecret.request; + settings.content = "paperlessSSOSecret"; + }; + + shb.hardcodedsecret.paperlessSSOSecretAuthelia = { + request = config.shb.paperless.sso.sharedSecretForAuthelia.request; + settings.content = "paperlessSSOSecret"; + }; + + # Configure LDAP groups for group-based access control + shb.lldap.ensureGroups.paperless_user = { }; + + shb.lldap.ensureUsers.paperless_test_user = { + email = "paperless_user@example.com"; + groups = [ "paperless_user" ]; + password.result = config.shb.hardcodedsecret.ldappaperlessUserPassword.result; + }; + + shb.lldap.ensureUsers.regular_test_user = { + email = "regular_user@example.com"; + groups = [ ]; + password.result = config.shb.hardcodedsecret.ldapRegularUserPassword.result; + }; + + shb.hardcodedsecret.ldappaperlessUserPassword = { + request = config.shb.lldap.ensureUsers.paperless_test_user.password.request; + settings.content = "paperless_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 = "paperless-basic"; + + nodes.server = basic; + nodes.client = { }; + + testScript = commonTestScript; + }; + + https = pkgs.nixosTest { + name = "paperless-https"; + + nodes.server = https; + nodes.client = { }; + + testScript = commonTestScript; + }; + + sso = pkgs.nixosTest { + name = "paperless-https"; + + nodes.server = sso; + nodes.client = { }; + + testScript = commonTestScript; + }; + + backup = pkgs.nixosTest { + name = "paperless-backup"; + + nodes.server = backup; + nodes.client = { }; + + testScript = + (lib.shb.mkScripts { + hasSSL = args: !(isNull args.node.config.shb.paperless.ssl); + waitForServices = args: [ + "paperless-web.service" + "nginx.service" + ]; + waitForPorts = args: [ + 28981 + 80 + ]; + waitForUrls = args: [ "${args.proto_fqdn}" ]; + }).backup; + }; + +}