From 064432a51969b027ba80e7daf50ef7f5fbd1858e Mon Sep 17 00:00:00 2001 From: Wolfgang Walther Date: Fri, 28 Mar 2025 17:09:32 +0100 Subject: [PATCH] nixos/postgrest: init module --- .../manual/release-notes/rl-2505.section.md | 2 + nixos/modules/module-list.nix | 1 + .../modules/services/databases/postgrest.nix | 311 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/postgrest.nix | 88 +++++ .../haskell-modules/configuration-nix.nix | 1 + 6 files changed, 404 insertions(+) create mode 100644 nixos/modules/services/databases/postgrest.nix create mode 100644 nixos/tests/postgrest.nix diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index 80ab576b438c..7f0ad316e5db 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -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). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 902503335169..e6c5f4384b0f 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -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 diff --git a/nixos/modules/services/databases/postgrest.nix b/nixos/modules/services/databases/postgrest.nix new file mode 100644 index 000000000000..34d36bae0bee --- /dev/null +++ b/nixos/modules/services/databases/postgrest.nix @@ -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: + + + In most cases, the following will be enough: + ``` + *:*:*:*: + ``` + ''; + }; + + 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. + + + ''; + }; + + 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. + + + ''; + }; + + db-config = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Enables the 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: + + + + ::: {.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. + + + ::: + ''; + }; + + 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: + + + `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`. + + + ''; + } + ]; + + 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} + ''; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 27124bb953d7..917658d92ee3 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -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 {}; diff --git a/nixos/tests/postgrest.nix b/nixos/tests/postgrest.nix new file mode 100644 index 000000000000..bc503c41893c --- /dev/null +++ b/nixos/tests/postgrest.nix @@ -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 + ) + ''; +} diff --git a/pkgs/development/haskell-modules/configuration-nix.nix b/pkgs/development/haskell-modules/configuration-nix.nix index 9ba180a102ee..e57f935d6cdc 100644 --- a/pkgs/development/haskell-modules/configuration-nix.nix +++ b/pkgs/development/haskell-modules/configuration-nix.nix @@ -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.