From 3993feaf8d6ed21a339256d06404ee4d5365333f Mon Sep 17 00:00:00 2001 From: Pino de Candia Date: Wed, 26 Aug 2026 20:25:02 +0000 Subject: [PATCH 1/3] Add pg_durable.host connection setting --- USER_GUIDE.md | 10 ++++++ docs/security-review/workbook-data.md | 3 +- src/lib.rs | 33 +++++++++++++++++ src/types.rs | 52 +++++++++++++++++++++++++-- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 053be94f..ae300c77 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2200,6 +2200,16 @@ SELECT df.grant_usage('app_role'); pg_durable uses multiple PostgreSQL connections for different purposes. Four GUCs let you control the connection budget to match your deployment's resources. +### Connection Host + +Set `pg_durable.host` in `postgresql.conf` to override the host for every PostgreSQL connection created by pg_durable: + +```ini +pg_durable.host = '/var/run/postgresql' +``` + +This postmaster setting requires a PostgreSQL restart. When it is empty or unset, pg_durable uses `PGHOST`, falling back to `127.0.0.1` when `PGHOST` is also unset. + ### Connection Architecture The background worker maintains three categories of connections, and diff --git a/docs/security-review/workbook-data.md b/docs/security-review/workbook-data.md index 1cd57db3..4d054059 100644 --- a/docs/security-review/workbook-data.md +++ b/docs/security-review/workbook-data.md @@ -115,13 +115,14 @@ pg_durable does not use token-based authentication. All identity is PostgreSQL r |---|---|---|---| | `pg_durable.worker_role` | "postgres" | Postmaster | Determines background worker's PostgreSQL identity; must be superuser | | `pg_durable.database` | "postgres" | Postmaster | Target database for extension operations | +| `pg_durable.host` | unset | Postmaster | Overrides `PGHOST` for all connections created by pg_durable | | `df.in_workflow` | unset | Session | Custom GUC set on worker connections; prevents variable mutation during execution | ### Environment Variables | Variable | Default | Purpose | |---|---|---| -| `PGHOST` | "127.0.0.1" | PostgreSQL host for worker connections | +| `PGHOST` | "127.0.0.1" | PostgreSQL host when `pg_durable.host` is unset | | `RUST_LOG` | (unset) | Controls tracing verbosity for worker process | --- diff --git a/src/lib.rs b/src/lib.rs index 11736117..208d8aa4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ pub static WORKER_ROLE: GucSetting> = pub static DATABASE: GucSetting> = GucSetting::>::new(Some(c"postgres")); +pub static HOST: GucSetting> = GucSetting::>::new(Some(c"")); + pub static MAX_MANAGEMENT_CONNECTIONS: GucSetting = GucSetting::::new(6); pub static MAX_DUROXIDE_CONNECTIONS: GucSetting = GucSetting::::new(10); pub static MAX_USER_CONNECTIONS: GucSetting = GucSetting::::new(10); @@ -105,6 +107,15 @@ pub extern "C-unwind" fn _PG_init() { GucFlags::default(), ); + GucRegistry::define_string_guc( + c"pg_durable.host", + c"PostgreSQL host used by pg_durable connections", + c"Overrides the PGHOST environment variable when set. Requires a server restart to change.", + &HOST, + GucContext::Postmaster, + GucFlags::SUPERUSER_ONLY, + ); + GucRegistry::define_int_guc( c"pg_durable.max_management_connections", c"Maximum number of connections in the background worker management pool (lifecycle, graph loading, status updates)", @@ -2942,6 +2953,28 @@ mod tests { ); } + #[pg_test] + fn test_host_guc_boot_default_is_unset() { + let boot_val = Spi::get_one::( + "SELECT boot_val FROM pg_catalog.pg_settings \ + WHERE name = 'pg_durable.host'", + ) + .unwrap() + .expect("GUC should exist in pg_settings"); + assert_eq!(boot_val, ""); + } + + #[pg_test] + fn test_host_guc_context_is_postmaster() { + let context = Spi::get_one::( + "SELECT context FROM pg_catalog.pg_settings \ + WHERE name = 'pg_durable.host'", + ) + .unwrap() + .expect("GUC should exist in pg_settings"); + assert_eq!(context, "postmaster"); + } + // ======================================================================== // Unit Tests - Superuser GUC // ======================================================================== diff --git a/src/types.rs b/src/types.rs index 00ab16c3..819d901b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -156,7 +156,7 @@ pub fn short_id() -> String { /// PostgreSQL connection string for the background worker and Duroxide runtime pub fn postgres_connection_string() -> String { - let host = std::env::var("PGHOST").unwrap_or_else(|_| "127.0.0.1".to_string()); + let host = get_host(); let port = unsafe { pgrx::pg_sys::PostPortNumber }; let user = get_worker_role(); let database = get_database(); @@ -191,9 +191,20 @@ fn build_connection_url(user: &str, host: &str, port: i32, database: &str) -> St } } -/// Get the PostgreSQL host for connections +fn resolve_host(configured_host: Option, environment_host: Option) -> String { + configured_host + .filter(|host| !host.is_empty()) + .or(environment_host) + .unwrap_or_else(|| "127.0.0.1".to_string()) +} + +/// Get the PostgreSQL host for connections. +/// `pg_durable.host` takes precedence over `PGHOST` when configured. pub fn get_host() -> String { - std::env::var("PGHOST").unwrap_or_else(|_| "127.0.0.1".to_string()) + let configured_host = crate::HOST + .get() + .map(|host| host.to_string_lossy().into_owned()); + resolve_host(configured_host, std::env::var("PGHOST").ok()) } /// Get the PostgreSQL port for connections @@ -1981,6 +1992,41 @@ mod tests { assert!(err.contains("Invalid quoted role name")); } + #[test] + fn configured_host_takes_precedence_over_pghost() { + assert_eq!( + resolve_host( + Some("configured.example.com".to_string()), + Some("environment.example.com".to_string()), + ), + "configured.example.com" + ); + } + + #[test] + fn pghost_is_used_when_host_guc_is_unset() { + assert_eq!( + resolve_host(None, Some("environment.example.com".to_string())), + "environment.example.com" + ); + } + + #[test] + fn pghost_is_used_when_host_guc_is_empty() { + assert_eq!( + resolve_host( + Some(String::new()), + Some("environment.example.com".to_string()), + ), + "environment.example.com" + ); + } + + #[test] + fn host_defaults_to_loopback_when_guc_and_pghost_are_unset() { + assert_eq!(resolve_host(None, None), "127.0.0.1"); + } + #[test] fn build_connection_url_tcp_host_unchanged() { assert_eq!( From 973dd6b83744094b57b2dcee4f8a9a1acd7d4b5b Mon Sep 17 00:00:00 2001 From: Pino de Candia Date: Wed, 26 Aug 2026 20:25:02 +0000 Subject: [PATCH 2/3] Test pg_durable.host precedence end to end --- scripts/test-e2e-local.sh | 30 ++++++++++++++-- tests/e2e/sql/67_host_guc.sql | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/sql/67_host_guc.sql diff --git a/scripts/test-e2e-local.sh b/scripts/test-e2e-local.sh index aacdb60c..1245c0fc 100755 --- a/scripts/test-e2e-local.sh +++ b/scripts/test-e2e-local.sh @@ -56,6 +56,7 @@ declare -a ACTIVE_PHASES=() DEFAULT_BUILD_PHASES=( "no-preload" "standard" + "host-guc" "superuser-guc-off" "connlimit-backpressure" "connlimit-timeout" @@ -67,6 +68,7 @@ DEFAULT_BUILD_PHASES=( ALL_PHASES=( "no-preload" "standard" + "host-guc" "superuser-guc-off" "connlimit-backpressure" "connlimit-timeout" @@ -137,6 +139,9 @@ phase_label() { standard) echo "standard suite" ;; + host-guc) + echo "pg_durable.host precedence" + ;; connlimit-backpressure) echo "connection limit backpressure" ;; @@ -172,6 +177,9 @@ phase_for_test() { 17_superuser_guc) echo "superuser-guc-off" ;; + 67_host_guc) + echo "host-guc" + ;; 44_connection_limit_backpressure) echo "connlimit-backpressure" ;; @@ -350,7 +358,11 @@ wait_for_server() { restart_server() { stop_server echo -e "${YELLOW}Starting PostgreSQL...${NC}" - "$PG_CTL" -D "$DATA_DIR" -l "$LOG_FILE" start >/dev/null 2>&1 + if [ -n "${SERVER_PGHOST:-}" ]; then + PGHOST="$SERVER_PGHOST" "$PG_CTL" -D "$DATA_DIR" -l "$LOG_FILE" start >/dev/null 2>&1 + else + "$PG_CTL" -D "$DATA_DIR" -l "$LOG_FILE" start >/dev/null 2>&1 + fi wait_for_server } @@ -458,11 +470,14 @@ configure_phase() { local phase="$1" ensure_data_dir + SERVER_PGHOST="" # Clear stale ALTER SYSTEM overrides from prior phases/runs. : > "$DATA_DIR/postgresql.auto.conf" set_conf_line "port" "$PG_PORT" clear_connlimit_gucs remove_conf_key "log_connections" + remove_conf_key "pg_durable.host" + remove_conf_key "unix_socket_directories" case "$phase" in no-preload) @@ -475,6 +490,15 @@ configure_phase() { set_conf_line "pg_durable.enable_superuser_instances" "on" set_conf_line "log_connections" "on" ;; + host-guc) + set_conf_line "shared_preload_libraries" "'pg_durable'" + set_conf_line "pg_durable.worker_role" "'postgres'" + set_conf_line "pg_durable.database" "'postgres'" + set_conf_line "pg_durable.host" "'$PGRX_HOME'" + set_conf_line "pg_durable.enable_superuser_instances" "on" + set_conf_line "unix_socket_directories" "'$PGRX_HOME'" + SERVER_PGHOST="does-not-resolve.invalid" + ;; superuser-guc-off) set_conf_line "shared_preload_libraries" "'pg_durable'" set_conf_line "pg_durable.worker_role" "'postgres'" @@ -550,7 +574,7 @@ prepare_phase() { http-allow-all) build_extension_http_allow_all ;; - no-preload|standard|superuser-guc-off|connlimit-backpressure|connlimit-timeout|connlimit-startup|reconcile) + no-preload|standard|host-guc|superuser-guc-off|connlimit-backpressure|connlimit-timeout|connlimit-startup|reconcile) # Rebuild if previous phase changed the Cargo features if [ "$CURRENT_FEATURES" != "http-allow-test-domains" ]; then build_extension @@ -600,7 +624,7 @@ prepare_phase() { wait_for_worker_ready fi ;; - connlimit-backpressure|connlimit-timeout) + host-guc|connlimit-backpressure|connlimit-timeout) ensure_e2e_role wait_for_worker_ready ;; diff --git a/tests/e2e/sql/67_host_guc.sql b/tests/e2e/sql/67_host_guc.sql new file mode 100644 index 00000000..cab927d9 --- /dev/null +++ b/tests/e2e/sql/67_host_guc.sql @@ -0,0 +1,64 @@ +-- Copyright (c) Microsoft Corporation. +-- Licensed under the PostgreSQL License. + +-- The host-guc phase starts PostgreSQL with an invalid PGHOST while setting +-- pg_durable.host to the server's Unix-socket directory. Worker readiness +-- proves URL-based pool connections use the GUC; this workflow proves the +-- direct per-user connection path uses it too. + +DO $$ +BEGIN + IF current_setting('pg_durable.host') != current_setting('unix_socket_directories') THEN + RAISE EXCEPTION 'TEST FAILED: pg_durable.host is not the configured socket directory'; + END IF; +END $$; + +DROP TABLE IF EXISTS host_guc_log; +CREATE TABLE host_guc_log ( + submitted_by TEXT NOT NULL, + connection_path TEXT NOT NULL +); +GRANT INSERT, SELECT ON host_guc_log TO df_e2e_user; + +CREATE TEMP TABLE _test_state ( + instance_id TEXT, + connection_path TEXT +); +GRANT INSERT ON _test_state TO df_e2e_user; + +SET ROLE df_e2e_user; +INSERT INTO _test_state +SELECT df.start( + 'INSERT INTO host_guc_log VALUES (current_user, ''workflow-sql'')', + 'host-guc-precedence' +), 'workflow-sql'; + +INSERT INTO _test_state +SELECT df.start( + 'INSERT INTO host_guc_log VALUES (current_user, ''new-transaction'')', + 'host-guc-new-transaction', + transaction_mode => 'new' +), 'new-transaction'; +RESET ROLE; + +DO $$ +DECLARE + test_state RECORD; + status TEXT; +BEGIN + FOR test_state IN SELECT * FROM _test_state LOOP + SELECT df.await_instance(test_state.instance_id, 30) INTO status; + + IF status != 'completed' THEN + RAISE EXCEPTION 'TEST FAILED: % status = %', test_state.connection_path, status; + END IF; + END LOOP; + + IF (SELECT count(*) FROM host_guc_log WHERE submitted_by = 'df_e2e_user') != 2 THEN + RAISE EXCEPTION 'TEST FAILED: expected both connection paths to execute as df_e2e_user'; + END IF; +END $$; + +DROP TABLE _test_state; +DROP TABLE host_guc_log; +SELECT 'TEST PASSED' AS result; \ No newline at end of file From 0124d99df5092274c3339999343cffc0eed5c867 Mon Sep 17 00:00:00 2001 From: Pino de Candia Date: Thu, 27 Aug 2026 20:36:19 +0000 Subject: [PATCH 3/3] Address review feedback on pg_durable.host Drop GucFlags::SUPERUSER_ONLY so pg_durable.host matches the visibility of pg_durable.worker_role and pg_durable.database; the Postmaster context already prevents runtime changes. Percent-encode connection URL hosts that are not plain names, IPv4 addresses, or bracketed IPv6 literals. A misconfigured host carrying URL metacharacters now stays inside the host component and fails to resolve instead of injecting a different host or extra connection parameters. Set unix_socket_directories in every E2E phase instead of removing it, so the shared pgrx cluster keeps a socket directory for `make installcheck`. Also add the changelog entry, promote the user guide section out of "Connection Limits", and fix the missing trailing newline. --- CHANGELOG.md | 4 ++ USER_GUIDE.md | 21 ++++++----- scripts/test-e2e-local.sh | 5 ++- src/lib.rs | 2 +- src/types.rs | 70 ++++++++++++++++++++++++++++++++--- tests/e2e/sql/67_host_guc.sql | 2 +- 6 files changed, 85 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b99cb3d..cc7358b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc ## [0.2.7] - Unreleased +### Added + +- **`pg_durable.host` (#360):** a postmaster GUC that selects the PostgreSQL host used by every connection pg_durable creates. When empty or unset, `PGHOST` is used, falling back to `127.0.0.1`. + ## [0.2.6] - 2026-08-23 ### Added diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ae300c77..7d6581cf 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -23,10 +23,11 @@ pg_durable is a PostgreSQL extension that brings durable, fault-tolerant functio 13. [Visualizing Functions](#visualizing-functions) 14. [Monitoring](#monitoring) 15. [User Isolation & Privileges](#user-isolation--privileges) -16. [Connection Limits](#connection-limits) -17. [Troubleshooting](#troubleshooting) -18. [Quick Reference Card](#quick-reference-card) -19. [Appendix: Test Data Setup](#appendix-test-data-setup) +16. [Connection Host](#connection-host) +17. [Connection Limits](#connection-limits) +18. [Troubleshooting](#troubleshooting) +19. [Quick Reference Card](#quick-reference-card) +20. [Appendix: Test Data Setup](#appendix-test-data-setup) --- @@ -2196,11 +2197,7 @@ SELECT df.grant_usage('app_role'); --- -## Connection Limits - -pg_durable uses multiple PostgreSQL connections for different purposes. Four GUCs let you control the connection budget to match your deployment's resources. - -### Connection Host +## Connection Host Set `pg_durable.host` in `postgresql.conf` to override the host for every PostgreSQL connection created by pg_durable: @@ -2210,6 +2207,12 @@ pg_durable.host = '/var/run/postgresql' This postmaster setting requires a PostgreSQL restart. When it is empty or unset, pg_durable uses `PGHOST`, falling back to `127.0.0.1` when `PGHOST` is also unset. +--- + +## Connection Limits + +pg_durable uses multiple PostgreSQL connections for different purposes. Four GUCs let you control the connection budget to match your deployment's resources. + ### Connection Architecture The background worker maintains three categories of connections, and diff --git a/scripts/test-e2e-local.sh b/scripts/test-e2e-local.sh index 1245c0fc..e90d83ed 100755 --- a/scripts/test-e2e-local.sh +++ b/scripts/test-e2e-local.sh @@ -477,7 +477,9 @@ configure_phase() { clear_connlimit_gucs remove_conf_key "log_connections" remove_conf_key "pg_durable.host" - remove_conf_key "unix_socket_directories" + # Match scripts/pg-common.sh so the shared pgrx cluster keeps a usable socket + # directory for `make installcheck` after an E2E run. + set_conf_line "unix_socket_directories" "'$PGRX_HOME'" case "$phase" in no-preload) @@ -496,7 +498,6 @@ configure_phase() { set_conf_line "pg_durable.database" "'postgres'" set_conf_line "pg_durable.host" "'$PGRX_HOME'" set_conf_line "pg_durable.enable_superuser_instances" "on" - set_conf_line "unix_socket_directories" "'$PGRX_HOME'" SERVER_PGHOST="does-not-resolve.invalid" ;; superuser-guc-off) diff --git a/src/lib.rs b/src/lib.rs index 208d8aa4..08826a7e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -113,7 +113,7 @@ pub extern "C-unwind" fn _PG_init() { c"Overrides the PGHOST environment variable when set. Requires a server restart to change.", &HOST, GucContext::Postmaster, - GucFlags::SUPERUSER_ONLY, + GucFlags::default(), ); GucRegistry::define_int_guc( diff --git a/src/types.rs b/src/types.rs index 819d901b..65902ac1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -177,20 +177,39 @@ pub(crate) fn connection_url_with_application_name( format!("{database_url}{separator}application_name={encoded}") } +/// Whether `host` can be placed in a `postgres://` URL verbatim. +/// +/// Plain hostnames, IPv4 addresses, and bracketed IPv6 literals are safe. Anything +/// else (Unix-socket paths, or a `pg_durable.host` / `PGHOST` value carrying URL +/// metacharacters) must be percent-encoded so it cannot inject a different host or +/// extra connection parameters. +fn host_is_url_safe(host: &str) -> bool { + if host.starts_with('[') { + return host.ends_with(']'); + } + + host.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + /// Build the worker's `postgres://` connection URL. /// -/// A Unix-socket `host` (one starting with `/`) is percent-encoded so the URL -/// parser keeps the whole path as the host; sqlx percent-decodes it and -/// connects over the socket. TCP addresses and hostnames are used verbatim. +/// Hosts that are not plain names or addresses are percent-encoded so the URL +/// parser keeps the whole value inside the host component. sqlx decodes socket +/// paths back out, so Unix sockets still connect; a host carrying URL +/// metacharacters stays inert and fails to resolve instead of injecting a +/// different host or extra connection parameters. fn build_connection_url(user: &str, host: &str, port: i32, database: &str) -> String { - if host.starts_with('/') { + if host_is_url_safe(host) { + format!("postgres://{user}@{host}:{port}/{database}") + } else { let encoded = utf8_percent_encode(host, NON_ALPHANUMERIC).to_string(); format!("postgres://{user}@{encoded}:{port}/{database}") - } else { - format!("postgres://{user}@{host}:{port}/{database}") } } +/// An empty `PGHOST` is deliberately preserved as an empty host so sqlx applies its +/// own default; only `pg_durable.host` treats empty as "not configured". fn resolve_host(configured_host: Option, environment_host: Option) -> String { configured_host .filter(|host| !host.is_empty()) @@ -2114,6 +2133,45 @@ mod tests { assert_ne!(opts.get_host(), "evil.com"); } + #[test] + fn build_connection_url_tcp_host_with_query_is_opaque() { + use sqlx::postgres::PgConnectOptions; + use std::str::FromStr; + + // A configured host carrying URL metacharacters must stay inside the host + // component; it must not split off and apply connection parameters. + let url = build_connection_url( + "postgres", + "db.example.com?sslmode=disable", + 5432, + "postgres", + ); + let opts = PgConnectOptions::from_str(&url).expect("TCP URL should parse"); + assert_ne!(opts.get_host(), "db.example.com"); + assert!(matches!( + opts.get_ssl_mode(), + sqlx::postgres::PgSslMode::Prefer + )); + } + + #[test] + fn build_connection_url_tcp_host_with_userinfo_is_opaque() { + use sqlx::postgres::PgConnectOptions; + use std::str::FromStr; + + let url = build_connection_url("postgres", "x@evil.com", 5432, "postgres"); + let opts = PgConnectOptions::from_str(&url).expect("TCP URL should parse"); + assert_ne!(opts.get_host(), "evil.com"); + } + + #[test] + fn build_connection_url_ipv6_host_unchanged() { + assert_eq!( + build_connection_url("postgres", "[::1]", 5432, "postgres"), + "postgres://postgres@[::1]:5432/postgres" + ); + } + #[test] fn connection_url_application_name_is_encoded() { let url = connection_url_with_application_name( diff --git a/tests/e2e/sql/67_host_guc.sql b/tests/e2e/sql/67_host_guc.sql index cab927d9..b94818d1 100644 --- a/tests/e2e/sql/67_host_guc.sql +++ b/tests/e2e/sql/67_host_guc.sql @@ -61,4 +61,4 @@ END $$; DROP TABLE _test_state; DROP TABLE host_guc_log; -SELECT 'TEST PASSED' AS result; \ No newline at end of file +SELECT 'TEST PASSED' AS result;