diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index b18dbef5fe7f3..534391cab8a6b 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -14,6 +14,8 @@ - [Kimai](https://www.kimai.org/), a web-based multi-user time-tracking application. Available as [services.kimai](option.html#opt-services.kimai). +- [Froide-Govplan](https://github.com/okfde/froide-govplan), a web application government planer. Available as [services.froide-govplan](#opt-services.froide-govplan.enable). + ## Backward Incompatibilities {#sec-release-25.05-incompatibilities} diff --git a/nixos/modules/services/web-apps/froide-govplan.nix b/nixos/modules/services/web-apps/froide-govplan.nix new file mode 100644 index 0000000000000..dfa0857d316b4 --- /dev/null +++ b/nixos/modules/services/web-apps/froide-govplan.nix @@ -0,0 +1,195 @@ +{ + config, + lib, + pkgs, + ... +}: +let + + cfg = config.services.froide-govplan; + format = pkgs.formats.toml { }; + + # Service hardening + defaultServiceConfig = { + # Secure the services + ReadWritePaths = [ "/var/lib/froide-govplan" ]; + CacheDirectory = "froide-govplan"; + CapabilityBoundingSet = ""; + # ProtectClock adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateNetwork = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectHome = true; + ProtectHostname = true; + ProtectSystem = "strict"; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged @setuid @keyring" ]; + UMask = "0066"; + }; + +in +{ + options.services.froide-govplan = { + + enable = lib.mkEnableOption "Gouvernment planer web app Govplan"; + + package = lib.mkPackageOption pkgs "froide-govplan" { }; + + address = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = "Web interface address."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "Web interface port."; + }; + + dataDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/froide-govplan"; + description = "Directory to store the Froide-Govplan server data."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Whether to open ports in the firewall for the server. + ''; + }; + + settings = lib.mkOption { + default = { }; + description = '' + IMAP authentication configuration for rspamd-trainer. For supplying + the IMAP password, use the `secrets` option. + ''; + type = lib.types.submodule { freeformType = format.type; }; + example = lib.literalExpression '' + { + HOST = "localhost"; + USERNAME = "spam@example.com"; + INBOXPREFIX = "INBOX/"; + } + ''; + }; + + secrets = lib.mkOption { + type = with lib.types; listOf path; + description = '' + A list of files containing the various secrets. Should be in the + format expected by systemd's `EnvironmentFile` directory. For the + IMAP account password use `PASSWORD = mypassword`. + ''; + default = [ ]; + }; + + }; + + config = lib.mkIf cfg.enable { + + services.postgresql = { + enable = true; + ensureDatabases = [ "govplan" ]; + ensureUsers = [ + { + name = "govplan"; + ensureDBOwnership = true; + } + ]; + extraPlugins = ps: with ps; [ postgis ]; + authentication = '' + host govplan govplan localhost trust + ''; + initialScript = pkgs.writeText "backend-initScript" '' + ALTER USER govplan WITH SUPERUSER; + ''; + }; + + systemd = { + services = { + + postgresql.serviceConfig.ExecStartPost = + let + sqlFile = pkgs.writeText "immich-pgvectors-setup.sql" '' + ALTER USER govplan WITH SUPERUSER; + #CREATE EXTENSION IF NOT EXISTS postgis; + #ALTER SCHEMA govplan OWNER TO govplan; + #ALTER EXTENSION govplan UPDATE; + ''; + in + [ + '' + ${lib.getExe' config.services.postgresql.package "psql"} -d govplan -f "${sqlFile}" + '' + ]; + + froide-govplan = { + description = "Gouvernment planer Govplan"; + serviceConfig = { + ExecStart = "${pkgs.froide-govplan}/bin/froide-govplan runserver ${cfg.address}:${toString cfg.port}"; + WorkingDirectory = "/var/lib/froide-govplan"; + StateDirectory = [ "froide-govplan" ]; + DynamicUser = true; + EnvironmentFile = [ + (format.generate "froide-govplan-env" cfg.settings) + cfg.secrets + ]; + }; + after = [ "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + preStart = '' + # Auto-migrate on first run or if the package has changed + versionFile="/var/lib/froide-govplan/src-version" + version=$(cat "$versionFile" 2>/dev/null || echo 0) + + if [[ $version != ${cfg.package.version} ]]; then + ${cfg.package}/bin/froide-govplan migrate + ${cfg.package}/bin/froide-govplan migrate djangocms_alias + + # Parse old version string format for backwards compatibility + version=$(echo "$version" | grep -ohP '[^-]+$') + + versionLessThan() { + target=$1 + [[ $({ echo "$version"; echo "$target"; } | sort -V | head -1) != "$target" ]] + } + + echo ${cfg.package.version} > "$versionFile" + fi + + ''; + }; + }; + + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + + }; + + meta.maintainers = with lib.maintainers; [ onny ]; + +} diff --git a/nixos/tests/froide-govplan.nix b/nixos/tests/froide-govplan.nix new file mode 100644 index 0000000000000..8b705c5563657 --- /dev/null +++ b/nixos/tests/froide-govplan.nix @@ -0,0 +1,109 @@ +import ./make-test-python.nix ( + { lib, ... }: + { + name = "paperless"; + meta.maintainers = with lib.maintainers; [ + leona + SuperSandro2000 + erikarvstedt + ]; + + nodes = + let + self = { + simple = + { pkgs, ... }: + { + environment.systemPackages = with pkgs; [ + imagemagick + jq + ]; + services.paperless = { + enable = true; + passwordFile = builtins.toFile "password" "admin"; + }; + }; + postgres = + { config, pkgs, ... }: + { + imports = [ self.simple ]; + services.postgresql = { + enable = true; + ensureDatabases = [ "paperless" ]; + ensureUsers = [ + { + name = config.services.paperless.user; + ensureDBOwnership = true; + } + ]; + }; + services.paperless.settings = { + PAPERLESS_DBHOST = "/run/postgresql"; + PAPERLESS_OCR_LANGUAGE = "deu"; + }; + }; + }; + in + self; + + testScript = '' + import json + + def test_paperless(node): + node.wait_for_unit("paperless-consumer.service") + + with subtest("Add a document via the file system"): + node.succeed( + "convert -size 400x40 xc:white -font 'DejaVu-Sans' -pointsize 20 -fill black " + "-annotate +5+20 'hello world 16-10-2005' /var/lib/paperless/consume/doc.png" + ) + + with subtest("Web interface gets ready"): + node.wait_for_unit("paperless-web.service") + # Wait until server accepts connections + node.wait_until_succeeds("curl -fs localhost:28981") + + # Required for consuming documents via the web interface + with subtest("Task-queue gets ready"): + node.wait_for_unit("paperless-task-queue.service") + + with subtest("Add a png document via the web interface"): + node.succeed( + "convert -size 400x40 xc:white -font 'DejaVu-Sans' -pointsize 20 -fill black " + "-annotate +5+20 'hello web 16-10-2005' /tmp/webdoc.png" + ) + node.wait_until_succeeds("curl -u admin:admin -F document=@/tmp/webdoc.png -fs localhost:28981/api/documents/post_document/") + + with subtest("Add a txt document via the web interface"): + node.succeed( + "echo 'hello web 16-10-2005' > /tmp/webdoc.txt" + ) + node.wait_until_succeeds("curl -u admin:admin -F document=@/tmp/webdoc.txt -fs localhost:28981/api/documents/post_document/") + + with subtest("Documents are consumed"): + node.wait_until_succeeds( + "(($(curl -u admin:admin -fs localhost:28981/api/documents/ | jq .count) == 3))" + ) + docs = json.loads(node.succeed("curl -u admin:admin -fs localhost:28981/api/documents/"))['results'] + assert "2005-10-16" in docs[0]['created'] + assert "2005-10-16" in docs[1]['created'] + assert "2005-10-16" in docs[2]['created'] + + # Detects gunicorn issues, see PR #190888 + with subtest("Document metadata can be accessed"): + metadata = json.loads(node.succeed("curl -u admin:admin -fs localhost:28981/api/documents/1/metadata/")) + assert "original_checksum" in metadata + + metadata = json.loads(node.succeed("curl -u admin:admin -fs localhost:28981/api/documents/2/metadata/")) + assert "original_checksum" in metadata + + metadata = json.loads(node.succeed("curl -u admin:admin -fs localhost:28981/api/documents/3/metadata/")) + assert "original_checksum" in metadata + + test_paperless(simple) + simple.send_monitor_command("quit") + simple.wait_for_shutdown() + test_paperless(postgres) + ''; + } +)