nixos/postgres-websockets: init

This commit is contained in:
Wolfgang Walther 2025-04-09 15:14:31 +02:00
parent b2b5f8be28
commit d62c14f5d1
No known key found for this signature in database
GPG key ID: B39893FA5F65CAE1
6 changed files with 313 additions and 1 deletions

View file

@ -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).

View file

@ -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

View file

@ -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:
<https://www.postgresql.org/docs/current/libpq-pgpass.html>
In most cases, the following will be enough:
```
*:*:*:*:<password>
```
'';
};
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:
<https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS>
::: {.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:
<https://github.com/diogob/postgres-websockets/blob/master/src/PostgresWebsockets/Config.hs#L71-L87>
`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}
'';
};
};
}

View file

@ -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 { };

View file

@ -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()
'';
}

View file

@ -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),