1{ config, pkgs, lib, ... }:
2
3with lib;
4let
5 cfg = config.services.paperless;
6
7 defaultUser = "paperless";
8 defaultFont = "${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf";
9
10 # Don't start a redis instance if the user sets a custom redis connection
11 enableRedis = !(cfg.settings ? PAPERLESS_REDIS);
12 redisServer = config.services.redis.servers.paperless;
13
14 env = {
15 PAPERLESS_DATA_DIR = cfg.dataDir;
16 PAPERLESS_MEDIA_ROOT = cfg.mediaDir;
17 PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir;
18 PAPERLESS_THUMBNAIL_FONT_NAME = defaultFont;
19 GUNICORN_CMD_ARGS = "--bind=${cfg.address}:${toString cfg.port}";
20 } // optionalAttrs (config.time.timeZone != null) {
21 PAPERLESS_TIME_ZONE = config.time.timeZone;
22 } // optionalAttrs enableRedis {
23 PAPERLESS_REDIS = "unix://${redisServer.unixSocket}";
24 } // optionalAttrs (cfg.settings.PAPERLESS_ENABLE_NLTK or true) {
25 PAPERLESS_NLTK_DIR = pkgs.symlinkJoin {
26 name = "paperless_ngx_nltk_data";
27 paths = cfg.package.nltkData;
28 };
29 } // optionalAttrs (cfg.openMPThreadingWorkaround) {
30 OMP_NUM_THREADS = "1";
31 } // (lib.mapAttrs (_: s:
32 if (lib.isAttrs s || lib.isList s) then builtins.toJSON s
33 else if lib.isBool s then lib.boolToString s
34 else toString s
35 ) cfg.settings);
36
37 manage = pkgs.writeShellScript "manage" ''
38 set -o allexport # Export the following env vars
39 ${lib.toShellVars env}
40 exec ${cfg.package}/bin/paperless-ngx "$@"
41 '';
42
43 # Secure the services
44 defaultServiceConfig = {
45 ReadWritePaths = [
46 cfg.consumptionDir
47 cfg.dataDir
48 cfg.mediaDir
49 ];
50 CacheDirectory = "paperless";
51 CapabilityBoundingSet = "";
52 # ProtectClock adds DeviceAllow=char-rtc r
53 DeviceAllow = "";
54 LockPersonality = true;
55 MemoryDenyWriteExecute = true;
56 NoNewPrivileges = true;
57 PrivateDevices = true;
58 PrivateMounts = true;
59 PrivateNetwork = true;
60 PrivateTmp = true;
61 PrivateUsers = true;
62 ProtectClock = true;
63 # Breaks if the home dir of the user is in /home
64 # ProtectHome = true;
65 ProtectHostname = true;
66 ProtectSystem = "strict";
67 ProtectControlGroups = true;
68 ProtectKernelLogs = true;
69 ProtectKernelModules = true;
70 ProtectKernelTunables = true;
71 ProtectProc = "invisible";
72 # Don't restrict ProcSubset because django-q requires read access to /proc/stat
73 # to query CPU and memory information.
74 # Note that /proc only contains processes of user `paperless`, so this is safe.
75 # ProcSubset = "pid";
76 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
77 RestrictNamespaces = true;
78 RestrictRealtime = true;
79 RestrictSUIDSGID = true;
80 SupplementaryGroups = optional enableRedis redisServer.user;
81 SystemCallArchitectures = "native";
82 SystemCallFilter = [ "@system-service" "~@privileged @setuid @keyring" ];
83 UMask = "0066";
84 };
85in
86{
87 meta.maintainers = with maintainers; [ erikarvstedt Flakebi leona ];
88
89 imports = [
90 (mkRenamedOptionModule [ "services" "paperless-ng" ] [ "services" "paperless" ])
91 (mkRenamedOptionModule [ "services" "paperless" "extraConfig" ] [ "services" "paperless" "settings" ])
92 ];
93
94 options.services.paperless = {
95 enable = mkOption {
96 type = lib.types.bool;
97 default = false;
98 description = ''
99 Enable Paperless.
100
101 When started, the Paperless database is automatically created if it doesn't
102 exist and updated if the Paperless package has changed.
103 Both tasks are achieved by running a Django migration.
104
105 A script to manage the Paperless instance (by wrapping Django's manage.py) is linked to
106 `''${dataDir}/paperless-manage`.
107 '';
108 };
109
110 dataDir = mkOption {
111 type = types.str;
112 default = "/var/lib/paperless";
113 description = "Directory to store the Paperless data.";
114 };
115
116 mediaDir = mkOption {
117 type = types.str;
118 default = "${cfg.dataDir}/media";
119 defaultText = literalExpression ''"''${dataDir}/media"'';
120 description = "Directory to store the Paperless documents.";
121 };
122
123 consumptionDir = mkOption {
124 type = types.str;
125 default = "${cfg.dataDir}/consume";
126 defaultText = literalExpression ''"''${dataDir}/consume"'';
127 description = "Directory from which new documents are imported.";
128 };
129
130 consumptionDirIsPublic = mkOption {
131 type = types.bool;
132 default = false;
133 description = "Whether all users can write to the consumption dir.";
134 };
135
136 passwordFile = mkOption {
137 type = types.nullOr types.path;
138 default = null;
139 example = "/run/keys/paperless-password";
140 description = ''
141 A file containing the superuser password.
142
143 A superuser is required to access the web interface.
144 If unset, you can create a superuser manually by running
145 `''${dataDir}/paperless-manage createsuperuser`.
146
147 The default superuser name is `admin`. To change it, set
148 option {option}`settings.PAPERLESS_ADMIN_USER`.
149 WARNING: When changing the superuser name after the initial setup, the old superuser
150 will continue to exist.
151
152 To disable login for the web interface, set the following:
153 `settings.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";`.
154 WARNING: Only use this on a trusted system without internet access to Paperless.
155 '';
156 };
157
158 address = mkOption {
159 type = types.str;
160 default = "localhost";
161 description = "Web interface address.";
162 };
163
164 port = mkOption {
165 type = types.port;
166 default = 28981;
167 description = "Web interface port.";
168 };
169
170 settings = mkOption {
171 type = lib.types.submodule {
172 freeformType = with lib.types; attrsOf (let
173 typeList = [ bool float int str path package ];
174 in oneOf (typeList ++ [ (listOf (oneOf typeList)) (attrsOf (oneOf typeList)) ]));
175 };
176 default = { };
177 description = ''
178 Extra paperless config options.
179
180 See [the documentation](https://docs.paperless-ngx.com/configuration/) for available options.
181
182 Note that some settings such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values.
183 Settings declared as lists or attrsets will automatically be serialised into JSON strings for your convenience.
184 '';
185 example = {
186 PAPERLESS_OCR_LANGUAGE = "deu+eng";
187 PAPERLESS_DBHOST = "/run/postgresql";
188 PAPERLESS_CONSUMER_IGNORE_PATTERN = [ ".DS_STORE/*" "desktop.ini" ];
189 PAPERLESS_OCR_USER_ARGS = {
190 optimize = 1;
191 pdfa_image_compression = "lossless";
192 };
193 };
194 };
195
196 user = mkOption {
197 type = types.str;
198 default = defaultUser;
199 description = "User under which Paperless runs.";
200 };
201
202 package = mkPackageOption pkgs "paperless-ngx" { } // {
203 apply = pkg: pkg.override {
204 tesseract5 = pkg.tesseract5.override {
205 # always enable detection modules
206 # tesseract fails to build when eng is not present
207 enableLanguages = if cfg.settings ? PAPERLESS_OCR_LANGUAGE then
208 lists.unique (
209 [ "equ" "osd" "eng" ]
210 ++ lib.splitString "+" cfg.settings.PAPERLESS_OCR_LANGUAGE
211 )
212 else null;
213 };
214 };
215 };
216
217 openMPThreadingWorkaround = mkEnableOption ''
218 a workaround for document classifier timeouts.
219
220 Paperless uses OpenBLAS via scikit-learn for document classification.
221
222 The default is to use threading for OpenMP but this would cause the
223 document classifier to spin on one core seemingly indefinitely if there
224 are large amounts of classes per classification; causing it to
225 effectively never complete due to running into timeouts.
226
227 This sets `OMP_NUM_THREADS` to `1` in order to mitigate the issue. See
228 https://github.com/NixOS/nixpkgs/issues/240591 for more information.
229 '' // mkOption { default = true; };
230 };
231
232 config = mkIf cfg.enable {
233 services.redis.servers.paperless.enable = mkIf enableRedis true;
234
235 systemd.tmpfiles.settings."10-paperless" = let
236 defaultRule = {
237 inherit (cfg) user;
238 inherit (config.users.users.${cfg.user}) group;
239 };
240 in {
241 "${cfg.dataDir}".d = defaultRule;
242 "${cfg.mediaDir}".d = defaultRule;
243 "${cfg.consumptionDir}".d = if cfg.consumptionDirIsPublic then { mode = "777"; } else defaultRule;
244 };
245
246 systemd.services.paperless-scheduler = {
247 description = "Paperless Celery Beat";
248 wantedBy = [ "multi-user.target" ];
249 wants = [ "paperless-consumer.service" "paperless-web.service" "paperless-task-queue.service" ];
250 serviceConfig = defaultServiceConfig // {
251 User = cfg.user;
252 ExecStart = "${cfg.package}/bin/celery --app paperless beat --loglevel INFO";
253 Restart = "on-failure";
254 LoadCredential = lib.optionalString (cfg.passwordFile != null) "PAPERLESS_ADMIN_PASSWORD:${cfg.passwordFile}";
255 };
256 environment = env;
257
258 preStart = ''
259 ln -sf ${manage} ${cfg.dataDir}/paperless-manage
260
261 # Auto-migrate on first run or if the package has changed
262 versionFile="${cfg.dataDir}/src-version"
263 version=$(cat "$versionFile" 2>/dev/null || echo 0)
264
265 if [[ $version != ${cfg.package.version} ]]; then
266 ${cfg.package}/bin/paperless-ngx migrate
267
268 # Parse old version string format for backwards compatibility
269 version=$(echo "$version" | grep -ohP '[^-]+$')
270
271 versionLessThan() {
272 target=$1
273 [[ $({ echo "$version"; echo "$target"; } | sort -V | head -1) != "$target" ]]
274 }
275
276 if versionLessThan 1.12.0; then
277 # Reindex documents as mentioned in https://github.com/paperless-ngx/paperless-ngx/releases/tag/v1.12.1
278 echo "Reindexing documents, to allow searching old comments. Required after the 1.12.x upgrade."
279 ${cfg.package}/bin/paperless-ngx document_index reindex
280 fi
281
282 echo ${cfg.package.version} > "$versionFile"
283 fi
284 ''
285 + optionalString (cfg.passwordFile != null) ''
286 export PAPERLESS_ADMIN_USER="''${PAPERLESS_ADMIN_USER:-admin}"
287 export PAPERLESS_ADMIN_PASSWORD=$(cat $CREDENTIALS_DIRECTORY/PAPERLESS_ADMIN_PASSWORD)
288 superuserState="$PAPERLESS_ADMIN_USER:$PAPERLESS_ADMIN_PASSWORD"
289 superuserStateFile="${cfg.dataDir}/superuser-state"
290
291 if [[ $(cat "$superuserStateFile" 2>/dev/null) != $superuserState ]]; then
292 ${cfg.package}/bin/paperless-ngx manage_superuser
293 echo "$superuserState" > "$superuserStateFile"
294 fi
295 '';
296 } // optionalAttrs enableRedis {
297 after = [ "redis-paperless.service" ];
298 };
299
300 systemd.services.paperless-task-queue = {
301 description = "Paperless Celery Workers";
302 after = [ "paperless-scheduler.service" ];
303 serviceConfig = defaultServiceConfig // {
304 User = cfg.user;
305 ExecStart = "${cfg.package}/bin/celery --app paperless worker --loglevel INFO";
306 Restart = "on-failure";
307 # The `mbind` syscall is needed for running the classifier.
308 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "mbind" ];
309 # Needs to talk to mail server for automated import rules
310 PrivateNetwork = false;
311 };
312 environment = env;
313 };
314
315 systemd.services.paperless-consumer = {
316 description = "Paperless document consumer";
317 # Bind to `paperless-scheduler` so that the consumer never runs
318 # during migrations
319 bindsTo = [ "paperless-scheduler.service" ];
320 after = [ "paperless-scheduler.service" ];
321 serviceConfig = defaultServiceConfig // {
322 User = cfg.user;
323 ExecStart = "${cfg.package}/bin/paperless-ngx document_consumer";
324 Restart = "on-failure";
325 };
326 environment = env;
327 # Allow the consumer to access the private /tmp directory of the server.
328 # This is required to support consuming files via a local folder.
329 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service";
330 };
331
332 systemd.services.paperless-web = {
333 description = "Paperless web server";
334 # Bind to `paperless-scheduler` so that the web server never runs
335 # during migrations
336 bindsTo = [ "paperless-scheduler.service" ];
337 after = [ "paperless-scheduler.service" ];
338 # Setup PAPERLESS_SECRET_KEY.
339 # If this environment variable is left unset, paperless-ngx defaults
340 # to a well-known value, which is insecure.
341 script = let
342 secretKeyFile = "${cfg.dataDir}/nixos-paperless-secret-key";
343 in ''
344 if [[ ! -f '${secretKeyFile}' ]]; then
345 (
346 umask 0377
347 tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge '${secretKeyFile}'
348 )
349 fi
350 export PAPERLESS_SECRET_KEY=$(cat '${secretKeyFile}')
351 if [[ ! $PAPERLESS_SECRET_KEY ]]; then
352 echo "PAPERLESS_SECRET_KEY is empty, refusing to start."
353 exit 1
354 fi
355 exec ${cfg.package.python.pkgs.gunicorn}/bin/gunicorn \
356 -c ${cfg.package}/lib/paperless-ngx/gunicorn.conf.py paperless.asgi:application
357 '';
358 serviceConfig = defaultServiceConfig // {
359 User = cfg.user;
360 Restart = "on-failure";
361
362 LimitNOFILE = 65536;
363 # gunicorn needs setuid, liblapack needs mbind
364 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "@setuid mbind" ];
365 # Needs to serve web page
366 PrivateNetwork = false;
367 } // lib.optionalAttrs (cfg.port < 1024) {
368 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
369 CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
370 };
371 environment = env // {
372 PYTHONPATH = "${cfg.package.python.pkgs.makePythonPath cfg.package.propagatedBuildInputs}:${cfg.package}/lib/paperless-ngx/src";
373 };
374 # Allow the web interface to access the private /tmp directory of the server.
375 # This is required to support uploading files via the web interface.
376 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service";
377 };
378
379 users = optionalAttrs (cfg.user == defaultUser) {
380 users.${defaultUser} = {
381 group = defaultUser;
382 uid = config.ids.uids.paperless;
383 home = cfg.dataDir;
384 };
385
386 groups.${defaultUser} = {
387 gid = config.ids.gids.paperless;
388 };
389 };
390 };
391}