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 defaultFont = "${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf";
11
12 # Don't start a redis instance if the user sets a custom redis connection
13 enableRedis = !hasAttr "PAPERLESS_REDIS" cfg.extraConfig;
14 redisServer = config.services.redis.servers.paperless;
15
16 env = {
17 PAPERLESS_DATA_DIR = cfg.dataDir;
18 PAPERLESS_MEDIA_ROOT = cfg.mediaDir;
19 PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir;
20 PAPERLESS_NLTK_DIR = nltkDir;
21 PAPERLESS_THUMBNAIL_FONT_NAME = defaultFont;
22 GUNICORN_CMD_ARGS = "--bind=${cfg.address}:${toString cfg.port}";
23 } // optionalAttrs (config.time.timeZone != null) {
24 PAPERLESS_TIME_ZONE = config.time.timeZone;
25 } // optionalAttrs enableRedis {
26 PAPERLESS_REDIS = "unix://${redisServer.unixSocket}";
27 } // (
28 lib.mapAttrs (_: toString) cfg.extraConfig
29 );
30
31 manage = pkgs.writeShellScript "manage" ''
32 set -o allexport # Export the following env vars
33 ${lib.toShellVars env}
34 exec ${pkg}/bin/paperless-ngx "$@"
35 '';
36
37 # Secure the services
38 defaultServiceConfig = {
39 ReadWritePaths = [
40 cfg.consumptionDir
41 cfg.dataDir
42 cfg.mediaDir
43 ];
44 CacheDirectory = "paperless";
45 CapabilityBoundingSet = "";
46 # ProtectClock adds DeviceAllow=char-rtc r
47 DeviceAllow = "";
48 LockPersonality = true;
49 MemoryDenyWriteExecute = true;
50 NoNewPrivileges = true;
51 PrivateDevices = true;
52 PrivateMounts = true;
53 PrivateNetwork = true;
54 PrivateTmp = true;
55 PrivateUsers = true;
56 ProtectClock = true;
57 # Breaks if the home dir of the user is in /home
58 # ProtectHome = true;
59 ProtectHostname = true;
60 ProtectSystem = "strict";
61 ProtectControlGroups = true;
62 ProtectKernelLogs = true;
63 ProtectKernelModules = true;
64 ProtectKernelTunables = true;
65 ProtectProc = "invisible";
66 # Don't restrict ProcSubset because django-q requires read access to /proc/stat
67 # to query CPU and memory information.
68 # Note that /proc only contains processes of user `paperless`, so this is safe.
69 # ProcSubset = "pid";
70 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
71 RestrictNamespaces = true;
72 RestrictRealtime = true;
73 RestrictSUIDSGID = true;
74 SupplementaryGroups = optional enableRedis redisServer.user;
75 SystemCallArchitectures = "native";
76 SystemCallFilter = [ "@system-service" "~@privileged @setuid @keyring" ];
77 UMask = "0066";
78 };
79in
80{
81 meta.maintainers = with maintainers; [ erikarvstedt Flakebi leona ];
82
83 imports = [
84 (mkRenamedOptionModule [ "services" "paperless-ng" ] [ "services" "paperless" ])
85 ];
86
87 options.services.paperless = {
88 enable = mkOption {
89 type = lib.types.bool;
90 default = false;
91 description = lib.mdDoc ''
92 Enable Paperless.
93
94 When started, the Paperless database is automatically created if it doesn't
95 exist and updated if the Paperless package has changed.
96 Both tasks are achieved by running a Django migration.
97
98 A script to manage the Paperless instance (by wrapping Django's manage.py) is linked to
99 `''${dataDir}/paperless-manage`.
100 '';
101 };
102
103 dataDir = mkOption {
104 type = types.str;
105 default = "/var/lib/paperless";
106 description = lib.mdDoc "Directory to store the Paperless data.";
107 };
108
109 mediaDir = mkOption {
110 type = types.str;
111 default = "${cfg.dataDir}/media";
112 defaultText = literalExpression ''"''${dataDir}/media"'';
113 description = lib.mdDoc "Directory to store the Paperless documents.";
114 };
115
116 consumptionDir = mkOption {
117 type = types.str;
118 default = "${cfg.dataDir}/consume";
119 defaultText = literalExpression ''"''${dataDir}/consume"'';
120 description = lib.mdDoc "Directory from which new documents are imported.";
121 };
122
123 consumptionDirIsPublic = mkOption {
124 type = types.bool;
125 default = false;
126 description = lib.mdDoc "Whether all users can write to the consumption dir.";
127 };
128
129 passwordFile = mkOption {
130 type = types.nullOr types.path;
131 default = null;
132 example = "/run/keys/paperless-password";
133 description = lib.mdDoc ''
134 A file containing the superuser password.
135
136 A superuser is required to access the web interface.
137 If unset, you can create a superuser manually by running
138 `''${dataDir}/paperless-manage createsuperuser`.
139
140 The default superuser name is `admin`. To change it, set
141 option {option}`extraConfig.PAPERLESS_ADMIN_USER`.
142 WARNING: When changing the superuser name after the initial setup, the old superuser
143 will continue to exist.
144
145 To disable login for the web interface, set the following:
146 `extraConfig.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";`.
147 WARNING: Only use this on a trusted system without internet access to Paperless.
148 '';
149 };
150
151 address = mkOption {
152 type = types.str;
153 default = "localhost";
154 description = lib.mdDoc "Web interface address.";
155 };
156
157 port = mkOption {
158 type = types.port;
159 default = 28981;
160 description = lib.mdDoc "Web interface port.";
161 };
162
163 # FIXME this should become an RFC42-style settings attr
164 extraConfig = mkOption {
165 type = types.attrs;
166 default = { };
167 description = lib.mdDoc ''
168 Extra paperless config options.
169
170 See [the documentation](https://docs.paperless-ngx.com/configuration/)
171 for available options.
172
173 Note that some options such as `PAPERLESS_CONSUMER_IGNORE_PATTERN` expect JSON values. Use `builtins.toJSON` to ensure proper quoting.
174 '';
175 example = literalExpression ''
176 {
177 PAPERLESS_OCR_LANGUAGE = "deu+eng";
178
179 PAPERLESS_DBHOST = "/run/postgresql";
180
181 PAPERLESS_CONSUMER_IGNORE_PATTERN = builtins.toJSON [ ".DS_STORE/*" "desktop.ini" ];
182
183 PAPERLESS_OCR_USER_ARGS = builtins.toJSON {
184 optimize = 1;
185 pdfa_image_compression = "lossless";
186 };
187 };
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 ExecStart = let pythonWithNltk = pkg.python.withPackages (ps: [ ps.nltk ]); in ''
310 ${pythonWithNltk}/bin/python -m nltk.downloader -d '${nltkDir}' punkt snowball_data stopwords
311 '';
312 };
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 = "${pkg}/bin/paperless-ngx document_consumer";
324 Restart = "on-failure";
325 };
326 environment = env;
327 };
328
329 systemd.services.paperless-web = {
330 description = "Paperless web server";
331 # Bind to `paperless-scheduler` so that the web server never runs
332 # during migrations
333 bindsTo = [ "paperless-scheduler.service" ];
334 after = [ "paperless-scheduler.service" ];
335 # Setup PAPERLESS_SECRET_KEY.
336 # If this environment variable is left unset, paperless-ngx defaults
337 # to a well-known value, which is insecure.
338 script = let
339 secretKeyFile = "${cfg.dataDir}/nixos-paperless-secret-key";
340 in ''
341 if [[ ! -f '${secretKeyFile}' ]]; then
342 (
343 umask 0377
344 tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge '${secretKeyFile}'
345 )
346 fi
347 export PAPERLESS_SECRET_KEY=$(cat '${secretKeyFile}')
348 if [[ ! $PAPERLESS_SECRET_KEY ]]; then
349 echo "PAPERLESS_SECRET_KEY is empty, refusing to start."
350 exit 1
351 fi
352 exec ${pkg.python.pkgs.gunicorn}/bin/gunicorn \
353 -c ${pkg}/lib/paperless-ngx/gunicorn.conf.py paperless.asgi:application
354 '';
355 serviceConfig = defaultServiceConfig // {
356 User = cfg.user;
357 Restart = "on-failure";
358
359 # gunicorn needs setuid, liblapack needs mbind
360 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "@setuid mbind" ];
361 # Needs to serve web page
362 PrivateNetwork = false;
363 } // lib.optionalAttrs (cfg.port < 1024) {
364 AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
365 CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
366 };
367 environment = env // {
368 PYTHONPATH = "${pkg.python.pkgs.makePythonPath pkg.propagatedBuildInputs}:${pkg}/lib/paperless-ngx/src";
369 };
370 # Allow the web interface to access the private /tmp directory of the server.
371 # This is required to support uploading files via the web interface.
372 unitConfig.JoinsNamespaceOf = "paperless-task-queue.service";
373 };
374
375 users = optionalAttrs (cfg.user == defaultUser) {
376 users.${defaultUser} = {
377 group = defaultUser;
378 uid = config.ids.uids.paperless;
379 home = cfg.dataDir;
380 };
381
382 groups.${defaultUser} = {
383 gid = config.ids.gids.paperless;
384 };
385 };
386 };
387}