at master 15 kB view raw
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}