public inbox for [email protected]
help / color / mirror / Atom feedFrom: Andrey Borodin <[email protected]>
To: Andres Freund <[email protected]>
Cc: Tom Lane <[email protected]>
Cc: Feike Steenbergen <[email protected]>
Cc: PostgreSQL mailing lists <[email protected]>
Subject: Re: Feature: Use DNS SRV records for connecting
Date: Wed, 22 Apr 2026 14:48:50 +0500
Message-ID: <[email protected]> (raw)
In-Reply-To: <[email protected]>
References: <CAK_s-G2_3S09_EA+nRxxefMW+0-UwKE=Uj6bCdBpPncPVRpM_g@mail.gmail.com>
<[email protected]>
<[email protected]>
> On 14 Aug 2019, at 23:01, Andres Freund <[email protected]> wrote:
>
> On 2019-08-13 10:43:07 -0400, Tom Lane wrote:
>> How would we get at that data without writing our own DNS client?
>> (AFAIK, our existing DNS interactions are all handled by getnameinfo()
>> or other library-supplied functions.)
>
>> Maybe that'd be worth doing, but it sounds like a lot of work and a
>> lot of new code to maintain, relative to the value of the feature.
>
> It might have enough independent advantages to make it worthwhile
> though.
Here is a patch prototyping SRV-based discovery for libpq, reviving
the idea from the 2019 thread.
Tom asked how we'd get SRV data without writing our own DNS client.
On POSIX, res_query(3) from libresolv does the lookup and
dn_expand(3) decompresses names in the response - the same library
that Kerberos and LDAP support already depend on. On Windows,
DnsQuery() from windns.h returns a typed linked list with no wire-format
parsing needed. The resolver is about 200 lines and uses no DNS logic
of our own.
Regarding non-blocking resolution raised by Andres: res_query() is
blocking, as is the existing getaddrinfo() call for regular hosts.
This patch does not make things worse, but does not improve them
either. Async DNS resolution is a separate, larger problem.
A new connection parameter "srvhost" (env: "PGSRVHOST") causes libpq
to query "_postgresql._tcp.<srvhost>" before connecting. I chose
"srvhost" over the original "dnssrv" suggestion to align with the
existing "host" parameter naming. Two URI schemes are supported as
shorthand:
psql "postgresql+srv://cluster.example.com/mydb?target_session_attrs=read-write"
psql "postgres+srv://cluster.example.com/mydb?target_session_attrs=read-write"
Given the DNS records:
_postgresql._tcp.cluster.example.com. SRV 10 50 5432 primary.example.com.
_postgresql._tcp.cluster.example.com. SRV 20 50 5432 standby1.example.com.
_postgresql._tcp.cluster.example.com. SRV 20 50 5432 standby2.example.com.
The resolved host list is sorted per RFC 2782 and injected into the
existing multi-host machinery before connhost[] is built, so
target_session_attrs, load_balance_hosts, and failover work on the
expanded list without any changes to PQconnectPoll.
"srvhost" is mutually exclusive with "host" and "hostaddr". Multiple
hosts in a "+srv" URI are rejected - expansion is DNS's responsibility.
I'm proposing this across the PostgreSQL driver ecosystem (pgx, pgjdbc,
npgsql, and now libpq). I would be happy to hear a feedback.
Best regards, Andrey Borodin.
Attachments:
[application/octet-stream] 0001-libpq-Add-DNS-SRV-record-support-for-service-discove.patch (33.8K, 2-0001-libpq-Add-DNS-SRV-record-support-for-service-discove.patch)
download | inline diff:
From 30b8f2eac8dc8f8b31556e242db90329e8f392b2 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <[email protected]>
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.<srvhost> 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=<domain> DNS domain for SRV lookup (env: PGSRVHOST)
postgresql+srv://<domain>/<db> URI shorthand for srvhost
postgres+srv://<domain>/<db> 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:
<variablelist>
+ <varlistentry id="libpq-connect-srvhost" xreflabel="srvhost">
+ <term><literal>srvhost</literal></term>
+ <listitem>
+ <para>
+ DNS domain name used for SRV-based service discovery (RFC 2782).
+ When set, libpq queries DNS for
+ <literal>_postgresql._tcp.<replaceable>srvhost</replaceable></literal>
+ 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.
+ </para>
+ <para>
+ This parameter is mutually exclusive with <literal>host</literal> and
+ <literal>hostaddr</literal>. It works together with all other
+ connection parameters, including <literal>target_session_attrs</literal>
+ and <literal>load_balance_hosts</literal>, which are applied to the
+ expanded host list after DNS resolution.
+ </para>
+ <para>
+ The environment variable equivalent is <envar>PGSRVHOST</envar>.
+ </para>
+ <para>
+ The <literal>postgresql+srv://</literal> and
+ <literal>postgres+srv://</literal> URI schemes are a shorthand for
+ setting <literal>srvhost</literal> from the URI host part:
+
+<synopsis>
+postgresql+srv://<replaceable>cluster.example.com</replaceable>/<replaceable>mydb</replaceable>
+</synopsis>
+
+ is equivalent to:
+
+<synopsis>
+postgresql:///<replaceable>mydb</replaceable>?srvhost=<replaceable>cluster.example.com</replaceable>
+</synopsis>
+
+ Multiple hosts are not permitted in a <literal>+srv</literal> URI
+ because the host expansion is performed by DNS.
+ </para>
+ </listitem>
+ </varlistentry>
+
<varlistentry id="libpq-connect-host" xreflabel="host">
<term><literal>host</literal></term>
<listitem>
@@ -9060,6 +9102,16 @@ myEventProc(PGEventId evtId, void *evtInfo, void *passThrough)
information into simple client applications, for example.
<itemizedlist>
+ <listitem>
+ <para>
+ <indexterm>
+ <primary><envar>PGSRVHOST</envar></primary>
+ </indexterm>
+ <envar>PGSRVHOST</envar> behaves the same as the <xref
+ linkend="libpq-connect-srvhost"/> connection parameter.
+ </para>
+ </listitem>
+
<listitem>
<para>
<indexterm>
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 <ifaddrs.h> 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.<srvhost> 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 <windns.h>
+
+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 <arpa/nameser.h>
+#include <resolv.h>
+#include <netdb.h>
+
+/* 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.<conn->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.<srvhost> 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.<srvhost>
+ * 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://[email protected]/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)
view thread (8+ messages) latest in thread
reply
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Reply to all the recipients using the --to and --cc options:
reply via email
To: [email protected]
Cc: [email protected], [email protected], [email protected], [email protected]
Subject: Re: Feature: Use DNS SRV records for connecting
In-Reply-To: <[email protected]>
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox