From 30b8f2eac8dc8f8b31556e242db90329e8f392b2 Mon Sep 17 00:00:00 2001 From: Andrey Borodin Date: Wed, 22 Apr 2026 11:14:17 +0500 Subject: [PATCH] libpq: Add DNS SRV record support for service discovery Add a new connection parameter "srvhost" that causes libpq to query _postgresql._tcp. SRV records before connecting, replacing the normal host list with the sorted result. This allows a whole PostgreSQL cluster to be addressed by a single DNS name. New connection options: srvhost= DNS domain for SRV lookup (env: PGSRVHOST) postgresql+srv:/// URI shorthand for srvhost postgres+srv:/// alias SRV records are sorted per RFC 2782 (priority ascending, weight descending) and injected into the existing multi-host machinery before connhost[] is built, so target_session_attrs, load_balance_hosts, and failover all work transparently on the expanded host list. srvhost is mutually exclusive with host and hostaddr. Multiple hosts in a +srv URI are rejected because expansion is DNS's job. On POSIX, resolution uses res_query(3) + dn_expand(3) to parse the DNS wire format directly, avoiding ns_initparse() which is absent on musl. On Windows, DnsQuery() from windns.h is used instead. Platforms without either receive a clear error at connect time. The resolver is called from pqConnectOptions2() before the connhost[] array is allocated, requiring no changes to PQconnectPoll or the connection state machine. --- configure | 56 ++++ configure.ac | 11 + doc/src/sgml/libpq.sgml | 52 +++ meson.build | 20 ++ src/include/pg_config.h.in | 3 + src/interfaces/libpq/Makefile | 13 +- src/interfaces/libpq/fe-connect-srv.c | 437 ++++++++++++++++++++++++++ src/interfaces/libpq/fe-connect-srv.h | 29 ++ src/interfaces/libpq/fe-connect.c | 83 ++++- src/interfaces/libpq/libpq-int.h | 5 + src/interfaces/libpq/meson.build | 1 + src/interfaces/libpq/t/007_srv.pl | 159 ++++++++++ 12 files changed, 863 insertions(+), 6 deletions(-) create mode 100644 src/interfaces/libpq/fe-connect-srv.c create mode 100644 src/interfaces/libpq/fe-connect-srv.h create mode 100644 src/interfaces/libpq/t/007_srv.pl diff --git a/configure b/configure index f66c1054a7..175a0bfcaf 100755 --- a/configure +++ b/configure @@ -12615,6 +12615,62 @@ if test "$ac_res" != no; then : fi +{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing res_query" >&5 +$as_echo_n "checking for library containing res_query... " >&6; } +if ${ac_cv_search_res_query+:} false; then : + $as_echo_n "(cached) " >&6 +else + ac_func_search_save_LIBS=$LIBS +cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +#ifdef __cplusplus +extern "C" +#endif +char res_query (); +int +main () +{ +return res_query (); + ; + return 0; +} +_ACEOF +for ac_lib in '' resolv; do + if test -z "$ac_lib"; then + ac_res="none required" + else + ac_res=-l$ac_lib + LIBS="-l$ac_lib $ac_func_search_save_LIBS" + fi + if ac_fn_c_try_link "$LINENO"; then : + ac_cv_search_res_query=$ac_res +fi +rm -f core conftest.err conftest.$ac_objext \ + conftest$ac_exeext + if ${ac_cv_search_res_query+:} false; then : + break +fi +done +if ${ac_cv_search_res_query+:} false; then : + +else + ac_cv_search_res_query=no +fi +rm conftest.$ac_ext +LIBS=$ac_func_search_save_LIBS +fi +{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_search_res_query" >&5 +$as_echo "$ac_cv_search_res_query" >&6; } +ac_res=$ac_cv_search_res_query +if test "$ac_res" != no; then : + test "$ac_res" = "none required" || LIBS="$ac_res $LIBS" + +$as_echo "#define HAVE_RES_QUERY 1" >>confdefs.h + +fi + + if test "$with_readline" = yes; then diff --git a/configure.ac b/configure.ac index 8d176bd346..36d836275b 100644 --- a/configure.ac +++ b/configure.ac @@ -1386,6 +1386,17 @@ AC_SEARCH_LIBS(backtrace_symbols, execinfo) AC_SEARCH_LIBS(pthread_barrier_wait, pthread) +dnl +dnl DNS SRV record support in libpq. +dnl On Linux and some BSDs res_query() lives in libresolv; on macOS it is +dnl available from the system library without any extra -l flag. +dnl AC_SEARCH_LIBS handles both cases: it tries libc first, then libresolv. +dnl +AC_SEARCH_LIBS(res_query, resolv, + [AC_DEFINE([HAVE_RES_QUERY], 1, + [Define to 1 if you have the res_query() DNS resolver function.])]) +AC_CHECK_HEADERS([arpa/nameser.h resolv.h]) + if test "$with_readline" = yes; then PGAC_CHECK_READLINE if test x"$pgac_cv_check_readline" = x"no"; then diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml index 0a19c2b553..db3e669a74 100644 --- a/doc/src/sgml/libpq.sgml +++ b/doc/src/sgml/libpq.sgml @@ -1129,6 +1129,48 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname The currently recognized parameter key words are: + + srvhost + + + DNS domain name used for SRV-based service discovery (RFC 2782). + When set, libpq queries DNS for + _postgresql._tcp.srvhost + SRV records and derives the list of hosts and ports to try from the + sorted result. Records are ordered by priority (ascending) then + weight (descending), exactly as specified in RFC 2782. + + + This parameter is mutually exclusive with host and + hostaddr. It works together with all other + connection parameters, including target_session_attrs + and load_balance_hosts, which are applied to the + expanded host list after DNS resolution. + + + The environment variable equivalent is PGSRVHOST. + + + The postgresql+srv:// and + postgres+srv:// URI schemes are a shorthand for + setting srvhost from the URI host part: + + +postgresql+srv://cluster.example.com/mydb + + + is equivalent to: + + +postgresql:///mydb?srvhost=cluster.example.com + + + Multiple hosts are not permitted in a +srv URI + because the host expansion is performed by DNS. + + + + host @@ -9060,6 +9102,16 @@ myEventProc(PGEventId evtId, void *evtInfo, void *passThrough) information into simple client applications, for example. + + + + PGSRVHOST + + PGSRVHOST behaves the same as the connection parameter. + + + diff --git a/meson.build b/meson.build index 20b887f1a1..76533e7f45 100644 --- a/meson.build +++ b/meson.build @@ -376,6 +376,8 @@ elif host_system == 'windows' secur32_dep = cc.find_library('secur32', required: true) backend_deps += secur32_dep libpq_deps += secur32_dep + # DnsQuery() for SRV record lookup on Windows + libpq_deps += cc.find_library('dnsapi', required: true) postgres_inc_d += 'src/include/port/win32' if cc.get_id() == 'msvc' @@ -1851,6 +1853,24 @@ endif +############################################################### +# DNS SRV support (libpq) +############################################################### + +# res_query() and dn_expand() are used by fe-connect-srv.c to resolve SRV +# records. On most POSIX systems they live in libresolv; on macOS they are +# in the system library but still require explicit linking with libresolv. +if host_machine.system() != 'windows' + resolv_dep = cc.find_library('resolv', required: false) + if resolv_dep.found() + cdata.set('HAVE_RES_QUERY', 1) + libpq_deps += resolv_dep + elif cc.has_function('res_query') + cdata.set('HAVE_RES_QUERY', 1) + endif +endif + + ############################################################### # Compiler tests ############################################################### diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in index 4f8113c144..40dbd52cff 100644 --- a/src/include/pg_config.h.in +++ b/src/include/pg_config.h.in @@ -204,6 +204,9 @@ /* Define to 1 if you have the header file. */ #undef HAVE_IFADDRS_H +/* Define to 1 if you have the `res_query' DNS resolver function. */ +#undef HAVE_RES_QUERY + /* Define to 1 if you have the `inet_aton' function. */ #undef HAVE_INET_ATON diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile index 0963995eed..4e20645543 100644 --- a/src/interfaces/libpq/Makefile +++ b/src/interfaces/libpq/Makefile @@ -34,6 +34,7 @@ OBJS = \ fe-auth-scram.o \ fe-cancel.o \ fe-connect.o \ + fe-connect-srv.o \ fe-exec.o \ fe-lobj.o \ fe-misc.o \ @@ -88,11 +89,21 @@ endif SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib ifneq ($(PORTNAME), win32) SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -ldl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS) +# DNS SRV: link with libresolv if the library exists and -lresolv is not +# already in SHLIB_LINK (e.g. because configure added it to LIBS). +# On Linux/glibc res_query lives in libresolv; on macOS it is in +# /usr/lib/libresolv.dylib. The empty-source link test is portable. +ifeq ($(filter -lresolv, $(SHLIB_LINK)),) +HAVE_RESOLV_LIB := $(shell echo 'int main(void){return 0;}' | $(CC) -lresolv -o /dev/null -x c - 2>/dev/null && echo yes) +ifeq ($(HAVE_RESOLV_LIB), yes) +SHLIB_LINK += -lresolv +endif +endif else SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE) endif ifeq ($(PORTNAME), win32) -SHLIB_LINK += -lshell32 -lws2_32 -lsecur32 $(filter -lcomerr32 -lkrb5_32, $(LIBS)) +SHLIB_LINK += -lshell32 -lws2_32 -lsecur32 -ldnsapi $(filter -lcomerr32 -lkrb5_32, $(LIBS)) endif SHLIB_PREREQS = submake-libpgport diff --git a/src/interfaces/libpq/fe-connect-srv.c b/src/interfaces/libpq/fe-connect-srv.c new file mode 100644 index 0000000000..c5b7faf827 --- /dev/null +++ b/src/interfaces/libpq/fe-connect-srv.c @@ -0,0 +1,437 @@ +/*------------------------------------------------------------------------- + * + * fe-connect-srv.c + * DNS SRV record lookup for libpq service discovery. + * + * When a connection string specifies "srvhost=cluster.example.com" (or the + * equivalent URI "postgresql+srv://cluster.example.com/"), libpq resolves + * _postgresql._tcp. SRV records, sorts them by priority (ascending) + * then weight (descending) per RFC 2782, and expands the result into the + * conn->pghost and conn->pgport fields before pqConnectOptions2() builds the + * per-host connection array. All existing multi-host machinery (failover, + * target_session_attrs, load_balance_hosts) then works unchanged. + * + * Platform support: + * - POSIX systems with res_query(3): Linux (glibc), macOS, *BSD, Solaris. + * Guarded by HAVE_RES_QUERY which is set by configure / meson. + * - Windows: DnsQuery() from windns.h, linked with Dnsapi.lib. + * - All other platforms: srvhost is rejected at connection time with an + * informative error message. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/interfaces/libpq/fe-connect-srv.c + * + *------------------------------------------------------------------------- + */ + +#include "postgres_fe.h" + +#include "common/int.h" +#include "fe-connect-srv.h" +#include "libpq-int.h" +#include "pqexpbuffer.h" + +/* ---------- + * Internal SRV record representation. + * ---------- + */ +typedef struct +{ + uint16_t priority; + uint16_t weight; + uint16_t port; + char target[NI_MAXHOST]; +} SRVRecord; + +/* Sort: lower priority first; higher weight first within same priority. */ +static int +compareSRVRecords(const void *a, const void *b) +{ + const SRVRecord *sa = (const SRVRecord *) a; + const SRVRecord *sb = (const SRVRecord *) b; + + if (sa->priority != sb->priority) + return pg_cmp_u16(sa->priority, sb->priority); + return pg_cmp_u16(sb->weight, sa->weight); +} + +/* + * Build conn->pghost and conn->pgport as comma-separated strings from a + * sorted SRVRecord array. Returns true on success; on OOM sets the + * connection error and returns false. + */ +static bool +srv_build_host_port(PGconn *conn, SRVRecord *records, int nrecords) +{ + PQExpBufferData hostbuf; + PQExpBufferData portbuf; + + initPQExpBuffer(&hostbuf); + initPQExpBuffer(&portbuf); + + for (int i = 0; i < nrecords; i++) + { + if (i > 0) + { + appendPQExpBufferChar(&hostbuf, ','); + appendPQExpBufferChar(&portbuf, ','); + } + appendPQExpBufferStr(&hostbuf, records[i].target); + appendPQExpBuffer(&portbuf, "%u", records[i].port); + } + + if (PQExpBufferDataBroken(hostbuf) || PQExpBufferDataBroken(portbuf)) + { + termPQExpBuffer(&hostbuf); + termPQExpBuffer(&portbuf); + libpq_append_conn_error(conn, "out of memory"); + return false; + } + + /* + * Replace conn->pghost and conn->pgport with the SRV-derived values. + * The originals were either NULL or empty (caller verified), but free + * them defensively before overwriting. + */ + free(conn->pghost); + conn->pghost = strdup(hostbuf.data); + free(conn->pgport); + conn->pgport = strdup(portbuf.data); + + termPQExpBuffer(&hostbuf); + termPQExpBuffer(&portbuf); + + if (!conn->pghost || !conn->pgport) + { + libpq_append_conn_error(conn, "out of memory"); + return false; + } + + return true; +} + + +/* ==================================================================== + * Platform-specific implementations + * ==================================================================== */ + +#ifdef WIN32 + +/* + * Windows implementation using DnsQuery() from windns.h. + * Link with -ldnsapi (Dnsapi.lib). + */ +#include + +bool +pqLookupSRVHosts(PGconn *conn) +{ + char qname[NI_MAXHOST + 30]; + PDNS_RECORD pDnsRecord = NULL; + PDNS_RECORD pRec; + SRVRecord *records = NULL; + int nrecords = 0; + int maxrecords = 0; + DNS_STATUS status; + bool ok; + + snprintf(qname, sizeof(qname), "_postgresql._tcp.%s", conn->srvhost); + + status = DnsQuery_A(qname, DNS_TYPE_SRV, DNS_QUERY_STANDARD, + NULL, &pDnsRecord, NULL); + if (status != ERROR_SUCCESS) + { + libpq_append_conn_error(conn, + "DNS SRV lookup failed for \"%s\": error code %lu", + qname, (unsigned long) status); + return false; + } + + /* Collect SRV records from the linked list */ + for (pRec = pDnsRecord; pRec != NULL; pRec = pRec->pNext) + { + SRVRecord *tmp; + SRVRecord *rec; + size_t tlen; + + if (pRec->wType != DNS_TYPE_SRV) + continue; + + if (nrecords >= maxrecords) + { + maxrecords = maxrecords ? maxrecords * 2 : 4; + tmp = realloc(records, maxrecords * sizeof(SRVRecord)); + if (!tmp) + { + DnsRecordListFree(pDnsRecord, DnsFreeRecordList); + free(records); + libpq_append_conn_error(conn, "out of memory"); + return false; + } + records = tmp; + } + + rec = &records[nrecords]; + rec->priority = pRec->Data.SRV.wPriority; + rec->weight = pRec->Data.SRV.wWeight; + rec->port = pRec->Data.SRV.wPort; + + /* + * DnsQuery_A returns pNameTarget as a plain ANSI string (PSTR); + * no wide-char conversion needed. + */ + strlcpy(rec->target, pRec->Data.SRV.pNameTarget, sizeof(rec->target)); + + /* Strip trailing dot from FQDN */ + tlen = strlen(rec->target); + if (tlen > 0 && rec->target[tlen - 1] == '.') + rec->target[tlen - 1] = '\0'; + + nrecords++; + } + + DnsRecordListFree(pDnsRecord, DnsFreeRecordList); + + if (nrecords == 0) + { + libpq_append_conn_error(conn, "no SRV records found for \"%s\"", qname); + free(records); + return false; + } + + qsort(records, nrecords, sizeof(SRVRecord), compareSRVRecords); + + ok = srv_build_host_port(conn, records, nrecords); + + free(records); + return ok; +} + +#elif defined(HAVE_RES_QUERY) + +/* + * POSIX implementation using res_query() and manual DNS wire-format parsing. + * + * HAVE_RES_QUERY is set by configure (AC_SEARCH_LIBS) and meson + * (cc.has_function / find_library) when res_query() is available. + * + * We avoid depending on ns_initparse() / ns_parserr() since those symbols + * are not available on all platforms (e.g. musl libc). Instead we parse + * the wire format directly, using only dn_expand() for name decompression + * (which is universally available wherever res_query() is). + */ + +#include +#include +#include + +/* DNS wire-format constants (from RFC 1035 / RFC 2782) */ +#ifndef T_SRV +#define T_SRV 33 /* RFC 2782: Service locator */ +#endif +#ifndef C_IN +#define C_IN 1 /* the Internet */ +#endif +#ifndef HFIXEDSZ +#define HFIXEDSZ 12 /* DNS message header size */ +#endif + +/* + * Skip a (possibly compressed) DNS domain name starting at cp. + * Returns bytes consumed, or -1 on error. + */ +static int +srv_skip_dname(const unsigned char *eom, const unsigned char *cp) +{ + const unsigned char *orig = cp; + + while (cp < eom) + { + int n = *cp & 0xFF; + + if (n == 0) + return (int) ((cp - orig) + 1); /* root label */ + if ((n & 0xC0) == 0xC0) + return (int) ((cp - orig) + 2); /* 2-byte pointer */ + if ((n & 0xC0) != 0) + return -1; /* unknown label type */ + cp += n + 1; + } + return -1; /* truncated */ +} + +bool +pqLookupSRVHosts(PGconn *conn) +{ + char qname[NI_MAXHOST + 30]; + unsigned char answer[4096]; + int len; + const unsigned char *cp; + const unsigned char *eom; + int qdcount, + ancount; + SRVRecord *records = NULL; + int nrecords = 0; + int maxrecords = 0; + bool retval = false; + + snprintf(qname, sizeof(qname), "_postgresql._tcp.%s", conn->srvhost); + + len = res_query(qname, C_IN, T_SRV, answer, sizeof(answer)); + if (len < 0) + { + /* + * h_errno is set by res_query on failure. HOST_NOT_FOUND and + * NO_DATA both indicate that the SRV RRset simply doesn't exist. + */ + const char *reason; + + switch (h_errno) + { + case HOST_NOT_FOUND: + reason = "host not found"; + break; + case NO_DATA: + reason = "no SRV records published"; + break; + case TRY_AGAIN: + reason = "temporary DNS failure, try again"; + break; + default: + reason = hstrerror(h_errno); + break; + } + libpq_append_conn_error(conn, + "DNS SRV lookup failed for \"%s\": %s", + qname, reason); + return false; + } + + if (len < HFIXEDSZ) + { + libpq_append_conn_error(conn, + "DNS response too short for \"%s\"", qname); + return false; + } + + eom = answer + len; + + /* + * Parse the DNS response header (RFC 1035 §4.1.1): + * bytes 4-5: QDCOUNT, bytes 6-7: ANCOUNT + */ + qdcount = (answer[4] << 8) | answer[5]; + ancount = (answer[6] << 8) | answer[7]; + cp = answer + HFIXEDSZ; + + /* Skip the question section */ + for (int i = 0; i < qdcount; i++) + { + int n = srv_skip_dname(eom, cp); + + if (n < 0) + goto parse_error; + cp += n + 4; /* name + QTYPE + QCLASS */ + if (cp > eom) + goto parse_error; + } + + /* Parse the answer section */ + for (int i = 0; i < ancount; i++) + { + int n; + uint16_t rtype, + rdlen; + char target[NI_MAXHOST]; + size_t tlen; + + n = srv_skip_dname(eom, cp); + if (n < 0) + goto parse_error; + cp += n; + + /* Need TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = 10 bytes */ + if (cp + 10 > eom) + goto parse_error; + + rtype = (cp[0] << 8) | cp[1]; + rdlen = (cp[8] << 8) | cp[9]; + cp += 10; + + if (cp + rdlen > eom) + goto parse_error; + + if (rtype == T_SRV) + { + /* SRV RDATA: priority(2) weight(2) port(2) target(variable) */ + if (rdlen < 7) + goto parse_error; + + n = dn_expand(answer, eom, cp + 6, target, sizeof(target)); + if (n < 0) + goto parse_error; + + /* Strip trailing dot from FQDN */ + tlen = strlen(target); + if (tlen > 0 && target[tlen - 1] == '.') + target[tlen - 1] = '\0'; + + if (nrecords >= maxrecords) + { + SRVRecord *tmp; + + maxrecords = maxrecords ? maxrecords * 2 : 4; + tmp = realloc(records, maxrecords * sizeof(SRVRecord)); + if (!tmp) + { + libpq_append_conn_error(conn, "out of memory"); + goto cleanup; + } + records = tmp; + } + + records[nrecords].priority = (cp[0] << 8) | cp[1]; + records[nrecords].weight = (cp[2] << 8) | cp[3]; + records[nrecords].port = (cp[4] << 8) | cp[5]; + strlcpy(records[nrecords].target, target, + sizeof(records[nrecords].target)); + nrecords++; + } + + cp += rdlen; + } + + if (nrecords == 0) + { + libpq_append_conn_error(conn, + "no SRV records found for \"%s\"", qname); + goto cleanup; + } + + qsort(records, nrecords, sizeof(SRVRecord), compareSRVRecords); + + retval = srv_build_host_port(conn, records, nrecords); + goto cleanup; + +parse_error: + libpq_append_conn_error(conn, "malformed DNS response for \"%s\"", qname); + +cleanup: + free(records); + return retval; +} + +#else /* no SRV support on this platform */ + +bool +pqLookupSRVHosts(PGconn *conn) +{ + libpq_append_conn_error(conn, + "\"srvhost\" is not supported on this platform " + "(DNS SRV lookup requires res_query)"); + return false; +} + +#endif /* platform selection */ diff --git a/src/interfaces/libpq/fe-connect-srv.h b/src/interfaces/libpq/fe-connect-srv.h new file mode 100644 index 0000000000..d4117ccb0d --- /dev/null +++ b/src/interfaces/libpq/fe-connect-srv.h @@ -0,0 +1,29 @@ +/*------------------------------------------------------------------------- + * + * fe-connect-srv.h + * DNS SRV record lookup for libpq service discovery. + * + * Copyright (c) 2026, PostgreSQL Global Development Group + * + * IDENTIFICATION + * src/interfaces/libpq/fe-connect-srv.h + * + *------------------------------------------------------------------------- + */ +#ifndef FE_CONNECT_SRV_H +#define FE_CONNECT_SRV_H + +#include "libpq-int.h" + +/* + * pqLookupSRVHosts + * + * Resolve _postgresql._tcp.srvhost> SRV records and store the + * result in conn->pghost and conn->pgport as comma-separated strings, + * suitable for consumption by pqConnectOptions2(). + * + * Returns true on success, false on error (error message set in conn). + */ +extern bool pqLookupSRVHosts(PGconn *conn); + +#endif /* FE_CONNECT_SRV_H */ diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c index 4272d386e6..1503fd6e36 100644 --- a/src/interfaces/libpq/fe-connect.c +++ b/src/interfaces/libpq/fe-connect.c @@ -30,6 +30,7 @@ #include "common/string.h" #include "fe-auth.h" #include "fe-auth-oauth.h" +#include "fe-connect-srv.h" #include "libpq-fe.h" #include "libpq-int.h" #include "mb/pg_wchar.h" @@ -231,6 +232,10 @@ static const internalPQconninfoOption PQconninfoOptions[] = { "Database-Name", "", 20, offsetof(struct pg_conn, dbName)}, + {"srvhost", "PGSRVHOST", NULL, NULL, + "Database-SRV-Host", "", 64, + offsetof(struct pg_conn, srvhost)}, + {"host", "PGHOST", NULL, NULL, "Database-Host", "", 40, offsetof(struct pg_conn, pghost)}, @@ -454,6 +459,9 @@ static const pg_fe_sasl_mech *supported_sasl_mechs[] = /* The connection URI must start with either of the following designators: */ static const char uri_designator[] = "postgresql://"; static const char short_uri_designator[] = "postgres://"; +/* SRV URI variants: the host is treated as the SRV domain, not a direct host */ +static const char srv_uri_designator[] = "postgresql+srv://"; +static const char short_srv_uri_designator[] = "postgres+srv://"; static bool connectOptions1(PGconn *conn, const char *conninfo); static bool init_allowed_encryption_methods(PGconn *conn); @@ -1258,6 +1266,29 @@ pqConnectOptions2(PGconn *conn) { int i; + /* + * If srvhost is set, validate mutual exclusivity with host/hostaddr and + * then resolve _postgresql._tcp. SRV records, populating + * conn->pghost and conn->pgport from the sorted results. This must + * happen before the host-array allocation below. + */ + if (conn->srvhost != NULL && conn->srvhost[0] != '\0') + { + if ((conn->pghost != NULL && conn->pghost[0] != '\0') || + (conn->pghostaddr != NULL && conn->pghostaddr[0] != '\0')) + { + conn->status = CONNECTION_BAD; + libpq_append_conn_error(conn, + "cannot use \"srvhost\" together with \"host\" or \"hostaddr\""); + return false; + } + if (!pqLookupSRVHosts(conn)) + { + conn->status = CONNECTION_BAD; + return false; + } + } + /* * Allocate memory for details about each host to which we might possibly * try to connect. For that, count the number of elements in the hostaddr @@ -6359,6 +6390,15 @@ parse_connection_string(const char *connstr, PQExpBuffer errorMessage, static int uri_prefix_length(const char *connstr) { + /* Check SRV URI variants first (longer prefixes before shorter) */ + if (strncmp(connstr, srv_uri_designator, + sizeof(srv_uri_designator) - 1) == 0) + return sizeof(srv_uri_designator) - 1; + + if (strncmp(connstr, short_srv_uri_designator, + sizeof(short_srv_uri_designator) - 1) == 0) + return sizeof(short_srv_uri_designator) - 1; + if (strncmp(connstr, uri_designator, sizeof(uri_designator) - 1) == 0) return sizeof(uri_designator) - 1; @@ -6910,6 +6950,14 @@ conninfo_uri_parse(const char *uri, PQExpBuffer errorMessage, * * postgresql://[user[:password]@][netloc][:port][,...][/dbname][?param1=value1&...] * + * The postgresql+srv:// and postgres+srv:// URI schemes are also recognized: + * + * postgresql+srv://[user[:password]@][srvdomain][/dbname][?param1=value1&...] + * + * In the +srv form, the netloc is interpreted as the SRV domain (stored in + * the "srvhost" connection parameter) rather than as a direct host address. + * Multiple netloc specifications are not allowed in the +srv form. + * * Any of the URI parts might use percent-encoding (%xy). */ static bool @@ -6917,6 +6965,7 @@ conninfo_uri_parse_options(PQconninfoOption *options, const char *uri, PQExpBuffer errorMessage) { int prefix_len; + bool is_srv_uri; char *p; char *buf = NULL; char *start; @@ -6944,8 +6993,10 @@ conninfo_uri_parse_options(PQconninfoOption *options, const char *uri, } start = buf; - /* Skip the URI prefix */ + /* Skip the URI prefix and detect if this is a +srv URI */ prefix_len = uri_prefix_length(uri); + is_srv_uri = (prefix_len == sizeof(srv_uri_designator) - 1 || + prefix_len == sizeof(short_srv_uri_designator) - 1); if (prefix_len == 0) { /* Should never happen */ @@ -7096,10 +7147,32 @@ conninfo_uri_parse_options(PQconninfoOption *options, const char *uri, /* Save final values for host and port. */ if (PQExpBufferDataBroken(hostbuf) || PQExpBufferDataBroken(portbuf)) goto cleanup; - if (hostbuf.data[0] && - !conninfo_storeval(options, "host", hostbuf.data, - errorMessage, false, true)) - goto cleanup; + if (hostbuf.data[0]) + { + if (is_srv_uri) + { + /* + * For postgresql+srv:// URIs the netloc is the SRV domain, not a + * direct host address. Store it in "srvhost" and reject multiple + * hosts (commas) since SRV already expands to multiple targets. + */ + if (strchr(hostbuf.data, ',') != NULL) + { + libpq_append_error(errorMessage, + "multiple hosts are not allowed in a postgresql+srv:// URI"); + goto cleanup; + } + if (!conninfo_storeval(options, "srvhost", hostbuf.data, + errorMessage, false, true)) + goto cleanup; + } + else + { + if (!conninfo_storeval(options, "host", hostbuf.data, + errorMessage, false, true)) + goto cleanup; + } + } if (portbuf.data[0] && !conninfo_storeval(options, "port", portbuf.data, errorMessage, false, true)) diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h index 23de98290c..cbfeee6288 100644 --- a/src/interfaces/libpq/libpq-int.h +++ b/src/interfaces/libpq/libpq-int.h @@ -371,6 +371,11 @@ typedef struct pg_conn_host struct pg_conn { /* Saved values of connection options */ + char *srvhost; /* DNS SRV cluster domain for service + * discovery; when set, _postgresql._tcp. + * is resolved at connect time and the result + * replaces pghost/pgport. Mutually exclusive + * with pghost and pghostaddr. */ char *pghost; /* the machine on which the server is running, * or a path to a UNIX-domain socket, or a * comma-separated list of machines and/or diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build index b0ae72167a..b5bf3f1faa 100644 --- a/src/interfaces/libpq/meson.build +++ b/src/interfaces/libpq/meson.build @@ -6,6 +6,7 @@ libpq_sources = files( 'fe-auth.c', 'fe-cancel.c', 'fe-connect.c', + 'fe-connect-srv.c', 'fe-exec.c', 'fe-lobj.c', 'fe-misc.c', diff --git a/src/interfaces/libpq/t/007_srv.pl b/src/interfaces/libpq/t/007_srv.pl new file mode 100644 index 0000000000..79e50c9ed6 --- /dev/null +++ b/src/interfaces/libpq/t/007_srv.pl @@ -0,0 +1,159 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group +use strict; +use warnings FATAL => 'all'; + +use PostgreSQL::Test::Utils; +use PostgreSQL::Test::Cluster; +use Test::More; + +# This test exercises DNS SRV record support in libpq: +# +# 1. URI parsing: postgresql+srv:// and postgres+srv:// schemes store the +# netloc as "srvhost" rather than "host". +# 2. Error handling: multiple hosts in a +srv URI, mixing srvhost with host. +# 3. Live SRV lookup against a running PostgreSQL cluster. +# +# Live lookup is gated by PG_TEST_EXTRA=srv because it requires that valid +# SRV records exist in DNS. The test relies on the SRV_HOST environment +# variable (defaulting to the value recommended in the PostgreSQL docs). + + +# -------------------------------------------------------------------------- +# Part 1: URI parsing (no network required, uses libpq_uri_regress) +# -------------------------------------------------------------------------- + +my @uri_tests = ( + + # postgresql+srv:// — netloc becomes srvhost + [ + q{postgresql+srv://cluster.example.com/mydb}, + q{dbname='mydb' srvhost='cluster.example.com'}, + q{}, + ], + + # postgres+srv:// short form + [ + q{postgres+srv://cluster.example.com/mydb}, + q{dbname='mydb' srvhost='cluster.example.com'}, + q{}, + ], + + # with user, extra params + [ + q{postgresql+srv://alice@cluster.example.com/mydb?target_session_attrs=read-write}, + q{user='alice' dbname='mydb' srvhost='cluster.example.com' target_session_attrs='read-write'}, + q{}, + ], + + # multiple hosts must be rejected for +srv URIs + [ + q{postgresql+srv://h1.example.com,h2.example.com/mydb}, + q{}, + q{multiple hosts are not allowed in a postgresql+srv:// URI}, + ], +); + +foreach my $t (@uri_tests) +{ + my ($uri, $want_out, $want_err) = @$t; + + my ($stdout, $stderr); + IPC::Run::run [ 'libpq_uri_regress', $uri ], + '>' => \$stdout, + '2>' => \$stderr; + chomp $stdout; + chomp $stderr; + + # Strip the trailing connection-type annotation "(local)"/"(inet)" so + # that the test is not sensitive to socket-vs-TCP defaults. + $stdout =~ s/\s+\(\w+\)$//; + + is($stdout, $want_out, "URI stdout: $uri"); + like($stderr, qr/\Q$want_err\E/, "URI stderr: $uri") + if $want_err ne ''; + is($stderr, '', "URI no stderr: $uri") + if $want_err eq ''; +} + +# -------------------------------------------------------------------------- +# Part 2: Live SRV lookup (optional, gated by PG_TEST_EXTRA) +# -------------------------------------------------------------------------- + +my $do_live = $ENV{PG_TEST_EXTRA} && $ENV{PG_TEST_EXTRA} =~ /\bsrv\b/; + +if (!$do_live) +{ + note 'Skipping live SRV test (not enabled in PG_TEST_EXTRA)'; + done_testing(); + exit 0; +} + +# The live test starts a PostgreSQL cluster on a local port, publishes the +# connection details via a mock hosts-file trick (as done in 004_load_balance_dns.pl), +# and verifies that a postgresql+srv:// URI resolves and connects. +# +# When running in CI, the SRV records for $SRV_HOST must resolve to 127.0.0.1 +# on port $SRV_PORT. + +my $srv_host = + $ENV{SRV_HOST} // 'pg-srvtest'; # DNS name whose SRV records point here +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init; +$node->start; + +my $port = $node->port; + +note "Testing SRV connection via srvhost=$srv_host (expect port $port)"; + +# 1. keyword=value connection string +my ($ret, $out, $err); +$ret = $node->psql( + 'postgres', + 'SELECT 1', + stdout => \$out, + stderr => \$err, + extra_params => [ '-d', "srvhost=$srv_host" ], + on_error_stop => 0); +is($ret, 0, "srvhost= keyword connects via SRV") + or diag("stderr: $err"); + +# 2. URI form +$ret = $node->psql( + 'postgres', + 'SELECT 1', + stdout => \$out, + stderr => \$err, + extra_params => [ '-d', "postgresql+srv://$srv_host/postgres" ], + on_error_stop => 0); +is($ret, 0, "postgresql+srv:// URI connects via SRV") + or diag("stderr: $err"); + +# 3. target_session_attrs=any works with SRV +$ret = $node->psql( + 'postgres', + 'SELECT 1', + stdout => \$out, + stderr => \$err, + extra_params => [ + '-d', + "postgresql+srv://$srv_host/postgres?target_session_attrs=any", + ], + on_error_stop => 0); +is($ret, 0, "postgresql+srv:// with target_session_attrs=any") + or diag("stderr: $err"); + +# 4. Mixing srvhost and host is rejected +$ret = $node->psql( + 'postgres', + 'SELECT 1', + stdout => \$out, + stderr => \$err, + extra_params => [ '-d', "srvhost=$srv_host host=localhost" ], + on_error_stop => 0); +isnt($ret, 0, "srvhost + host= is rejected"); +like($err, qr/cannot use "srvhost" together with "host"/, + "correct error for srvhost + host conflict"); + +$node->stop; + +done_testing(); -- 2.50.1 (Apple Git-155)