1{ config, pkgs, lib, ... }:
2
3with lib;
4let
5 cfg = config.services.paperless-ng;
6
7 defaultUser = "paperless";
8
9 env = {
10 PAPERLESS_DATA_DIR = cfg.dataDir;
11 PAPERLESS_MEDIA_ROOT = cfg.mediaDir;
12 PAPERLESS_CONSUMPTION_DIR = cfg.consumptionDir;
13 GUNICORN_CMD_ARGS = "--bind=${cfg.address}:${toString cfg.port}";
14 } // lib.mapAttrs (_: toString) cfg.extraConfig;
15
16 manage = let
17 setupEnv = lib.concatStringsSep "\n" (mapAttrsToList (name: val: "export ${name}=\"${val}\"") env);
18 in pkgs.writeShellScript "manage" ''
19 ${setupEnv}
20 exec ${cfg.package}/bin/paperless-ng "$@"
21 '';
22
23 # Secure the services
24 defaultServiceConfig = {
25 TemporaryFileSystem = "/:ro";
26 BindReadOnlyPaths = [
27 "/nix/store"
28 "-/etc/resolv.conf"
29 "-/etc/nsswitch.conf"
30 "-/etc/hosts"
31 "-/etc/localtime"
32 "-/run/postgresql"
33 ];
34 BindPaths = [
35 cfg.consumptionDir
36 cfg.dataDir
37 cfg.mediaDir
38 ];
39 CapabilityBoundingSet = "";
40 # ProtectClock adds DeviceAllow=char-rtc r
41 DeviceAllow = "";
42 LockPersonality = true;
43 MemoryDenyWriteExecute = true;
44 NoNewPrivileges = true;
45 PrivateDevices = true;
46 PrivateMounts = true;
47 # Needs to connect to redis
48 # PrivateNetwork = true;
49 PrivateTmp = true;
50 PrivateUsers = true;
51 ProcSubset = "pid";
52 ProtectClock = true;
53 # Breaks if the home dir of the user is in /home
54 # Also does not add much value in combination with the TemporaryFileSystem.
55 # ProtectHome = true;
56 ProtectHostname = true;
57 # Would re-mount paths ignored by temporary root
58 #ProtectSystem = "strict";
59 ProtectControlGroups = true;
60 ProtectKernelLogs = true;
61 ProtectKernelModules = true;
62 ProtectKernelTunables = true;
63 ProtectProc = "invisible";
64 RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
65 RestrictNamespaces = true;
66 RestrictRealtime = true;
67 RestrictSUIDSGID = true;
68 SystemCallArchitectures = "native";
69 SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ];
70 # Does not work well with the temporary root
71 #UMask = "0066";
72 };
73in
74{
75 meta.maintainers = with maintainers; [ earvstedt Flakebi ];
76
77 imports = [
78 (mkRemovedOptionModule [ "services" "paperless"] ''
79 The paperless module has been removed as the upstream project died.
80 Users should migrate to the paperless-ng module (services.paperless-ng).
81 More information can be found in the NixOS 21.11 release notes.
82 '')
83 ];
84
85 options.services.paperless-ng = {
86 enable = mkOption {
87 type = lib.types.bool;
88 default = false;
89 description = ''
90 Enable Paperless-ng.
91
92 When started, the Paperless database is automatically created if it doesn't
93 exist and updated if the Paperless package has changed.
94 Both tasks are achieved by running a Django migration.
95
96 A script to manage the Paperless instance (by wrapping Django's manage.py) is linked to
97 <literal>''${dataDir}/paperless-ng-manage</literal>.
98 '';
99 };
100
101 dataDir = mkOption {
102 type = types.str;
103 default = "/var/lib/paperless";
104 description = "Directory to store the Paperless data.";
105 };
106
107 mediaDir = mkOption {
108 type = types.str;
109 default = "${cfg.dataDir}/media";
110 defaultText = literalExpression ''"''${dataDir}/media"'';
111 description = "Directory to store the Paperless documents.";
112 };
113
114 consumptionDir = mkOption {
115 type = types.str;
116 default = "${cfg.dataDir}/consume";
117 defaultText = literalExpression ''"''${dataDir}/consume"'';
118 description = "Directory from which new documents are imported.";
119 };
120
121 consumptionDirIsPublic = mkOption {
122 type = types.bool;
123 default = false;
124 description = "Whether all users can write to the consumption dir.";
125 };
126
127 passwordFile = mkOption {
128 type = types.nullOr types.path;
129 default = null;
130 example = "/run/keys/paperless-ng-password";
131 description = ''
132 A file containing the superuser password.
133
134 A superuser is required to access the web interface.
135 If unset, you can create a superuser manually by running
136 <literal>''${dataDir}/paperless-ng-manage createsuperuser</literal>.
137
138 The default superuser name is <literal>admin</literal>. To change it, set
139 option <option>extraConfig.PAPERLESS_ADMIN_USER</option>.
140 WARNING: When changing the superuser name after the initial setup, the old superuser
141 will continue to exist.
142
143 To disable login for the web interface, set the following:
144 <literal>extraConfig.PAPERLESS_AUTO_LOGIN_USERNAME = "admin";</literal>.
145 WARNING: Only use this on a trusted system without internet access to Paperless.
146 '';
147 };
148
149 address = mkOption {
150 type = types.str;
151 default = "localhost";
152 description = "Web interface address.";
153 };
154
155 port = mkOption {
156 type = types.port;
157 default = 28981;
158 description = "Web interface port.";
159 };
160
161 extraConfig = mkOption {
162 type = types.attrs;
163 default = {};
164 description = ''
165 Extra paperless-ng config options.
166
167 See <link xlink:href="https://paperless-ng.readthedocs.io/en/latest/configuration.html">the documentation</link>
168 for available options.
169 '';
170 example = literalExpression ''
171 {
172 PAPERLESS_OCR_LANGUAGE = "deu+eng";
173 }
174 '';
175 };
176
177 user = mkOption {
178 type = types.str;
179 default = defaultUser;
180 description = "User under which Paperless runs.";
181 };
182
183 package = mkOption {
184 type = types.package;
185 default = pkgs.paperless-ng;
186 defaultText = literalExpression "pkgs.paperless-ng";
187 description = "The Paperless package to use.";
188 };
189 };
190
191 config = mkIf cfg.enable {
192 # Enable redis if no special url is set
193 services.redis.enable = mkIf (!hasAttr "PAPERLESS_REDIS" env) true;
194
195 systemd.tmpfiles.rules = [
196 "d '${cfg.dataDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
197 "d '${cfg.mediaDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
198 (if cfg.consumptionDirIsPublic then
199 "d '${cfg.consumptionDir}' 777 - - - -"
200 else
201 "d '${cfg.consumptionDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
202 )
203 ];
204
205 systemd.services.paperless-ng-server = {
206 description = "Paperless document server";
207 serviceConfig = defaultServiceConfig // {
208 User = cfg.user;
209 ExecStart = "${cfg.package}/bin/paperless-ng qcluster";
210 Restart = "on-failure";
211 };
212 environment = env;
213 wantedBy = [ "multi-user.target" ];
214 wants = [ "paperless-ng-consumer.service" "paperless-ng-web.service" ];
215
216 preStart = ''
217 ln -sf ${manage} ${cfg.dataDir}/paperless-ng-manage
218
219 # Auto-migrate on first run or if the package has changed
220 versionFile="${cfg.dataDir}/src-version"
221 if [[ $(cat "$versionFile" 2>/dev/null) != ${cfg.package} ]]; then
222 ${cfg.package}/bin/paperless-ng migrate
223 echo ${cfg.package} > "$versionFile"
224 fi
225 ''
226 + optionalString (cfg.passwordFile != null) ''
227 export PAPERLESS_ADMIN_USER="''${PAPERLESS_ADMIN_USER:-admin}"
228 export PAPERLESS_ADMIN_PASSWORD=$(cat "${cfg.dataDir}/superuser-password")
229 superuserState="$PAPERLESS_ADMIN_USER:$PAPERLESS_ADMIN_PASSWORD"
230 superuserStateFile="${cfg.dataDir}/superuser-state"
231
232 if [[ $(cat "$superuserStateFile" 2>/dev/null) != $superuserState ]]; then
233 ${cfg.package}/bin/paperless-ng manage_superuser
234 echo "$superuserState" > "$superuserStateFile"
235 fi
236 '';
237 };
238
239 # Password copying can't be implemented as a privileged preStart script
240 # in 'paperless-ng-server' because 'defaultServiceConfig' limits the filesystem
241 # paths accessible by the service.
242 systemd.services.paperless-ng-copy-password = mkIf (cfg.passwordFile != null) {
243 requiredBy = [ "paperless-ng-server.service" ];
244 before = [ "paperless-ng-server.service" ];
245 serviceConfig = {
246 ExecStart = ''
247 ${pkgs.coreutils}/bin/install --mode 600 --owner '${cfg.user}' --compare \
248 '${cfg.passwordFile}' '${cfg.dataDir}/superuser-password'
249 '';
250 Type = "oneshot";
251 };
252 };
253
254 systemd.services.paperless-ng-consumer = {
255 description = "Paperless document consumer";
256 serviceConfig = defaultServiceConfig // {
257 User = cfg.user;
258 ExecStart = "${cfg.package}/bin/paperless-ng document_consumer";
259 Restart = "on-failure";
260 };
261 environment = env;
262 # Bind to `paperless-ng-server` so that the consumer never runs
263 # during migrations
264 bindsTo = [ "paperless-ng-server.service" ];
265 after = [ "paperless-ng-server.service" ];
266 };
267
268 systemd.services.paperless-ng-web = {
269 description = "Paperless web server";
270 serviceConfig = defaultServiceConfig // {
271 User = cfg.user;
272 ExecStart = ''
273 ${pkgs.python3Packages.gunicorn}/bin/gunicorn \
274 -c ${cfg.package}/lib/paperless-ng/gunicorn.conf.py paperless.asgi:application
275 '';
276 Restart = "on-failure";
277
278 AmbientCapabilities = "CAP_NET_BIND_SERVICE";
279 CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
280 # gunicorn needs setuid
281 SystemCallFilter = defaultServiceConfig.SystemCallFilter ++ [ "@setuid" ];
282 };
283 environment = env // {
284 PATH = mkForce cfg.package.path;
285 PYTHONPATH = "${cfg.package.pythonPath}:${cfg.package}/lib/paperless-ng/src";
286 };
287 # Allow the web interface to access the private /tmp directory of the server.
288 # This is required to support uploading files via the web interface.
289 unitConfig.JoinsNamespaceOf = "paperless-ng-server.service";
290 # Bind to `paperless-ng-server` so that the web server never runs
291 # during migrations
292 bindsTo = [ "paperless-ng-server.service" ];
293 after = [ "paperless-ng-server.service" ];
294 };
295
296 users = optionalAttrs (cfg.user == defaultUser) {
297 users.${defaultUser} = {
298 group = defaultUser;
299 uid = config.ids.uids.paperless;
300 home = cfg.dataDir;
301 };
302
303 groups.${defaultUser} = {
304 gid = config.ids.gids.paperless;
305 };
306 };
307 };
308}