Skip to content

Commit c76d239

Browse files
nixos/postgres-websockets: init (#397408)
2 parents eb8f8f5 + d62c14f commit c76d239

File tree

7 files changed

+316
-0
lines changed

7 files changed

+316
-0
lines changed

nixos/doc/manual/release-notes/rl-2505.section.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@
111111

112112
- [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).
113113

114+
- [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).
115+
114116
- [µStreamer](https://github.com/pikvm/ustreamer), a lightweight MJPEG-HTTP streamer. Available as [services.ustreamer](options.html#opt-services.ustreamer).
115117

116118
- [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).

nixos/modules/module-list.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@
515515
./services/databases/opentsdb.nix
516516
./services/databases/pgbouncer.nix
517517
./services/databases/pgmanage.nix
518+
./services/databases/postgres-websockets.nix
518519
./services/databases/postgresql.nix
519520
./services/databases/postgrest.nix
520521
./services/databases/redis.nix
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
{
2+
config,
3+
lib,
4+
pkgs,
5+
...
6+
}:
7+
8+
let
9+
cfg = config.services.postgres-websockets;
10+
11+
# Turns an attrset of libpq connection params:
12+
# {
13+
# dbname = "postgres";
14+
# user = "authenticator";
15+
# }
16+
# into a libpq connection string:
17+
# dbname=postgres user=authenticator
18+
PGWS_DB_URI = lib.pipe cfg.environment.PGWS_DB_URI [
19+
(lib.filterAttrs (_: v: v != null))
20+
(lib.mapAttrsToList (k: v: "${k}='${lib.escape [ "'" "\\" ] v}'"))
21+
(lib.concatStringsSep " ")
22+
];
23+
in
24+
25+
{
26+
meta = {
27+
maintainers = with lib.maintainers; [ wolfgangwalther ];
28+
};
29+
30+
options.services.postgres-websockets = {
31+
enable = lib.mkEnableOption "postgres-websockets";
32+
33+
pgpassFile = lib.mkOption {
34+
type =
35+
with lib.types;
36+
nullOr (pathWith {
37+
inStore = false;
38+
absolute = true;
39+
});
40+
default = null;
41+
example = "/run/keys/db_password";
42+
description = ''
43+
The password to authenticate to PostgreSQL with.
44+
Not needed for peer or trust based authentication.
45+
46+
The file must be a valid `.pgpass` file as described in:
47+
<https://www.postgresql.org/docs/current/libpq-pgpass.html>
48+
49+
In most cases, the following will be enough:
50+
```
51+
*:*:*:*:<password>
52+
```
53+
'';
54+
};
55+
56+
jwtSecretFile = lib.mkOption {
57+
type =
58+
with lib.types;
59+
nullOr (pathWith {
60+
inStore = false;
61+
absolute = true;
62+
});
63+
example = "/run/keys/jwt_secret";
64+
description = ''
65+
Secret used to sign JWT tokens used to open communications channels.
66+
'';
67+
};
68+
69+
environment = lib.mkOption {
70+
type = lib.types.submodule {
71+
freeformType = with lib.types; attrsOf str;
72+
73+
options = {
74+
PGWS_DB_URI = lib.mkOption {
75+
type = lib.types.submodule {
76+
freeformType = with lib.types; attrsOf str;
77+
78+
# This should not be used; use pgpassFile instead.
79+
options.password = lib.mkOption {
80+
default = null;
81+
readOnly = true;
82+
internal = true;
83+
};
84+
# This should not be used; use pgpassFile instead.
85+
options.passfile = lib.mkOption {
86+
default = null;
87+
readOnly = true;
88+
internal = true;
89+
};
90+
};
91+
default = { };
92+
description = ''
93+
libpq connection parameters as documented in:
94+
95+
<https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS>
96+
97+
::: {.note}
98+
The `environment.PGWS_DB_URI.password` and `environment.PGWS_DB_URI.passfile` options are blocked.
99+
Use [`pgpassFile`](#opt-services.postgres-websockets.pgpassFile) instead.
100+
:::
101+
'';
102+
example = lib.literalExpression ''
103+
{
104+
host = "localhost";
105+
dbname = "postgres";
106+
}
107+
'';
108+
};
109+
110+
# This should not be used; use jwtSecretFile instead.
111+
PGWS_JWT_SECRET = lib.mkOption {
112+
default = null;
113+
readOnly = true;
114+
internal = true;
115+
};
116+
117+
PGWS_HOST = lib.mkOption {
118+
type = with lib.types; nullOr str;
119+
default = "127.0.0.1";
120+
description = ''
121+
Address the server will listen for websocket connections.
122+
'';
123+
};
124+
};
125+
};
126+
default = { };
127+
description = ''
128+
postgres-websockets configuration as defined in:
129+
<https://github.com/diogob/postgres-websockets/blob/master/src/PostgresWebsockets/Config.hs#L71-L87>
130+
131+
`PGWS_DB_URI` is represented as an attribute set, see [`environment.PGWS_DB_URI`](#opt-services.postgres-websockets.environment.PGWS_DB_URI)
132+
133+
::: {.note}
134+
The `environment.PGWS_JWT_SECRET` option is blocked.
135+
Use [`jwtSecretFile`](#opt-services.postgres-websockets.jwtSecretFile) instead.
136+
:::
137+
'';
138+
example = lib.literalExpression ''
139+
{
140+
PGWS_LISTEN_CHANNEL = "my_channel";
141+
PGWS_DB_URI.dbname = "postgres";
142+
}
143+
'';
144+
};
145+
};
146+
147+
config = lib.mkIf cfg.enable {
148+
services.postgres-websockets.environment.PGWS_DB_URI.application_name =
149+
with pkgs.postgres-websockets;
150+
"${pname} ${version}";
151+
152+
systemd.services.postgres-websockets = {
153+
description = "postgres-websockets";
154+
155+
wantedBy = [ "multi-user.target" ];
156+
wants = [ "network-online.target" ];
157+
after = [
158+
"network-online.target"
159+
"postgresql.service"
160+
];
161+
162+
environment =
163+
cfg.environment
164+
// {
165+
inherit PGWS_DB_URI;
166+
PGWS_JWT_SECRET = "@%d/jwt_secret";
167+
}
168+
// lib.optionalAttrs (cfg.pgpassFile != null) {
169+
PGPASSFILE = "%C/postgres-websockets/pgpass";
170+
};
171+
172+
serviceConfig = {
173+
CacheDirectory = "postgres-websockets";
174+
CacheDirectoryMode = "0700";
175+
LoadCredential = [
176+
"jwt_secret:${cfg.jwtSecretFile}"
177+
] ++ lib.optional (cfg.pgpassFile != null) "pgpass:${cfg.pgpassFile}";
178+
Restart = "always";
179+
User = "postgres-websockets";
180+
181+
# Hardening
182+
CapabilityBoundingSet = [ "" ];
183+
DevicePolicy = "closed";
184+
DynamicUser = true;
185+
LockPersonality = true;
186+
MemoryDenyWriteExecute = true;
187+
NoNewPrivileges = true;
188+
PrivateDevices = true;
189+
PrivateIPC = true;
190+
PrivateMounts = true;
191+
ProcSubset = "pid";
192+
ProtectClock = true;
193+
ProtectControlGroups = true;
194+
ProtectHostname = true;
195+
ProtectKernelLogs = true;
196+
ProtectKernelModules = true;
197+
ProtectKernelTunables = true;
198+
ProtectProc = "invisible";
199+
RestrictAddressFamilies = [
200+
"AF_INET"
201+
"AF_INET6"
202+
"AF_UNIX"
203+
];
204+
RestrictNamespaces = true;
205+
RestrictRealtime = true;
206+
SystemCallArchitectures = "native";
207+
SystemCallFilter = [ "" ];
208+
UMask = "0077";
209+
};
210+
211+
# Copy the pgpass file to different location, to have it report mode 0400.
212+
# Fixes: https://github.com/systemd/systemd/issues/29435
213+
script = ''
214+
if [ -f "$CREDENTIALS_DIRECTORY/pgpass" ]; then
215+
cp -f "$CREDENTIALS_DIRECTORY/pgpass" "$CACHE_DIRECTORY/pgpass"
216+
fi
217+
exec ${lib.getExe pkgs.postgres-websockets}
218+
'';
219+
};
220+
};
221+
}

nixos/tests/all-tests.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,6 +1081,7 @@ in
10811081
handleTest ./postfix-raise-smtpd-tls-security-level.nix
10821082
{ };
10831083
postfixadmin = handleTest ./postfixadmin.nix { };
1084+
postgres-websockets = runTest ./postgres-websockets.nix;
10841085
postgresql = handleTest ./postgresql { };
10851086
postgrest = runTest ./postgrest.nix;
10861087
powerdns = handleTest ./powerdns.nix { };
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
{ lib, ... }:
2+
{
3+
name = "postgres-websockets";
4+
5+
meta = {
6+
maintainers = with lib.maintainers; [ wolfgangwalther ];
7+
};
8+
9+
nodes.machine =
10+
{
11+
config,
12+
lib,
13+
pkgs,
14+
...
15+
}:
16+
{
17+
environment.systemPackages = [ pkgs.websocat ];
18+
19+
services.postgresql = {
20+
enable = true;
21+
initialScript = pkgs.writeText "init.sql" ''
22+
CREATE ROLE "postgres-websockets" LOGIN NOINHERIT;
23+
CREATE ROLE "postgres-websockets_with_password" LOGIN NOINHERIT PASSWORD 'password';
24+
'';
25+
};
26+
27+
services.postgres-websockets = {
28+
enable = true;
29+
jwtSecretFile = "/run/secrets/jwt.secret";
30+
environment.PGWS_DB_URI.dbname = "postgres";
31+
environment.PGWS_LISTEN_CHANNEL = "websockets-listener";
32+
};
33+
34+
specialisation.withPassword.configuration = {
35+
services.postgresql.enableTCPIP = true;
36+
services.postgres-websockets = {
37+
pgpassFile = "/run/secrets/.pgpass";
38+
environment.PGWS_DB_URI.host = "localhost";
39+
environment.PGWS_DB_URI.user = "postgres-websockets_with_password";
40+
};
41+
};
42+
};
43+
44+
extraPythonPackages = p: [ p.pyjwt ];
45+
46+
testScript =
47+
{ nodes, ... }:
48+
let
49+
withPassword = "${nodes.machine.system.build.toplevel}/specialisation/withPassword";
50+
in
51+
''
52+
machine.execute("""
53+
mkdir -p /run/secrets
54+
echo reallyreallyreallyreallyverysafe > /run/secrets/jwt.secret
55+
""")
56+
57+
import jwt
58+
token = jwt.encode({ "mode": "rw" }, "reallyreallyreallyreallyverysafe")
59+
60+
def test():
61+
machine.wait_for_unit("postgresql.service")
62+
machine.wait_for_unit("postgres-websockets.service")
63+
64+
machine.succeed(f"echo 'hi there' | websocat --no-close 'ws://localhost:3000/test/{token}' > output &")
65+
machine.sleep(1)
66+
machine.succeed("grep 'hi there' output")
67+
68+
machine.succeed("""
69+
sudo -u postgres psql -c "SELECT pg_notify('websockets-listener', json_build_object('channel', 'test', 'event', 'message', 'payload', 'Hello World')::text);" >/dev/null
70+
""")
71+
machine.sleep(1)
72+
machine.succeed("grep 'Hello World' output")
73+
74+
with subtest("without password"):
75+
test()
76+
77+
with subtest("with password"):
78+
machine.execute("""
79+
echo "*:*:*:*:password" > /run/secrets/.pgpass
80+
""")
81+
machine.succeed("${withPassword}/bin/switch-to-configuration test >&2")
82+
test()
83+
'';
84+
}

pkgs/development/haskell-modules/configuration-nix.nix

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,12 @@ builtins.intersectAttrs super {
480480
hasql-pool = dontCheck super.hasql-pool;
481481
hasql-transaction = dontCheck super.hasql-transaction;
482482

483+
# Avoid compiling twice by providing executable as a separate output (with small closure size),
484+
postgres-websockets = lib.pipe super.postgres-websockets [
485+
enableSeparateBinOutput
486+
(overrideCabal { passthru.tests = pkgs.nixosTests.postgres-websockets; })
487+
];
488+
483489
# Test suite requires a running postgresql server,
484490
# avoid compiling twice by providing executable as a separate output (with small closure size),
485491
# generate shell completion

pkgs/top-level/all-packages.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12478,6 +12478,7 @@ with pkgs;
1247812478
postgresql16JitPackages = recurseIntoAttrs postgresql_16_jit.pkgs;
1247912479
postgresql17JitPackages = recurseIntoAttrs postgresql_17_jit.pkgs;
1248012480

12481+
postgres-websockets = haskellPackages.postgres-websockets.bin;
1248112482
postgrest = haskellPackages.postgrest.bin;
1248212483

1248312484
prom2json = callPackage ../servers/monitoring/prometheus/prom2json.nix { };

0 commit comments

Comments
 (0)