nixos/postgrest: init module

This commit is contained in:
Wolfgang Walther 2025-03-28 17:09:32 +01:00
parent 71740ceb69
commit 064432a519
No known key found for this signature in database
GPG Key ID: B39893FA5F65CAE1
6 changed files with 404 additions and 0 deletions

View File

@ -126,6 +126,8 @@
- [Autotier](https://github.com/45Drives/autotier), a passthrough FUSE filesystem. Available as [services.autotierfs](options.html#opt-services.autotierfs.enable).
- [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).
- [µ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

@ -513,6 +513,7 @@
./services/databases/pgbouncer.nix
./services/databases/pgmanage.nix
./services/databases/postgresql.nix
./services/databases/postgrest.nix
./services/databases/redis.nix
./services/databases/surrealdb.nix
./services/databases/tigerbeetle.nix

View File

@ -0,0 +1,311 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.postgrest;
# Turns an attrset of libpq connection params:
# {
# dbname = "postgres";
# user = "authenticator";
# }
# into a libpq connection string:
# dbname=postgres user=authenticator
db-uri = lib.pipe (cfg.settings.db-uri or { }) [
(lib.filterAttrs (_: v: v != null))
(lib.mapAttrsToList (k: v: "${k}=${v}"))
(lib.concatStringsSep " ")
];
# Writes a postgrest config file according to:
# https://hackage.haskell.org/package/configurator-0.3.0.0/docs/Data-Configurator.html
# Only a subset of the functionality is used by PostgREST.
configFile = lib.pipe (cfg.settings // { inherit db-uri; }) [
(lib.filterAttrs (_: v: v != null))
(lib.mapAttrs (
_: v:
if true == v then
"true"
else if false == v then
"false"
else if lib.isInt v then
toString v
else
"\"${lib.escape [ "\"" ] v}\""
))
(lib.mapAttrsToList (k: v: "${k} = ${v}"))
(lib.concatStringsSep "\n")
(pkgs.writeText "postgrest.conf")
];
in
{
meta = {
maintainers = with lib.maintainers; [ wolfgangwalther ];
};
options.services.postgrest = {
enable = lib.mkEnableOption "PostgREST";
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;
});
default = null;
example = "/run/keys/jwt_secret";
description = ''
The secret or JSON Web Key (JWK) (or set) used to decode JWT tokens clients provide for authentication.
For security the key must be at least 32 characters long.
If this parameter is not specified then PostgREST refuses authentication requests.
<https://docs.postgrest.org/en/stable/references/configuration.html#jwt-secret>
'';
};
settings = lib.mkOption {
type = lib.types.submodule {
freeformType =
with lib.types;
attrsOf (oneOf [
bool
ints.unsigned
str
]);
options = {
admin-server-port = lib.mkOption {
type = with lib.types; nullOr port;
default = null;
description = ''
Specifies the port for the admin server, which can be used for healthchecks.
<https://docs.postgrest.org/en/stable/references/admin_server.html#admin-server>
'';
};
db-config = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Enables the in-database configuration.
<https://docs.postgrest.org/en/stable/references/configuration.html#in-database-configuration>
::: {.note}
This is enabled by default upstream, but disabled by default in this module.
:::
'';
};
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 `settings.db-uri.password` and `settings.db-uri.passfile` options are blocked.
Use [`pgpassFile`](#opt-services.postgrest.pgpassFile) instead.
:::
'';
example = lib.literalExpression ''
{
host = "localhost";
dbname = "postgres";
}
'';
};
# This should not be used; use jwtSecretFile instead.
jwt-secret = lib.mkOption {
default = null;
readOnly = true;
internal = true;
};
server-host = lib.mkOption {
type = with lib.types; nullOr str;
default = "127.0.0.1";
description = ''
Where to bind the PostgREST web server.
::: {.note}
The admin server will also bind here, but potentially exposes sensitive information.
Make sure you turn off the admin server, when opening this to the public.
<https://github.com/PostgREST/postgrest/issues/3956>
:::
'';
};
server-port = lib.mkOption {
type = with lib.types; nullOr port;
default = null;
example = 3000;
description = ''
The TCP port to bind the web server.
'';
};
server-unix-socket = lib.mkOption {
type = with lib.types; nullOr path;
default = "/run/postgrest/postgrest.sock";
description = ''
Unix domain socket where to bind the PostgREST web server.
'';
};
};
};
default = { };
description = ''
PostgREST configuration as documented in:
<https://docs.postgrest.org/en/stable/references/configuration.html#list-of-parameters>
`db-uri` is represented as an attribute set, see [`settings.db-uri`](#opt-services.postgrest.settings.db-uri)
::: {.note}
The `settings.jwt-secret` option is blocked.
Use [`jwtSecretFile`](#opt-services.postgrest.jwtSecretFile) instead.
:::
'';
example = lib.literalExpression ''
{
db-anon-role = "anon";
db-uri.dbname = "postgres";
"app.settings.custom" = "value";
}
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.settings.server-port == null) != (cfg.settings.server-unix-socket == null);
message = ''
PostgREST can listen either on a TCP port or on a unix socket, but not both.
Please set one of `settings.server-port`](#opt-services.postgrest.jwtSecretFile) or `settings.server-unix-socket` to `null`.
<https://docs.postgrest.org/en/stable/references/configuration.html#server-unix-socket>
'';
}
];
warnings =
lib.optional (cfg.settings.admin-server-port != null && cfg.settings.server-host != "127.0.0.1")
"The PostgREST admin server is potentially listening on a public host. This may expose sensitive information via the `/config` endpoint.";
systemd.services.postgrest = {
description = "PostgREST";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [
"network-online.target"
"postgresql.service"
];
serviceConfig = {
CacheDirectory = "postgrest";
CacheDirectoryMode = "0700";
Environment =
lib.optional (cfg.pgpassFile != null) "PGPASSFILE=%C/postgrest/pgpass"
++ lib.optional (cfg.jwtSecretFile != null) "PGRST_JWT_SECRET=@%d/jwt_secret";
LoadCredential =
lib.optional (cfg.pgpassFile != null) "pgpass:${cfg.pgpassFile}"
++ lib.optional (cfg.jwtSecretFile != null) "jwt_secret:${cfg.jwtSecretFile}";
Restart = "always";
RuntimeDirectory = "postgrest";
User = "postgrest";
# 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.postgrest} ${configFile}
'';
};
};
}

