From f3120f071074397fe4b45414c2733f756887d027 Mon Sep 17 00:00:00 2001 From: soyouzpanda Date: Fri, 25 Apr 2025 18:15:21 +0200 Subject: [PATCH] nixos/lasuite-docs: init --- .../manual/release-notes/rl-2511.section.md | 2 + nixos/modules/module-list.nix | 1 + .../services/web-apps/lasuite-docs.nix | 514 ++++++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 nixos/modules/services/web-apps/lasuite-docs.nix diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index 65bac7327ed9..d86c9b80de3a 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -12,6 +12,8 @@ - [gtklock](https://github.com/jovanlanik/gtklock), a GTK-based lockscreen for Wayland. Available as [programs.gtklock](#opt-programs.gtklock.enable). +- [SuiteNumérique Docs](https://github.com/suitenumerique/docs), a collaborative note taking, wiki and documentation web platform and alternative to Notion or Outline. Available as [services.lasuite-docs](#opt-services.lasuite-docs.enable). + ## Backward Incompatibilities {#sec-release-25.11-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index ff65ce2125f1..eefc4480e586 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1579,6 +1579,7 @@ ./services/web-apps/kimai.nix ./services/web-apps/komga.nix ./services/web-apps/lanraragi.nix + ./services/web-apps/lasuite-docs.nix ./services/web-apps/lemmy.nix ./services/web-apps/limesurvey.nix ./services/web-apps/mainsail.nix diff --git a/nixos/modules/services/web-apps/lasuite-docs.nix b/nixos/modules/services/web-apps/lasuite-docs.nix new file mode 100644 index 000000000000..eb1c44c2e87b --- /dev/null +++ b/nixos/modules/services/web-apps/lasuite-docs.nix @@ -0,0 +1,514 @@ +{ + config, + lib, + pkgs, + utils, + ... +}: +let + inherit (lib) + getExe + mapAttrs + mkEnableOption + mkIf + mkPackageOption + mkOption + types + optional + optionalString + ; + + cfg = config.services.lasuite-docs; + + pythonEnvironment = mapAttrs ( + _: value: + if value == null then + "None" + else if value == true then + "True" + else if value == false then + "False" + else + toString value + ) cfg.settings; + + commonServiceConfig = { + RuntimeDirectory = "lasuite-docs"; + StateDirectory = "lasuite-docs"; + WorkingDirectory = "/var/lib/lasuite-docs"; + + User = "lasuite-docs"; + DynamicUser = true; + SupplementaryGroups = mkIf cfg.redis.createLocally [ + config.services.redis.servers.lasuite-docs.group + ]; + # hardening + AmbientCapabilities = ""; + CapabilityBoundingSet = [ "" ]; + DevicePolicy = "closed"; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0077"; + }; +in +{ + options.services.lasuite-docs = { + enable = mkEnableOption "SuiteNumérique Docs"; + + backendPackage = mkPackageOption pkgs "lasuite-docs" { }; + + frontendPackage = mkPackageOption pkgs "lasuite-docs-frontend" { }; + + bind = mkOption { + type = types.str; + default = "unix:/run/lasuite-docs/gunicorn.sock"; + example = "127.0.0.1:8000"; + description = '' + The path, host/port or file descriptior to bind the gunicorn socket to. + + See for possible options. + ''; + }; + + enableNginx = mkEnableOption "enable and configure Nginx for reverse proxying" // { + default = true; + }; + + secretKeyPath = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to the Django secret key. + + The key can be generated using: + ``` + python3 -c 'import secrets; print(secrets.token_hex())' + ``` + + If not set, the secret key will be automatically generated. + ''; + }; + + s3Url = mkOption { + type = types.str; + description = '' + URL of the S3 bucket. + ''; + }; + + postgresql = { + createLocally = mkOption { + type = types.bool; + default = false; + description = '' + Configure local PostgreSQL database server for docs. + ''; + }; + }; + + redis = { + createLocally = mkOption { + type = types.bool; + default = false; + description = '' + Configure local Redis cache server for docs. + ''; + }; + }; + + collaborationServer = { + package = mkPackageOption pkgs "lasuite-docs-collaboration-server" { }; + + port = mkOption { + type = types.port; + default = 4444; + description = '' + Port used by the collaboration server to listen. + ''; + }; + + settings = mkOption { + type = types.submodule { + freeformType = types.attrsOf ( + types.oneOf [ + types.str + types.bool + ] + ); + + options = { + PORT = mkOption { + type = types.str; + default = toString cfg.collaborationServer.port; + readOnly = true; + description = "Port used by collaboration server to listen to"; + }; + + COLLABORATION_BACKEND_BASE_URL = mkOption { + type = types.str; + default = "https://${cfg.domain}"; + defaultText = lib.literalExpression "https://\${cfg.domain}"; + description = "URL to the backend server base"; + }; + }; + }; + default = { }; + example = '' + { + COLLABORATION_LOGGING = true; + } + ''; + description = '' + Configuration options of collaboration server. + + See https://github.com/suitenumerique/docs/blob/v${cfg.collaborationServer.package.version}/docs/env.md + ''; + }; + }; + + gunicorn = { + extraArgs = mkOption { + type = types.listOf types.str; + default = [ + "--name=impress" + "--workers=3" + ]; + description = '' + Extra arguments to pass to the gunicorn process. + ''; + }; + }; + + celery = { + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = '' + Extra arguments to pass to the celery process. + ''; + }; + }; + + domain = mkOption { + type = types.str; + description = '' + Domain name of the docs instance. + ''; + }; + + settings = mkOption { + type = types.submodule { + freeformType = types.attrsOf ( + types.nullOr ( + types.oneOf [ + types.str + types.bool + types.path + types.int + ] + ) + ); + + options = { + DJANGO_CONFIGURATION = mkOption { + type = types.str; + internal = true; + default = "Production"; + description = "The configuration that Django will use"; + }; + + DJANGO_SETTINGS_MODULE = mkOption { + type = types.str; + internal = true; + default = "impress.settings"; + description = "The configuration module that Django will use"; + }; + + DJANGO_SECRET_KEY_FILE = mkOption { + type = types.path; + default = + if cfg.secretKeyPath == null then "/var/lib/lasuite-docs/django_secret_key" else cfg.secretKeyPath; + description = "The path to the file containing Django's secret key"; + }; + + DATA_DIR = mkOption { + type = types.path; + default = "/var/lib/lasuite-docs"; + description = "Path to the data directory"; + }; + + DJANGO_ALLOWED_HOSTS = mkOption { + type = types.str; + default = if cfg.enableNginx then "localhost,127.0.0.1,${cfg.domain}" else ""; + defaultText = lib.literalExpression '' + if cfg.enableNginx then "localhost,127.0.0.1,$${cfg.domain}" else "" + ''; + description = "Comma-separated list of hosts that are able to connect to the server"; + }; + + DB_NAME = mkOption { + type = types.str; + default = "lasuite-docs"; + description = "Name of the database"; + }; + + DB_USER = mkOption { + type = types.str; + default = "lasuite-docs"; + description = "User of the database"; + }; + + DB_HOST = mkOption { + type = types.nullOr types.str; + default = if cfg.postgresql.createLocally then "/run/postgresql" else null; + description = "Host of the database"; + }; + + REDIS_URL = mkOption { + type = types.nullOr types.str; + default = + if cfg.redis.createLocally then + "unix://${config.services.redis.servers.lasuite-docs.unixSocket}?db=0" + else + null; + description = "URL of the redis backend"; + }; + + CELERY_BROKER_URL = mkOption { + type = types.nullOr types.str; + default = + if cfg.redis.createLocally then + "redis+socket://${config.services.redis.servers.lasuite-docs.unixSocket}?db=1" + else + null; + description = "URL of the redis backend for celery"; + }; + }; + }; + default = { }; + example = '' + { + DJANGO_ALLOWED_HOSTS = "*"; + } + ''; + description = '' + Configuration options of docs. + + See https://github.com/suitenumerique/docs/blob/v${cfg.backendPackage.version}/docs/env.md + + `REDIS_URL` and `CELERY_BROKER_URL` are set if `services.lasuite-docs.redis.createLocally` is true. + `DB_HOST` is set if `services.lasuite-docs.postgresql.createLocally` is true. + ''; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to environment file. + + This can be useful to pass secrets to docs via tools like `agenix` or `sops`. + ''; + }; + }; + + config = mkIf cfg.enable { + systemd.services.lasuite-docs = { + description = "Docs from SuiteNumérique"; + after = + [ "network.target" ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wantedBy = [ "multi-user.target" ]; + + preStart = '' + ln -sfT ${cfg.backendPackage}/share/static /var/lib/lasuite-docs/static + + if [ ! -f .version ]; then + touch .version + fi + + if [ "${cfg.backendPackage.version}" != "$(cat .version)" ]; then + ${getExe cfg.backendPackage} migrate + echo -n "${cfg.backendPackage.version}" > .version + fi + ${optionalString (cfg.secretKeyPath == null) '' + if [[ ! -f /var/lib/lasuite-docs/django_secret_key ]]; then + ( + umask 0377 + tr -dc A-Za-z0-9 < /dev/urandom | head -c64 | ${pkgs.moreutils}/bin/sponge /var/lib/lasuite-docs/django_secret_key + ) + fi + ''} + ''; + + environment = pythonEnvironment; + + serviceConfig = { + ExecStart = utils.escapeSystemdExecArgs ( + [ + (lib.getExe' cfg.backendPackage "gunicorn") + "--bind=${cfg.bind}" + ] + ++ cfg.gunicorn.extraArgs + ++ [ "impress.wsgi:application" ] + ); + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; + MemoryDenyWriteExecute = true; + } // commonServiceConfig; + }; + + systemd.services.lasuite-docs-celery = { + description = "Docs Celery broker from SuiteNumérique"; + after = + [ "network.target" ] + ++ (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wants = + (optional cfg.postgresql.createLocally "postgresql.service") + ++ (optional cfg.redis.createLocally "redis-lasuite-docs.service"); + wantedBy = [ "multi-user.target" ]; + + environment = pythonEnvironment; + + serviceConfig = { + ExecStart = utils.escapeSystemdExecArgs ( + [ + (lib.getExe' cfg.backendPackage "celery") + ] + ++ cfg.celery.extraArgs + ++ [ + "--app=impress.celery_app" + "worker" + ] + ); + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; + MemoryDenyWriteExecute = true; + } // commonServiceConfig; + }; + + systemd.services.lasuite-docs-collaboration-server = { + description = "Docs Collaboration Server from SuiteNumérique"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + environment = cfg.collaborationServer.settings; + + serviceConfig = { + ExecStart = getExe cfg.collaborationServer.package; + } // commonServiceConfig; + }; + + services.postgresql = mkIf cfg.postgresql.createLocally { + enable = true; + ensureDatabases = [ "lasuite-docs" ]; + ensureUsers = [ + { + name = "lasuite-docs"; + ensureDBOwnership = true; + } + ]; + }; + + services.redis.servers.lasuite-docs = mkIf cfg.redis.createLocally { enable = true; }; + + services.nginx = mkIf cfg.enableNginx { + enable = true; + + virtualHosts.${cfg.domain} = { + extraConfig = '' + error_page 401 /401; + error_page 403 /403; + error_page 404 /404; + ''; + + root = cfg.frontendPackage; + + locations."~ '^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$'" = { + tryFiles = "$uri /docs/[id]/index.html"; + }; + + locations."/api" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + }; + + locations."/admin" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + }; + + locations."/collaboration/ws/" = { + proxyPass = "http://localhost:${toString cfg.collaborationServer.port}"; + recommendedProxySettings = true; + proxyWebsockets = true; + }; + + locations."/collaboration/api/" = { + proxyPass = "http://localhost:${toString cfg.collaborationServer.port}"; + recommendedProxySettings = true; + }; + + locations."/media-auth" = { + proxyPass = "http://${cfg.bind}"; + recommendedProxySettings = true; + extraConfig = '' + rewrite $/(.*)^ /api/v1.0/documents/$1 break; + proxy_set_header X-Original-URL $request_uri; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-Method $request_method; + ''; + }; + + locations."/media/" = { + proxyPass = cfg.s3Url; + recommendedProxySettings = true; + extraConfig = '' + auth_request /media-auth; + auth_request_set $authHeader $upstream_http_authorization; + auth_request_set $authDate $upstream_http_x_amz_date; + auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256; + + proxy_set_header Authorization $authHeader; + proxy_set_header X-Amz-Date $authDate; + proxy_set_header X-Amz-Content-SHA256 $authContentSha256; + + add_header Content-Security-Policy "default-src 'none'" always; + ''; + }; + }; + }; + }; + + meta = { + buildDocsInSandbox = false; + maintainers = [ lib.maintainers.soyouzpanda ]; + }; +}