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 053be94f..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,6 +2197,18 @@ SELECT df.grant_usage('app_role'); --- +## 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 Limits pg_durable uses multiple PostgreSQL connections for different purposes. Four GUCs let you control the connection budget to match your deployment's resources. 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/scripts/test-e2e-local.sh b/scripts/test-e2e-local.sh index aacdb60c..e90d83ed 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,16 @@ 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" + # 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) @@ -475,6 +492,14 @@ 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" + 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 +575,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 +625,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/src/lib.rs b/src/lib.rs index 11736117..08826a7e 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::default(), + ); + 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..65902ac1 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(); @@ -177,23 +177,53 @@ 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}") } } -/// Get the PostgreSQL host for connections +/// 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()) + .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 +2011,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!( @@ -2068,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 new file mode 100644 index 00000000..b94818d1 --- /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;