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