Unverified Commit c76d239f authored by Wolfgang Walther's avatar Wolfgang Walther Committed by GitHub
Browse files

nixos/postgres-websockets: init (#397408)

parents eb8f8f50 d62c14f5
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -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).
+1 −0
Original line number Diff line number Diff line
@@ -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
+221 −0
Original line number Diff line number Diff line
{
  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}
      '';
    };
  };
}
+1 −0
Original line number Diff line number Diff line
@@ -1081,6 +1081,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 { };
+84 −0
Original line number Diff line number Diff line
{ 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()
    '';
}
Loading