#587 Implement SHB module for paperless service

This commit is contained in:
sivert 2025-11-17 20:29:32 +01:00 committed by Pierre Penninckx
parent 879254bcfd
commit fd676bdef7
3 changed files with 630 additions and 0 deletions

View file

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

View file

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

180
test/services/paperless.nix Normal file
View file

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