1{
2 config,
3 lib,
4 pkgs,
5 utils,
6 ...
7}:
8let
9 inherit (lib)
10 getExe
11 mapAttrs
12 match
13 mkEnableOption
14 mkIf
15 mkPackageOption
16 mkOption
17 types
18 optional
19 optionalString
20 ;
21
22 cfg = config.services.lasuite-docs;
23
24 pythonEnvironment = mapAttrs (
25 _: value:
26 if value == null then
27 "None"
28 else if value == true then
29 "True"
30 else if value == false then
31 "False"
32 else
33 toString value
34 ) cfg.settings;
35
36 proxySuffix = if match "unix:.*" cfg.bind != null then ":" else "";
37
38 commonServiceConfig = {
39 RuntimeDirectory = "lasuite-docs";
40 StateDirectory = "lasuite-docs";
41 WorkingDirectory = "/var/lib/lasuite-docs";
42
43 User = "lasuite-docs";
44 DynamicUser = true;
45 SupplementaryGroups = mkIf cfg.redis.createLocally [
46 config.services.redis.servers.lasuite-docs.group
47 ];
48 # hardening
49 AmbientCapabilities = "";
50 CapabilityBoundingSet = [ "" ];
51 DevicePolicy = "closed";
52 LockPersonality = true;
53 NoNewPrivileges = true;
54 PrivateDevices = true;
55 PrivateTmp = true;
56 PrivateUsers = true;
57 ProcSubset = "pid";
58 ProtectClock = true;
59 ProtectControlGroups = true;
60 ProtectHome = true;
61 ProtectHostname = true;
62 ProtectKernelLogs = true;
63 ProtectKernelModules = true;
64 ProtectKernelTunables = true;
65 ProtectProc = "invisible";
66 ProtectSystem = "strict";
67 RemoveIPC = true;
68 RestrictAddressFamilies = [
69 "AF_INET"
70 "AF_INET6"
71 "AF_UNIX"
72 ];
73 RestrictNamespaces = true;
74 RestrictRealtime = true;
75 RestrictSUIDSGID = true;
76 SystemCallArchitectures = "native";
77 UMask = "0077";
78 };
79in
80{
81 options.services.lasuite-docs = {
82 enable = mkEnableOption "SuiteNumérique Docs";
83
84 backendPackage = mkPackageOption pkgs "lasuite-docs" { };
85
86 frontendPackage = mkPackageOption pkgs "lasuite-docs-frontend" { };
87
88 bind = mkOption {
89 type = types.str;
90 default = "unix:/run/lasuite-docs/gunicorn.sock";
91 example = "127.0.0.1:8000";
92 description = ''
93 The path, host/port or file descriptior to bind the gunicorn socket to.
94
95 See <https://docs.gunicorn.org/en/stable/settings.html#bind> for possible options.
96 '';
97 };
98
99 enableNginx = mkEnableOption "enable and configure Nginx for reverse proxying" // {
100 default = true;
101 };
102
103 secretKeyPath = mkOption {
104 type = types.nullOr types.path;
105 default = null;
106 description = ''
107 Path to the Django secret key.
108
109 The key can be generated using:
110 ```
111 python3 -c 'import secrets; print(secrets.token_hex())'
112 ```
113
114 If not set, the secret key will be automatically generated.
115 '';
116 };
117
118 s3Url = mkOption {
119 type = types.str;
120 description = ''
121 URL of the S3 bucket.
122 '';
123 };
124
125 postgresql = {
126 createLocally = mkOption {
127 type = types.bool;
128 default = false;
129 description = ''
130 Configure local PostgreSQL database server for docs.
131 '';
132 };
133 };
134
135 redis = {
136 createLocally = mkOption {
137 type = types.bool;
138 default = false;
139 description = ''
140 Configure local Redis cache server for docs.
141 '';
142 };
143 };
144
145 collaborationServer = {
146 package = mkPackageOption pkgs "lasuite-docs-collaboration-server" { };
147
148 port = mkOption {
149 type = types.port;
150 default = 4444;
151 description = ''
152 Port used by the collaboration server to listen.
153 '';
154 };
155
156 settings = mkOption {
157 type = types.submodule {
158 freeformType = types.attrsOf (
159 types.oneOf [
160 types.str
161 types.bool
162 ]
163 );
164
165 options = {
166 PORT = mkOption {
167 type = types.str;
168 default = toString cfg.collaborationServer.port;
169 readOnly = true;
170 description = "Port used by collaboration server to listen to";
171 };
172
173 COLLABORATION_BACKEND_BASE_URL = mkOption {
174 type = types.str;
175 default = "https://${cfg.domain}";
176 defaultText = lib.literalExpression "https://\${cfg.domain}";
177 description = "URL to the backend server base";
178 };
179
180 COLLABORATION_SERVER_ORIGIN = mkOption {
181 type = types.str;
182 default = "https://${cfg.domain}";
183 defaultText = lib.literalExpression "https://\${cfg.domain}";
184 description = "Origins allowed to connect to the collaboration server";
185 };
186 };
187 };
188 default = { };
189 example = ''
190 {
191 COLLABORATION_LOGGING = true;
192 }
193 '';
194 description = ''
195 Configuration options of collaboration server.
196
197 See <https://github.com/suitenumerique/docs/blob/v${cfg.collaborationServer.package.version}/docs/env.md>
198 '';
199 };
200 };
201
202 gunicorn = {
203 extraArgs = mkOption {
204 type = types.listOf types.str;
205 default = [
206 "--name=impress"
207 "--workers=3"
208 ];
209 description = ''
210 Extra arguments to pass to the gunicorn process.
211 '';
212 };
213 };
214
215 celery = {
216 extraArgs = mkOption {
217 type = types.listOf types.str;
218 default = [ ];
219 description = ''
220 Extra arguments to pass to the celery process.
221 '';
222 };
223 };
224
225 domain = mkOption {
226 type = types.str;
227 description = ''
228 Domain name of the docs instance.
229 '';
230 };
231
232 settings = mkOption {
233 type = types.submodule {
234 freeformType = types.attrsOf (
235 types.nullOr (
236 types.oneOf [
237 types.str
238 types.bool
239 types.path
240 types.int
241 ]
242 )
243 );
244
245 options = {
246 DJANGO_CONFIGURATION = mkOption {
247 type = types.str;
248 internal = true;
249 default = "Production";
250 description = "The configuration that Django will use";
251 };
252
253 DJANGO_SETTINGS_MODULE = mkOption {
254 type = types.str;
255 internal = true;
256 default = "impress.settings";
257 description = "The configuration module that Django will use";
258 };
259
260 DJANGO_SECRET_KEY_FILE = mkOption {
261 type = types.path;
262 default =
263 if cfg.secretKeyPath == null then "/var/lib/lasuite-docs/django_secret_key" else cfg.secretKeyPath;
264 description = "The path to the file containing Django's secret key";
265 };
266
267 DATA_DIR = mkOption {
268 type = types.path;
269 default = "/var/lib/lasuite-docs";
270 description = "Path to the data directory";
271 };
272
273 DJANGO_ALLOWED_HOSTS = mkOption {
274 type = types.str;
275 default = if cfg.enableNginx then "localhost,127.0.0.1,${cfg.domain}" else "";
276 defaultText = lib.literalExpression ''
277 if cfg.enableNginx then "localhost,127.0.0.1,''${cfg.domain}" else ""
278 '';
279 description = "Comma-separated list of hosts that are able to connect to the server";
280 };
281
282 DB_NAME = mkOption {
283 type = types.str;
284 default = "lasuite-docs";
285 description = "Name of the database";
286 };
287
288 DB_USER = mkOption {
289 type = types.str;
290 default = "lasuite-docs";
291 description = "User of the database";
292 };
293
294 DB_HOST = mkOption {
295 type = types.nullOr types.str;
296 default = if cfg.postgresql.createLocally then "/run/postgresql" else null;
297 description = "Host of the database";
298 };
299
300 REDIS_URL = mkOption {
301 type = types.nullOr types.str;
302 default =
303 if cfg.redis.createLocally then
304 "unix://${config.services.redis.servers.lasuite-docs.unixSocket}?db=0"
305 else
306 null;
307 description = "URL of the redis backend";
308 };
309
310 CELERY_BROKER_URL = mkOption {
311 type = types.nullOr types.str;
312 default =
313 if cfg.redis.createLocally then
314 "redis+socket://${config.services.redis.servers.lasuite-docs.unixSocket}?db=1"
315 else
316 null;
317 description = "URL of the redis backend for celery";
318 };
319 };
320 };
321 default = { };
322 example = ''
323 {
324 DJANGO_ALLOWED_HOSTS = "*";
325 }
326 '';
327 description = ''
328 Configuration options of docs.
329
330 See <https://github.com/suitenumerique/docs/blob/v${cfg.backendPackage.version}/docs/env.md>
331
332 `REDIS_URL` and `CELERY_BROKER_URL` are set if `services.lasuite-docs.redis.createLocally` is true.
333 `DB_HOST` is set if `services.lasuite-docs.postgresql.createLocally` is true.
334 '';
335 };
336
337 environmentFile = mkOption {
338 type = types.nullOr types.path;
339 default = null;
340 description = ''
341 Path to environment file.
342
343 This can be useful to pass secrets to docs via tools like `agenix` or `sops`.
344 '';
345 };
346 };
347
348 config = mkIf cfg.enable {
349 systemd.services.lasuite-docs = {
350 description = "Docs from SuiteNumérique";
351 after = [
352 "network.target"
353 ]
354 ++ (optional cfg.postgresql.createLocally "postgresql.target")
355 ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service");
356 wants =
357 (optional cfg.postgresql.createLocally "postgresql.target")
358 ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service");
359 wantedBy = [ "multi-user.target" ];
360
361 preStart = ''
362 if [ ! -f .version ]; then
363 touch .version
364 fi
365
366 ${optionalString (cfg.secretKeyPath == null) ''
367 if [[ ! -f /var/lib/lasuite-docs/django_secret_key ]]; then
368 (
369 umask 0377
370 tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge /var/lib/lasuite-docs/django_secret_key
371 )
372 fi
373 ''}
374 if [ "${cfg.backendPackage.version}" != "$(cat .version)" ]; then
375 ${getExe cfg.backendPackage} migrate
376 echo -n "${cfg.backendPackage.version}" > .version
377 fi
378 '';
379
380 environment = pythonEnvironment;
381
382 serviceConfig = {
383 BindReadOnlyPaths = "${cfg.backendPackage}/share/static:/var/lib/lasuite-docs/static";
384
385 ExecStart = utils.escapeSystemdExecArgs (
386 [
387 (lib.getExe' cfg.backendPackage "gunicorn")
388 "--bind=${cfg.bind}"
389 ]
390 ++ cfg.gunicorn.extraArgs
391 ++ [ "impress.wsgi:application" ]
392 );
393 EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
394 MemoryDenyWriteExecute = true;
395 }
396 // commonServiceConfig;
397 };
398
399 systemd.services.lasuite-docs-celery = {
400 description = "Docs Celery broker from SuiteNumérique";
401 after = [
402 "network.target"
403 ]
404 ++ (optional cfg.postgresql.createLocally "postgresql.target")
405 ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service");
406 wants =
407 (optional cfg.postgresql.createLocally "postgresql.target")
408 ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service");
409 wantedBy = [ "multi-user.target" ];
410
411 environment = pythonEnvironment;
412
413 serviceConfig = {
414 ExecStart = utils.escapeSystemdExecArgs (
415 [
416 (lib.getExe' cfg.backendPackage "celery")
417 ]
418 ++ cfg.celery.extraArgs
419 ++ [
420 "--app=impress.celery_app"
421 "worker"
422 ]
423 );
424 EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
425 MemoryDenyWriteExecute = true;
426 }
427 // commonServiceConfig;
428 };
429
430 systemd.services.lasuite-docs-collaboration-server = {
431 description = "Docs Collaboration Server from SuiteNumérique";
432 after = [ "network.target" ];
433 wantedBy = [ "multi-user.target" ];
434
435 environment = cfg.collaborationServer.settings;
436
437 serviceConfig = {
438 ExecStart = getExe cfg.collaborationServer.package;
439 }
440 // commonServiceConfig;
441 };
442
443 services.postgresql = mkIf cfg.postgresql.createLocally {
444 enable = true;
445 ensureDatabases = [ "lasuite-docs" ];
446 ensureUsers = [
447 {
448 name = "lasuite-docs";
449 ensureDBOwnership = true;
450 }
451 ];
452 };
453
454 services.redis.servers.lasuite-docs = mkIf cfg.redis.createLocally { enable = true; };
455
456 services.nginx = mkIf cfg.enableNginx {
457 enable = true;
458
459 virtualHosts.${cfg.domain} = {
460 extraConfig = ''
461 error_page 401 /401;
462 error_page 403 /403;
463 error_page 404 /404;
464 '';
465
466 root = cfg.frontendPackage;
467
468 locations."~ '^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$'" = {
469 tryFiles = "$uri /docs/[id]/index.html";
470 };
471
472 locations."/api" = {
473 proxyPass = "http://${cfg.bind}";
474 recommendedProxySettings = true;
475 };
476
477 locations."/admin" = {
478 proxyPass = "http://${cfg.bind}";
479 recommendedProxySettings = true;
480 };
481
482 locations."/collaboration/ws/" = {
483 proxyPass = "http://localhost:${toString cfg.collaborationServer.port}";
484 recommendedProxySettings = true;
485 proxyWebsockets = true;
486 };
487
488 locations."/collaboration/api/" = {
489 proxyPass = "http://localhost:${toString cfg.collaborationServer.port}";
490 recommendedProxySettings = true;
491 };
492
493 locations."/media-auth" = {
494 proxyPass = "http://${cfg.bind}${proxySuffix}/api/v1.0/documents/media-auth/";
495 recommendedProxySettings = true;
496 extraConfig = ''
497 proxy_set_header X-Original-URL $request_uri;
498 proxy_pass_request_body off;
499 proxy_set_header Content-Length "";
500 proxy_set_header X-Original-Method $request_method;
501 '';
502 };
503
504 locations."/media/" = {
505 proxyPass = cfg.s3Url;
506 extraConfig = ''
507 auth_request /media-auth;
508 auth_request_set $authHeader $upstream_http_authorization;
509 auth_request_set $authDate $upstream_http_x_amz_date;
510 auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
511
512 proxy_set_header Authorization $authHeader;
513 proxy_set_header X-Amz-Date $authDate;
514 proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
515
516 add_header Content-Security-Policy "default-src 'none'" always;
517 '';
518 };
519 };
520 };
521 };
522
523 meta = {
524 buildDocsInSandbox = false;
525 maintainers = [ lib.maintainers.soyouzpanda ];
526 };
527}