diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 957f80a7d12b..b20d1902c5c9 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -14,6 +14,8 @@ designed to run on affordable, low-power devices. Available as [services.meshtasticd] (#opt-services.meshtasticd.enable). +- [Goupile](https://goupile.org/en), an open-source design tool for secure forms including Clinical Report Forms (eCRF). Available as [services.goupile](#opt-services.goupile.enable). + - [knot-resolver](https://www.knot-resolver.cz/) in version 6. Available as `services.knot-resolver`. A module for knot-resolver 5 was already available as `services.kresd`. - [ImmichFrame](https://immichframe.dev/), display your photos from Immich as a digital photo frame. Available as `services.immichframe`. diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index fcae71655603..7ed613ef87f0 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1651,6 +1651,7 @@ ./services/web-apps/goatcounter.nix ./services/web-apps/gotify-server.nix ./services/web-apps/gotosocial.nix + ./services/web-apps/goupile.nix ./services/web-apps/grav.nix ./services/web-apps/grocy.nix ./services/web-apps/guacamole-client.nix diff --git a/nixos/modules/services/web-apps/goupile.nix b/nixos/modules/services/web-apps/goupile.nix new file mode 100644 index 000000000000..0cb986e1c7f4 --- /dev/null +++ b/nixos/modules/services/web-apps/goupile.nix @@ -0,0 +1,147 @@ +{ + lib, + pkgs, + config, + ... +}: +let + cfg = config.services.goupile; + settingsFormat = pkgs.formats.ini { }; +in +{ + options.services.goupile = { + enable = lib.mkEnableOption "Goupile server"; + package = lib.mkPackageOption pkgs "goupile" { }; + + enableSandbox = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable the sandbox option."; + }; + + settings = lib.mkOption { + type = lib.types.submodule { + freeformType = settingsFormat.type; + options = { + HTTP.Port = lib.mkOption { + type = lib.types.port; + default = 8889; + description = "The port goupile runs on"; + }; + Data.RootDirectory = lib.mkOption { + type = lib.types.str; + default = "/var/lib/goupile"; + description = "Goupile's data directory."; + }; + }; + }; + default = { }; # default will be lost for submodules if overriden + example = lib.literalExpression '' + { + HTTP.Port = 8888; + } + ''; + description = '' + The options for `systemd.services.goupile` in ini format. + + The configuration options available can be found here + https://github.com/Koromix/rygel/blob/goupile/3.11.1/src/goupile/server/admin.cc#L41 + ''; + }; + + configFile = lib.mkOption { + type = lib.types.path; + description = '' + The configuration file to be passed to goupile server. + + By default the configuration file is created from `services.goupile.settings`. + ''; + }; + + hostName = lib.mkOption { + type = lib.types.str; + default = "goupile"; + description = "Nginx service name for goupile service."; + }; + }; + config = lib.mkIf cfg.enable ( + lib.mkMerge [ + { + services.nginx = { + enable = lib.mkDefault true; + virtualHosts.${cfg.hostName} = { + locations."/".proxyPass = "http://${cfg.hostName}:${builtins.toString cfg.settings.HTTP.Port}"; + }; + }; + } + { + services.goupile.configFile = settingsFormat.generate "goupile.ini" cfg.settings; + } + { + systemd.services.goupile = { + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + documentation = [ "https://goupile.org/en" ]; + description = "Goupile eCRF"; + + serviceConfig = { + ExecStart = '' + ${lib.getExe cfg.package} \ + ${lib.optionalString cfg.enableSandbox "--sandbox"} \ + -C ${cfg.configFile} + ''; + + DynamicUser = true; + + RuntimeDirectory = "goupile"; + RuntimeDirectoryPreserve = "yes"; + StateDirectory = "goupile"; + UMask = 0077; + WorkingDirectory = "%S/goupile"; + + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "~@privileged" + "~@resources" + "~@obsolete" + "~@mount" + "@system-service" + "@file-system" + "@basic-io" + "@clock" + ]; + + ProtectHome = true; + PrivateUsers = true; + PrivateDevices = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectKernelModules = true; + + CapabilityBoundingSet = [ + "CAP_SYS_PTRACE" + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + "CAP_KILL" # Required for child process management + "CAP_NET_BIND_SERVICE" + "CAP_SETGID" + "CAP_SETUID" + "CAP_SYS_CHROOT" + "CAP_SYS_RESOURCE" + ]; + + Restart = "always"; + RestartSec = 20; + TimeoutStopSec = 30; + LimitNOFILE = 4096; + }; + }; + } + ] + ); + + meta.maintainers = lib.teams.ngi.members; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index de66d01e1c09..cb914220e4be 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -689,6 +689,7 @@ in gotenberg = runTest ./gotenberg.nix; gotify-server = runTest ./gotify-server.nix; gotosocial = runTest ./web-apps/gotosocial.nix; + goupile = runTest ./web-apps/goupile; grafana = handleTest ./grafana { }; graphite = runTest ./graphite.nix; grav = runTest ./web-apps/grav.nix; diff --git a/nixos/tests/web-apps/goupile/basic_interaction_test.py b/nixos/tests/web-apps/goupile/basic_interaction_test.py new file mode 100644 index 000000000000..46b0f78903fc --- /dev/null +++ b/nixos/tests/web-apps/goupile/basic_interaction_test.py @@ -0,0 +1,178 @@ +import os +import openpyxl +import tempfile +from playwright.sync_api import sync_playwright + +BASE_URL = "http://localhost:8889" + +# NOTE: these are the passwords +ADMIN_PASSWD = "car-shop-in-the-mall" +ALICE_PASSWD = "user-goes-to-the-car-shop" + + +def run_test(): + is_headful = os.getenv("HEADFUL") == "1" + + with sync_playwright() as p: + browser = p.chromium.launch(headless=not is_headful) + context = browser.new_context( + accept_downloads=True, record_video_dir="/tmp/videos/" + ) + # more default timeout for slow nixos test vms + context.set_default_timeout(90 * 1000) + page = context.new_page() + + page.goto(f"{BASE_URL}/admin") + + # admin and doman setup + page.get_by_role("textbox", name="Domain name *").fill("domain") + page.get_by_role("textbox", name="Domain title *").fill("domain") + page.get_by_role("textbox", name="Password *").fill(ADMIN_PASSWD) + page.get_by_role("textbox", name="Confirmation").fill(ADMIN_PASSWD) + page.get_by_role("textbox", name="Decryption key *").click() + page.get_by_role("button", name="Installer").click() + + # login to admin dashboard as admin + page.get_by_role("textbox", name="Username *").fill("admin") + page.get_by_role("textbox", name="Password *").fill(ADMIN_PASSWD) + page.get_by_role("button", name="Login").click() + + # create a sample project, it will switch the view to project's configure page + page.get_by_text("Create new project").click() + page.get_by_role("textbox", name="Name *").fill("proj1") + page.get_by_role("button", name="Create").click() + + # create a test non-root user, alice + page.get_by_text("Create new user").click() + page.get_by_role("textbox", name="Username *").fill("alice") + page.get_by_role("button", name="No", exact=True).click() + page.get_by_role("textbox", name="Password *").fill(ALICE_PASSWD) + page.get_by_role("textbox", name="Confirmation").fill(ALICE_PASSWD) + page.get_by_role("button", name="Create").click() + + # give alice, permissions to access the project + page.get_by_role("button", name="Assign").nth(1).click() + page.get_by_text("Read", exact=True).click() + page.get_by_text("Save", exact=True).click() + page.get_by_text("Export", exact=True).click() + page.get_by_text("Download", exact=True).click() + + # Open the project in new page + page.locator("form").get_by_role("button", name="Edit").click() + with page.expect_popup() as page1_info: + page.get_by_role("link", name="access").click() + page1 = page1_info.value + page1.set_default_timeout(120 * 1000) + + # fill entries as admin (enter 1 for everything) + page1.get_by_role("button", name="Create new record").click() + + page1.locator("#ins_tiles").get_by_text("Introduction").click() + page1.get_by_role("textbox", name="Inclusion date *").fill("2000-01-01") + page1.get_by_role("spinbutton", name="Age *").click() + page1.get_by_role("spinbutton", name="Age *").fill("1") + page1.get_by_role("button", name="Save").click() + page1.wait_for_timeout(1000) + + page1.get_by_role("button", name="Advanced").click() + page1.get_by_role("spinbutton", name="Age *").click() + page1.get_by_role("spinbutton", name="Age *").fill("1") + page1.get_by_role("button", name="Save").click() + page1.wait_for_timeout(1000) + + page1.get_by_role("button", name="Page layout").click() + page1.get_by_role("spinbutton", name="Variable A1").fill("1") + page1.get_by_role("button", name="Save").click() + page1.wait_for_timeout(1000) + + # create export #1 + page1.get_by_role("button", name="Data").click() + page1.wait_for_timeout(1000) + + page1.get_by_role("button", name="Data exports").click() + with page1.expect_download() as download_info: + page1.get_by_role("button", name="Create export").click() + + # logout as admin + page.get_by_role("button", name="admin", exact=True).click() + with page.expect_popup() as page2_info: + page.get_by_role("link", name="access").click() + page2 = page2_info.value + page2.set_default_timeout(120 * 1000) + + page2.get_by_role("button", name="admin").click() + page2.get_by_role("button", name="Logout").click() + + # login as alice + page2.get_by_role("textbox", name="Username *").fill("alice") + page2.get_by_role("textbox", name="Password *").fill(ALICE_PASSWD) + page2.get_by_role("button", name="Login").click() + + # create entry as alice (fill `2` for everything) + page2.get_by_role("button", name="Create new record").click() + + page2.get_by_text("1 Introduction").click() + page2.get_by_role("textbox", name="Inclusion date *").fill("2000-01-01") + page2.get_by_role("spinbutton", name="Age *").click() + page2.get_by_role("spinbutton", name="Age *").fill("2") + page2.get_by_role("button", name="Save").click() + page2.wait_for_timeout(1000) + + page2.get_by_role("button", name="Advanced").click() + page2.get_by_role("spinbutton", name="Age *").click() + page2.get_by_role("spinbutton", name="Age *").fill("2") + page2.get_by_role("button", name="Save").click() + page2.wait_for_timeout(1000) + + page2.get_by_role("button", name="Page layout").click() + page2.get_by_role("spinbutton", name="Variable A1").click() + page2.get_by_role("spinbutton", name="Variable A1").fill("2") + page2.get_by_role("button", name="Save").click() + page2.wait_for_timeout(1000) + + # create export #2 + page2.get_by_role("button", name="Data").click() + page2.wait_for_timeout(1000) + + page2.get_by_role("button", name="Data exports").click() + page2.get_by_role("button", name="Previous exports").click() + + with page2.expect_download() as download1_info: + page2.locator("a").filter(has_text="Download").click() + + download1 = download1_info.value + save_path1 = os.path.join(tempfile.gettempdir(), download1.suggested_filename) + download1.save_as(save_path1) + + print(f"exported all records to {save_path1}") + + page2.get_by_role("button", name="Data exports").click() + with page2.expect_download() as download2_info: + page2.get_by_role("button", name="Create export").click() + + download2 = download2_info.value + save_path2 = os.path.join(tempfile.gettempdir(), download2.suggested_filename) + download2.save_as(save_path2) + + print(f"exported all records to {save_path2}") + + context.close() + browser.close() + + # check that exported files have correct entries + + wb1 = openpyxl.load_workbook(save_path1) + for sheet, cell in zip(["intro", "advanced", "layout"], ["D2", "D2", "C2"]): + val = wb1[sheet][cell].value + assert val == 1, f"Sheet {sheet}, Cell {cell}: Expected 1 (admin), got {val}" + + wb2 = openpyxl.load_workbook(save_path2) + for sheet, cell in zip(["intro", "advanced", "layout"], ["D3", "D3", "C3"]): + val = wb2[sheet][cell].value + assert val == 2, f"Sheet {sheet}, Cell {cell}: Expected 2 (alice), got {val}" + + print("Test passed successfully!") + + +if __name__ == "__main__": + run_test() diff --git a/nixos/tests/web-apps/goupile/default.nix b/nixos/tests/web-apps/goupile/default.nix new file mode 100644 index 000000000000..48215af9fa45 --- /dev/null +++ b/nixos/tests/web-apps/goupile/default.nix @@ -0,0 +1,153 @@ +{ + lib, + pkgs, + ... +}: +let + python = pkgs.python3.withPackages ( + ps: with ps; [ + requests + playwright + openpyxl + ] + ); + + runScript = "${lib.getExe python} ${./basic_interaction_test.py}"; + + run-goupile-test = pkgs.writeShellScriptBin "run-goupile-test" '' + set -euo pipefail + + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 + + # check if attached to a terminal + if [ -t 1 ]; then + # interactive testing + export HEADFUL=''${HEADFUL:-1} + export PWDEBUG=''${PWDEBUG:-0} + export DISPLAY=''${DISPLAY:-:0} + if [ "$(id -u)" = "0" ] && [ -d "/home/alice" ]; then + runuser -u alice \ + -w DISPLAY,HEADFUL,PWDEBUG,PLAYWRIGHT_BROWSERS_PATH,PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD \ + -- ${runScript} + else + ${runScript} + fi + else + # non-interactive nixos test + + # Print instructions to the nix logs + cat <<'EOF' | tee >(systemd-cat -t goupile-e2e) + ================================================================================ + NOTE: The goupile e2e test can be run interactively either inside the vm or on the host + - First, run `nix-build -A nixosTests.goupile.driverInteractive` and `./result/bin/nixos-test-driver` + - Run `start_all()` inside the repl + - Then `$(nix-build -A nixosTests.goupile.interactive-script)/bin/run-goupile-test` to run the full test interactively + - Or `env PWDEBUG=1 $(nix-build -A nixosTests.goupile.interactive-script)/bin/run-goupile-test` to show the playwright inspector to debug + ================================================================================ + EOF + + echo "Starting smoke test..." | systemd-cat -t goupile-e2e + ${runScript} 2>&1 | tee >(systemd-cat -t goupile-e2e) + fi + ''; +in +{ + name = "goupile"; + + passthru.interactive-script = run-goupile-test; + + nodes.machine = + { + lib, + pkgs, + config, + ... + }: + { + services.goupile = { + enable = true; + enableSandbox = true; + settings.HTTP.Port = 8889; + }; + #systemd.services.goupile.environment.DEFAULT_SECCOMP_ACTION = "Log"; # Block|Log|Kill + networking = { + firewall.allowedTCPPorts = [ config.services.nginx.defaultHTTPListenPort ]; + hostName = "goupile"; + domain = "local"; + }; + + # goupile tries to resolve it at runtime, resolve it instead of patching it out + # as the dns resolution step serves a purpose, to force glibc to load NSS libraries + # see server/goupile.cc and search for getaddrinfo or www.example.com + networking.extraHosts = '' + 127.0.0.1 www.example.com + ''; + + environment.systemPackages = [ + python + run-goupile-test + ]; + + # more cores and memory to improve chromium performance + virtualisation.memorySize = lib.mkForce 8192; + virtualisation.cores = 4; + }; + + testScript = + { nodes, ... }: + let + port = builtins.toString nodes.machine.services.goupile.settings.HTTP.Port; + in + # py + '' + import os + start_all() + + machine.wait_for_unit("goupile.service") + machine.wait_for_open_port(${port}) + + machine.succeed("curl -q http://localhost:${port}") + machine.succeed("curl -q http://goupile.local") + machine.succeed("curl -q http://localhost") + + machine.succeed("run-goupile-test") + out_dir = os.environ.get("out", os.getcwd()) + machine.copy_from_vm("/tmp/videos", out_dir) + ''; + + # Debug interactively with: + # - nix-build -A nixosTests.goupile.driverInteractive + # - ./result/bin/nixos-test-driver + # - run_tests() + # ssh -o User=root vsock%3 (can also do vsock/3, but % works with scp etc.) + interactive.sshBackdoor.enable = true; + + interactive.nodes.machine = + { config, ... }: + let + port = config.services.goupile.settings.HTTP.Port; + in + { + imports = [ + # enable graphical session + users (alice, bob) + ../../common/x11.nix + ../../common/user-account.nix + ]; + services.xserver.enable = true; + test-support.displayManager.auto.user = "alice"; + + virtualisation.forwardPorts = [ + { + from = "host"; + host.port = port; + guest.port = port; + } + ]; + + # forwarded ports need to be accessible + networking.firewall.allowedTCPPorts = [ port ]; + }; + + meta.maintainers = lib.teams.ngi.members; +} diff --git a/pkgs/by-name/go/goupile/package.nix b/pkgs/by-name/go/goupile/package.nix new file mode 100644 index 000000000000..a99a5f88539e --- /dev/null +++ b/pkgs/by-name/go/goupile/package.nix @@ -0,0 +1,77 @@ +{ + lib, + fetchFromGitHub, + versionCheckHook, + installShellFiles, + + stdenv, + clangStdenv, + llvmPackages, + nixosTests, + + # https://goupile.org/en/build recommends a Paranoid build + # which is not bit by bit reproducible, whereas others are + profile ? "Paranoid", +}: + +assert lib.assertOneOf "profile" profile [ + "Fast" + "Debug" + "Paranoid" +]; + +let + stdenv' = if (profile == "Paranoid") then clangStdenv else stdenv; +in +stdenv'.mkDerivation (finalAttrs: { + pname = "goupile"; + version = "3.12.1"; + + # https://github.com/Koromix/rygel/tags + src = fetchFromGitHub { + owner = "Koromix"; + repo = "rygel"; + tag = "goupile/${finalAttrs.version}"; + hash = "sha256-Pn/0tjezVKJedAtqj69avxeIK2l3l9FGioYSyEao12E="; + }; + + nativeBuildInputs = [ + installShellFiles + ] + ++ lib.optionals (profile == "Paranoid") [ + llvmPackages.bintools + ]; + + # pipe2() is only exposed with _GNU_SOURCE + NIX_CFLAGS_COMPILE = [ "-D_GNU_SOURCE" ]; + + buildPhase = '' + runHook preBuild + ./bootstrap.sh + echo "goupile = ${finalAttrs.version}" >FelixVersions.ini + ./felix -s -p${profile} goupile + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + installBin bin/${profile}/goupile + runHook postInstall + ''; + + doInstallCheck = true; + versionCheckProgramArg = "--version"; + nativeInstallCheckInputs = [ versionCheckHook ]; + + passthru.tests = { inherit (nixosTests) goupile; }; + + meta = { + changelog = "https://github.com/Koromix/rygel/blob/${finalAttrs.src.rev}/src/goupile/CHANGELOG.md"; + description = "Free design tool for secure forms including Clinical Report Forms (eCRF)"; + homepage = "https://goupile.org/en"; + license = lib.licenses.gpl3Plus; # sdpx headers + platforms = lib.platforms.linux; # https://goupile.org/en/build + mainProgram = "goupile"; + teams = with lib.teams; [ ngi ]; + }; +})