From d62c14f5d1b39a61fae1688e5f760b1cb9c3a1ec Mon Sep 17 00:00:00 2001 From: Wolfgang Walther Date: Wed, 9 Apr 2025 15:14:31 +0200 Subject: [PATCH] nixos/postgres-websockets: init --- .../manual/release-notes/rl-2505.section.md | 2 + nixos/modules/module-list.nix | 1 + .../databases/postgres-websockets.nix | 221 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/postgres-websockets.nix | 84 +++++++ .../haskell-modules/configuration-nix.nix | 5 +- 6 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 nixos/modules/services/databases/postgres-websockets.nix create mode 100644 nixos/tests/postgres-websockets.nix diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index 1351b79027a6..4199a7f23253 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -111,6 +111,8 @@ - [PostgREST](https://postgrest.org), a standalone web server that turns your PostgreSQL database directly into a RESTful API. Available as [services.postgrest](options.html#opt-services.postgrest.enable). +- [postgres-websockets](https://github.com/diogob/postgres-websockets), a middleware that adds websockets capabilites on top of PostgreSQL's asynchronous notifications using LISTEN and NOTIFY commands. Available as [services.postgres-websockets](options.html#opt-services.postgres-websockets.enable). + - [µStreamer](https://github.com/pikvm/ustreamer), a lightweight MJPEG-HTTP streamer. Available as [services.ustreamer](options.html#opt-services.ustreamer). - [Whoogle Search](https://github.com/benbusby/whoogle-search), a self-hosted, ad-free, privacy-respecting metasearch engine. Available as [services.whoogle-search](options.html#opt-services.whoogle-search.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index e00a1e1d52e4..409688614580 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -515,6 +515,7 @@ ./services/databases/opentsdb.nix ./services/databases/pgbouncer.nix ./services/databases/pgmanage.nix + ./services/databases/postgres-websockets.nix ./services/databases/postgresql.nix ./services/databases/postgrest.nix ./services/databases/redis.nix diff --git a/nixos/modules/services/databases/postgres-websockets.nix b/nixos/modules/services/databases/postgres-websockets.nix new file mode 100644 index 000000000000..a83054a507cb --- /dev/null +++ b/nixos/modules/services/databases/postgres-websockets.nix @@ -0,0 +1,221 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.postgres-websockets; + + # Turns an attrset of libpq connection params: + # { + # dbname = "postgres"; + # user = "authenticator"; + # } + # into a libpq connection string: + # dbname=postgres user=authenticator + PGWS_DB_URI = lib.pipe cfg.environment.PGWS_DB_URI [ + (lib.filterAttrs (_: v: v != null)) + (lib.mapAttrsToList (k: v: "${k}='${lib.escape [ "'" "\\" ] v}'")) + (lib.concatStringsSep " ") + ]; +in + +{ + meta = { + maintainers = with lib.maintainers; [ wolfgangwalther ]; + }; + + options.services.postgres-websockets = { + enable = lib.mkEnableOption "postgres-websockets"; + + pgpassFile = lib.mkOption { + type = + with lib.types; + nullOr (pathWith { + inStore = false; + absolute = true; + }); + default = null; + example = "/run/keys/db_password"; + description = '' + The password to authenticate to PostgreSQL with. + Not needed for peer or trust based authentication. + + The file must be a valid `.pgpass` file as described in: + + + In most cases, the following will be enough: + ``` + *:*:*:*: + ``` + ''; + }; + + jwtSecretFile = lib.mkOption { + type = + with lib.types; + nullOr (pathWith { + inStore = false; + absolute = true; + }); + example = "/run/keys/jwt_secret"; + description = '' + Secret used to sign JWT tokens used to open communications channels. + ''; + }; + + environment = lib.mkOption { + type = lib.types.submodule { + freeformType = with lib.types; attrsOf str; + + options = { + PGWS_DB_URI = lib.mkOption { + type = lib.types.submodule { + freeformType = with lib.types; attrsOf str; + + # This should not be used; use pgpassFile instead. + options.password = lib.mkOption { + default = null; + readOnly = true; + internal = true; + }; + # This should not be used; use pgpassFile instead. + options.passfile = lib.mkOption { + default = null; + readOnly = true; + internal = true; + }; + }; + default = { }; + description = '' + libpq connection parameters as documented in: + + + + ::: {.note} + The `environment.PGWS_DB_URI.password` and `environment.PGWS_DB_URI.passfile` options are blocked. + Use [`pgpassFile`](#opt-services.postgres-websockets.pgpassFile) instead. + ::: + ''; + example = lib.literalExpression '' + { + host = "localhost"; + dbname = "postgres"; + } + ''; + }; + + # This should not be used; use jwtSecretFile instead. + PGWS_JWT_SECRET = lib.mkOption { + default = null; + readOnly = true; + internal = true; + }; + + PGWS_HOST = lib.mkOption { + type = with lib.types; nullOr str; + default = "127.0.0.1"; + description = '' + Address the server will listen for websocket connections. + ''; + }; + }; + }; + default = { }; + description = '' + postgres-websockets configuration as defined in: + + + `PGWS_DB_URI` is represented as an attribute set, see [`environment.PGWS_DB_URI`](#opt-services.postgres-websockets.environment.PGWS_DB_URI) + + ::: {.note} + The `environment.PGWS_JWT_SECRET` option is blocked. + Use [`jwtSecretFile`](#opt-services.postgres-websockets.jwtSecretFile) instead. + ::: + ''; + example = lib.literalExpression '' + { + PGWS_LISTEN_CHANNEL = "my_channel"; + PGWS_DB_URI.dbname = "postgres"; + } + ''; + }; + }; + + config = lib.mkIf cfg.enable { + services.postgres-websockets.environment.PGWS_DB_URI.application_name = + with pkgs.postgres-websockets; + "${pname} ${version}"; + + systemd.services.postgres-websockets = { + description = "postgres-websockets"; + + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + "postgresql.service" + ]; + + environment = + cfg.environment + // { + inherit PGWS_DB_URI; + PGWS_JWT_SECRET = "@%d/jwt_secret"; + } + // lib.optionalAttrs (cfg.pgpassFile != null) { + PGPASSFILE = "%C/postgres-websockets/pgpass"; + }; + + serviceConfig = { + CacheDirectory = "postgres-websockets"; + CacheDirectoryMode = "0700"; + LoadCredential = [ + "jwt_secret:${cfg.jwtSecretFile}" + ] ++ lib.optional (cfg.pgpassFile != null) "pgpass:${cfg.pgpassFile}"; + Restart = "always"; + User = "postgres-websockets"; + + # Hardening + CapabilityBoundingSet = [ "" ]; + DevicePolicy = "closed"; + DynamicUser = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateIPC = true; + PrivateMounts = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "" ]; + UMask = "0077"; + }; + + # Copy the pgpass file to different location, to have it report mode 0400. + # Fixes: https://github.com/systemd/systemd/issues/29435 + script = '' + if [ -f "$CREDENTIALS_DIRECTORY/pgpass" ]; then + cp -f "$CREDENTIALS_DIRECTORY/pgpass" "$CACHE_DIRECTORY/pgpass" + fi + exec ${lib.getExe pkgs.postgres-websockets} + ''; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 4716614cd20f..706ac8e8528b 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1080,6 +1080,7 @@ in handleTest ./postfix-raise-smtpd-tls-security-level.nix { }; postfixadmin = handleTest ./postfixadmin.nix { }; + postgres-websockets = runTest ./postgres-websockets.nix; postgresql = handleTest ./postgresql { }; postgrest = runTest ./postgrest.nix; powerdns = handleTest ./powerdns.nix { }; diff --git a/nixos/tests/postgres-websockets.nix b/nixos/tests/postgres-websockets.nix new file mode 100644 index 000000000000..c3badf22383e --- /dev/null +++ b/nixos/tests/postgres-websockets.nix @@ -0,0 +1,84 @@ +{ lib, ... }: +{ + name = "postgres-websockets"; + + meta = { + maintainers = with lib.maintainers; [ wolfgangwalther ]; + }; + + nodes.machine = + { + config, + lib, + pkgs, + ... + }: + { + environment.systemPackages = [ pkgs.websocat ]; + + services.postgresql = { + enable = true; + initialScript = pkgs.writeText "init.sql" '' + CREATE ROLE "postgres-websockets" LOGIN NOINHERIT; + CREATE ROLE "postgres-websockets_with_password" LOGIN NOINHERIT PASSWORD 'password'; + ''; + }; + + services.postgres-websockets = { + enable = true; + jwtSecretFile = "/run/secrets/jwt.secret"; + environment.PGWS_DB_URI.dbname = "postgres"; + environment.PGWS_LISTEN_CHANNEL = "websockets-listener"; + }; + + specialisation.withPassword.configuration = { + services.postgresql.enableTCPIP = true; + services.postgres-websockets = { + pgpassFile = "/run/secrets/.pgpass"; + environment.PGWS_DB_URI.host = "localhost"; + environment.PGWS_DB_URI.user = "postgres-websockets_with_password"; + }; + }; + }; + + extraPythonPackages = p: [ p.pyjwt ]; + + testScript = + { nodes, ... }: + let + withPassword = "${nodes.machine.system.build.toplevel}/specialisation/withPassword"; + in + '' + machine.execute(""" + mkdir -p /run/secrets + echo reallyreallyreallyreallyverysafe > /run/secrets/jwt.secret + """) + + import jwt + token = jwt.encode({ "mode": "rw" }, "reallyreallyreallyreallyverysafe") + + def test(): + machine.wait_for_unit("postgresql.service") + machine.wait_for_unit("postgres-websockets.service") + + machine.succeed(f"echo 'hi there' | websocat --no-close 'ws://localhost:3000/test/{token}' > output &") + machine.sleep(1) + machine.succeed("grep 'hi there' output") + + machine.succeed(""" + sudo -u postgres psql -c "SELECT pg_notify('websockets-listener', json_build_object('channel', 'test', 'event', 'message', 'payload', 'Hello World')::text);" >/dev/null + """) + machine.sleep(1) + machine.succeed("grep 'Hello World' output") + + with subtest("without password"): + test() + + with subtest("with password"): + machine.execute(""" + echo "*:*:*:*:password" > /run/secrets/.pgpass + """) + machine.succeed("${withPassword}/bin/switch-to-configuration test >&2") + test() + ''; +} diff --git a/pkgs/development/haskell-modules/configuration-nix.nix b/pkgs/development/haskell-modules/configuration-nix.nix index 5bb74fa2ce03..b196a55bd5bd 100644 --- a/pkgs/development/haskell-modules/configuration-nix.nix +++ b/pkgs/development/haskell-modules/configuration-nix.nix @@ -481,7 +481,10 @@ builtins.intersectAttrs super { hasql-transaction = dontCheck super.hasql-transaction; # Avoid compiling twice by providing executable as a separate output (with small closure size), - postgres-websockets = enableSeparateBinOutput super.postgres-websockets; + postgres-websockets = lib.pipe super.postgres-websockets [ + enableSeparateBinOutput + (overrideCabal { passthru.tests = pkgs.nixosTests.postgres-websockets; }) + ]; # Test suite requires a running postgresql server, # avoid compiling twice by providing executable as a separate output (with small closure size),