View File

@ -972,6 +972,7 @@ in {
postfix-raise-smtpd-tls-security-level = handleTest ./postfix-raise-smtpd-tls-security-level.nix {};
postfixadmin = handleTest ./postfixadmin.nix {};
postgresql = handleTest ./postgresql {};
postgrest = runTest ./postgrest.nix;
powerdns = handleTest ./powerdns.nix {};
powerdns-admin = handleTest ./powerdns-admin.nix {};
power-profiles-daemon = handleTest ./power-profiles-daemon.nix {};

88
nixos/tests/postgrest.nix Normal file
View File

@ -0,0 +1,88 @@
{ lib, ... }:
{
name = "postgrest";
meta = {
maintainers = with lib.maintainers; [ wolfgangwalther ];
};
nodes.machine =
{
config,
lib,
pkgs,
...
}:
{
services.postgresql = {
enable = true;
initialScript = pkgs.writeText "init.sql" ''
CREATE ROLE postgrest LOGIN NOINHERIT;
CREATE ROLE anon ROLE postgrest;
CREATE ROLE postgrest_with_password LOGIN NOINHERIT PASSWORD 'password';
CREATE ROLE authenticated ROLE postgrest_with_password;
'';
};
services.postgrest = {
enable = true;
settings = {
admin-server-port = 3001;
db-anon-role = "anon";
db-uri.dbname = "postgres";
};
};
specialisation.withSecrets.configuration = {
services.postgresql.enableTCPIP = true;
services.postgrest = {
pgpassFile = "/run/secrets/.pgpass";
jwtSecretFile = "/run/secrets/jwt.secret";
settings.db-uri.host = "localhost";
settings.db-uri.user = "postgrest_with_password";
settings.server-port = 3000;
settings.server-unix-socket = null;
};
};
};
extraPythonPackages = p: [ p.pyjwt ];
testScript =
{ nodes, ... }:
let
withSecrets = "${nodes.machine.system.build.toplevel}/specialisation/withSecrets";
in
''
import jwt
machine.wait_for_unit("postgresql.service")
def wait_for_postgrest():
machine.wait_for_unit("postgrest.service")
machine.wait_until_succeeds("curl --fail -s http://localhost:3001/ready", timeout=30)
with subtest("anonymous access"):
wait_for_postgrest()
machine.succeed(
"curl --fail-with-body --no-progress-meter --unix-socket /run/postgrest/postgrest.sock http://localhost",
timeout=2
)
machine.execute("""
mkdir -p /run/secrets
echo "*:*:*:*:password" > /run/secrets/.pgpass
echo reallyreallyreallyreallyverysafe > /run/secrets/jwt.secret
""")
with subtest("authenticated access"):
machine.succeed("${withSecrets}/bin/switch-to-configuration test >&2")
wait_for_postgrest()
token = jwt.encode({ "role": "authenticated" }, "reallyreallyreallyreallyverysafe")
machine.succeed(
f"curl --fail-with-body --no-progress-meter -H 'Authorization: Bearer {token}' http://localhost:3000",
timeout=2
)
'';
}

View File

@ -439,6 +439,7 @@ self: super: builtins.intersectAttrs super {
dontCheck
enableSeparateBinOutput
(self.generateOptparseApplicativeCompletions [ "postgrest" ])
(overrideCabal { passthru.tests = pkgs.nixosTests.postgrest; })
];
# Tries to mess with extended POSIX attributes, but can't in our chroot environment.