1{ config, pkgs, lib, ... }:
2
3with lib;
4let
5 cfg = config.services.paperless;
6 pkg = cfg.package;
7
8 defaultUser = "paperless";
9 nltkDir = "/var/cache/paperless/nltk";
10
11 # Don't start a redis instance if the user sets a custom redis connection
12 enableRedis = !hasAttr "PAPERLESS_REDIS" cfg.extraConfig;
13 redisServer = config.services.redis.servers.paperless;
14
15 env = {
16 PAPERLESS_DATA_DIR = cfg.dataDir;
17 PAPERLESS_MEDIA_ROOT = cfg.mediaDir;
18 PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir;
19 PAPERLESS_NLTK_DIR = nltkDir;
20 GUNICORN_CMD_ARGS = "--bind=${cfg.address}:${toString cfg.port}";
21 } // optionalAttrs (config.time.timeZone != null) {
22 PAPERLESS_TIME_ZONE = config.time.timeZone;
23 } // optionalAttrs enableRedis {
24 PAPERLESS_REDIS = "unix://${redisServer.unixSocket}";
25 } // (
26 lib.mapAttrs (_: toString) cfg.extraConfig
27 );
28
29 manage =
30 let
31 setupEnv = lib.concatStringsSep "\n" (mapAttrsToList (name: val: "export ${name}=\"${val}\"") env);
32 in
33 pkgs.writeShellScript "manage" ''
34 ${setupEnv}
35 exec ${pkg}/bin/paperless-ngx "$@"
36 '';
37
38 # Secure the services
39 defaultServiceConfig = {
40 TemporaryFileSystem = "/:ro";
41 BindReadOnlyPaths = [
42 "/nix/store"
43 "-/etc/resolv.conf"
44 "-/etc/nsswitch.conf"
45 "-/etc/hosts"
46 "-/etc/localtime"
47 "-/run/postgresql"
48 ] ++ (optional enableRedis redisServer.unixSocket);
49 BindPaths = [
50 cfg.consumptionDir
51 cfg.dataDir
52 cfg.mediaDir
53 ];
54 CacheDirectory = "paperless";
55 CapabilityBoundingSet = "";
56 # ProtectClock adds DeviceAllow=char-rtc r
57 DeviceAllow = "";
58 LockPersonality = true;
59 MemoryDenyWriteExecute = true;
60 NoNewPrivileges = true;
61 PrivateDevices = true;
62 PrivateMounts = true;
63 PrivateNetwork = true;
64 PrivateTmp = true;
65 PrivateUsers = true;
66 ProtectClock = true;
67 # Breaks if the home dir of the user is in /home
68 # Also does not add much value in combination with the TemporaryFileSystem.
69 # ProtectHome = true;
70 ProtectHostname = true;
71 # Would re-mount paths ignored by temporary root
72 #ProtectSystem = "strict";
73 ProtectControlGroups = true;
74 ProtectKernelLogs = true;
75 ProtectKernelModules = true;
76 ProtectKernelTunables = true;
77 ProtectProc = "invisible";
78 # Don't restrict ProcSubset because django-q requires read access to /proc/stat
79 # to query CPU and memory information.
80 # Note that /proc only contains processes of user `paperless`, so this is safe.
81 # ProcSubset = "pid";
82 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
83 RestrictNamespaces = true;
84 RestrictRealtime = true;
85 RestrictSUIDSGID = true;
86 SupplementaryGroups = optional enableRedis redisServer.user;
87 SystemCallArchitectures = "native";
88 SystemCallFilter = [ "@system-service" "~@privileged @setuid @keyring" ];
89 # Does not work well with the temporary root
90 #UMask = "0066";
91 };
92in
93{
94 meta.maintainers = with maintainers; [ erikarvstedt Flakebi ];
95
96 imports = [
97 (mkRenamedOptionModule [ "services" "paperless-ng" ] [ "services" "paperless" ])
98 ];
99
100 options.services.paperless = {
101 enable = mkOption {
102 type = lib.types.bool;
103 default = false;
104 description = lib.mdDoc ''
105 Enable Paperless.
106
107 When started, the Paperless database is automatically created if it doesn't
108 exist and updated if the Paperless package has changed.
109 Both tasks are achieved by running a Django migration.
110
111 A script to manage the Paperless instance (by wrapping Django's manage.py) is linked to
112 `''${dataDir}/paperless-manage`.
113 '';
114 };
115
116 dataDir = mkOption {
117 type = types.str;
118 default = "/var/lib/paperless";
119 description = lib.mdDoc "Directory to store the Paperless data.";
120 };
121
122 mediaDir = mkOption {
123 type = types.str;
124 default = "${cfg.dataDir}/media";
125 defaultText = literalExpression ''"''${dataDir}/media"'';
126 description = lib.mdDoc "Directory to store the Paperless documents.";
127 };
128
129 consumptionDir = mkOption {
130 type = types.str;
131 default = "${cfg.dataDir}/consume";
132 defaultText = literalExpression ''"''${dataDir}/consume"'';
133 description = lib.mdDoc "Directory from which new documents are imported.";
134 };
135
136 consumptionDirIsPublic = mkOption {
137 type = types.bool;
138 default = false;
139 description = lib.mdDoc "Whether all users can write to the consumption dir.";
140 };
141
142 passwordFile = mkOption {
143 type = types.nullOr types.path;
144 default = null;
145 example = "/run/keys/paperless-password";
146 description = lib.mdDoc ''
147 A file containing the superuser password.
148
149 A superuser is required to access the web interface.
150 If unset, you can create a superuser manually by running
151 `''${dataDir}/paperless-manage createsuperuser`.
152
153 The default superuser name is `admin`. To change it, set
154 option {option}`extraConfig.PAPERLESS_ADMIN_USER`.
155 WARNING: When changing the superuser name after the initial setup, the old superuser
156 will continue to exist.
157
158 To disable login for the web interface, set the following:
159 `extraConfig.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";`.
160 WARNING: Only use this on a trusted system without internet access to Paperless.
161 '';
162 };
163
164 address = mkOption {
165 type = types.str;
166 default = "localhost";
167 description = lib.mdDoc "Web interface address.";
168 };
169
170 port = mkOption {
171 type = types.port;
172 default = 28981;
173 description = lib.mdDoc "Web interface port.";
174 };
175
176 extraConfig = mkOption {
177 type = types.attrs;
178 default = { };
179 description = lib.mdDoc ''
180 Extra paperless config options.
181
182 See [the documentation](https://paperless-ngx.readthedocs.io/en/latest/configuration.html)
183 for available options.
184 '';
185 example = {
186 PAPERLESS_OCR_LANGUAGE = "deu+eng";
187 PAPERLESS_DBHOST = "/run/postgresql";
188 };
189 };
190
191 user = mkOption {
192 type = types.str;
193 default = defaultUser;
194 description = lib.mdDoc "User under which Paperless runs.";
195 };
196
197 package = mkOption {
198 type = types.package;
199 default = pkgs.paperless-ngx;
200 defaultText = literalExpression "pkgs.paperless-ngx";
201 description = lib.mdDoc "The Paperless package to use.";
202 };
203 };
204
205 config = mkIf cfg.enable {
206 services.redis.servers.paperless.enable = mkIf enableRedis true;
207
208 systemd.tmpfiles.rules = [
209 "d '${cfg.dataDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
210 "d '${cfg.mediaDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
211 (if cfg.consumptionDirIsPublic then
212 "d '${cfg.consumptionDir}' 777 - - - -"
213 else
214 "d '${cfg.consumptionDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
215 )
216 ];
217
218 systemd.services.paperless-scheduler = {
219 description = "Paperless Celery Beat";
220 wantedBy = [ "multi-user.target" ];
221 wants = [ "paperless-consumer.service" "paperless-web.service" "paperless-task-queue.service" ];
222 serviceConfig = defaultServiceConfig // {
223 User = cfg.user;
224 ExecStart = "${pkg}/bin/celery --app paperless beat --loglevel INFO";
225 Restart = "on-failure";
226 };
227 environment = env;
228
229 preStart = ''
230 ln -sf ${manage} ${cfg.dataDir}/paperless-manage
231
232 # Auto-migrate on first run or if the package has changed
233 versionFile="${cfg.dataDir}/src-version"
234 version=$(cat "$versionFile" 2>/dev/null || echo 0)
235
236 if [[ $version != ${pkg.version} ]]; then
237 ${pkg}/bin/paperless-ngx migrate
238
239 # Parse old version string format for backwards compatibility
240 version=$(echo "$version" | grep -ohP '[^-]+$')
241
242 versionLessThan() {
243 target=$1
244 [[ $({ echo "$version"; echo "$target"; } | sort -V | head -1) != "$target" ]]
245 }
246
247 if versionLessThan 1.12.0; then
248 # Reindex documents as mentioned in https://github.com/paperless-ngx/paperless-ngx/releases/tag/v1.12.1
249 echo "Reindexing documents, to allow searching old comments. Required after the 1.12.x upgrade."
250 ${pkg}/bin/paperless-ngx document_index reindex
251 fi
252
253 echo ${pkg.version} > "$versionFile"
254 fi
255 ''
256 + optionalString (cfg.passwordFile != null) ''
257 export PAPERLESS_ADMIN_USER="''${PAPERLESS_ADMIN_USER:-admin}"
258 export PAPERLESS_ADMIN_PASSWORD=$(cat "${cfg.dataDir}/superuser-password")
259 superuserState="$PAPERLESS_ADMIN_USER:$PAPERLESS_ADMIN_PASSWORD"
260 superuserStateFile="${cfg.dataDir}/superuser-state"
261
262 if [[ $(cat "$superuserStateFile" 2>/dev/null) != $superuserState ]]; then
263 ${pkg}/bin/paperless-ngx manage_superuser
264 echo "$superuserState" > "$superuserStateFile"
265 fi
266 '';
267 } // optionalAttrs enableRedis {
268 after = [ "redis-paperless.service" ];
269 };
270
271 systemd.services.paperless-task-queue = {
272 description = "Paperless Celery Workers";
273 after = [ "paperless-scheduler.service" ];
274 serviceConfig = defaultServiceConfig // {
275 User = cfg.user;
276 ExecStart = "${pkg}/bin/celery --app paperless worker --loglevel INFO";
277 Restart = "on-failure";
278 # The `mbind` syscall is needed for running the classifier.
279 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "mbind" ];
280 # Needs to talk to mail server for automated import rules
281 PrivateNetwork = false;
282 };
283 environment = env;
284 };
285
286 # Reading the user-provided password file requires root access
287 systemd.services.paperless-copy-password = mkIf (cfg.passwordFile != null) {
288 requiredBy = [ "paperless-scheduler.service" ];
289 before = [ "paperless-scheduler.service" ];
290 serviceConfig = {
291 ExecStart = ''
292 ${pkgs.coreutils}/bin/install --mode 600 --owner '${cfg.user}' --compare \
293 '${cfg.passwordFile}' '${cfg.dataDir}/superuser-password'
294 '';
295 Type = "oneshot";
296 };
297 };
298
299 # Download NLTK corpus data
300 systemd.services.paperless-download-nltk-data = {
301 wantedBy = [ "paperless-scheduler.service" ];
302 before = [ "paperless-scheduler.service" ];
303 after = [ "network-online.target" ];
304 serviceConfig = defaultServiceConfig // {
305 User = cfg.user;
306 Type = "oneshot";
307 # Enable internet access
308 PrivateNetwork = false;
309 # Restrict write access
310 BindPaths = [];
311 BindReadOnlyPaths = [
312 "/nix/store"
313 "-/etc/resolv.conf"
314 "-/etc/nsswitch.conf"
315 "-/etc/ssl/certs"
316 "-/etc/static/ssl/certs"
317 "-/etc/hosts"
318 "-/etc/localtime"
319 ];
320 ExecStart = let pythonWithNltk = pkg.python.withPackages (ps: [ ps.nltk ]); in ''
321 ${pythonWithNltk}/bin/python -m nltk.downloader -d '${nltkDir}' punkt snowball_data stopwords
322 '';
323 };
324 };
325
326 systemd.services.paperless-consumer = {
327 description = "Paperless document consumer";
328 # Bind to `paperless-scheduler` so that the consumer never runs
329 # during migrations
330 bindsTo = [ "paperless-scheduler.service" ];
331 after = [ "paperless-scheduler.service" ];
332 serviceConfig = defaultServiceConfig // {
333 User = cfg.user;
334 ExecStart = "${pkg}/bin/paperless-ngx document_consumer";
335 Restart = "on-failure";
336 };
337 environment = env;
338 };
339
340 systemd.services.paperless-web = {
341 description = "Paperless web server";
342 # Bind to `paperless-scheduler` so that the web server never runs
343 # during migrations
344 bindsTo = [ "paperless-scheduler.service" ];
345 after = [ "paperless-scheduler.service" ];
346 serviceConfig = defaultServiceConfig // {
347 User = cfg.user;
348 ExecStart = ''
349 ${pkg.python.pkgs.gunicorn}/bin/gunicorn \
350 -c ${pkg}/lib/paperless-ngx/gunicorn.conf.py paperless.asgi:application
351 '';
352 Restart = "on-failure";
353
354 # gunicorn needs setuid, liblapack needs mbind
355 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "@setuid mbind" ];
356 # Needs to serve web page
357 PrivateNetwork = false;
358 } // lib.optionalAttrs (cfg.port < 1024) {
359 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
360 CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
361 };
362 environment = env // {
363 PATH = mkForce pkg.path;
364 PYTHONPATH = "${pkg.python.pkgs.makePythonPath pkg.propagatedBuildInputs}:${pkg}/lib/paperless-ngx/src";
365 };
366 # Allow the web interface to access the private /tmp directory of the server.
367 # This is required to support uploading files via the web interface.
368 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service";
369 };
370
371 users = optionalAttrs (cfg.user == defaultUser) {
372 users.${defaultUser} = {
373 group = defaultUser;
374 uid = config.ids.uids.paperless;
375 home = cfg.dataDir;
376 };
377
378 groups.${defaultUser} = {
379 gid = config.ids.gids.paperless;
380 };
381 };
382 };
383}