Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/security-review/workbook-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---
Expand Down
31 changes: 28 additions & 3 deletions scripts/test-e2e-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ declare -a ACTIVE_PHASES=()
DEFAULT_BUILD_PHASES=(
"no-preload"
"standard"
"host-guc"
"superuser-guc-off"
"connlimit-backpressure"
"connlimit-timeout"
Expand All @@ -67,6 +68,7 @@ DEFAULT_BUILD_PHASES=(
ALL_PHASES=(
"no-preload"
"standard"
"host-guc"
"superuser-guc-off"
"connlimit-backpressure"
"connlimit-timeout"
Expand Down Expand Up @@ -137,6 +139,9 @@ phase_label() {
standard)
echo "standard suite"
;;
host-guc)
echo "pg_durable.host precedence"
;;
connlimit-backpressure)
echo "connection limit backpressure"
;;
Expand Down Expand Up @@ -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"
;;
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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'"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
;;
Expand Down
33 changes: 33 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ pub static WORKER_ROLE: GucSetting<Option<CString>> =
pub static DATABASE: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(Some(c"postgres"));

pub static HOST: GucSetting<Option<CString>> = GucSetting::<Option<CString>>::new(Some(c""));

pub static MAX_MANAGEMENT_CONNECTIONS: GucSetting<i32> = GucSetting::<i32>::new(6);
pub static MAX_DUROXIDE_CONNECTIONS: GucSetting<i32> = GucSetting::<i32>::new(10);
pub static MAX_USER_CONNECTIONS: GucSetting<i32> = GucSetting::<i32>::new(10);
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -2942,6 +2953,28 @@ mod tests {
);
}

#[pg_test]
fn test_host_guc_boot_default_is_unset() {
let boot_val = Spi::get_one::<String>(
"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::<String>(
"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
// ========================================================================
Expand Down
122 changes: 113 additions & 9 deletions src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<String>, environment_host: Option<String>) -> 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
Expand Down Expand Up @@ -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!(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading