From c8aea86957ad12b7e48a32370eb2c565c20a2205 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <dgustafsson@postgresql.org>
Date: Mon, 4 Nov 2024 13:52:23 +0100
Subject: [PATCH v2] Serverside SNI support for libpq

Experimental support for serverside SNI support in libpq, a new config
file $datadir/pg_hosts.conf is used for configuring which certicate and
key should be used for which hostname. A new GUC, ssl_snimode, is added
which controls how the hostname TLS extension is handled. The possible
values are off, default and strict:

  - off: pg_hosts.conf is not parsed and the hostname TLS extension is
    not inspected at all. The normal SSL GUCs for certificates and keys
	are used.
  - default: pg_hosts.conf is loaded as well as the normal GUCs. If no
    match for the TLS extension hostname is found in pg_hosts the cert
	and key from the postgresql.conf GUCs is used as the default (used
	as a wildcard host).
  - strict: only pg_hosts.conf is loaded and the TLS extension hostname
    MUST be passed and MUST have a match in the configuration, else the
	connection is refused.

CRL file(s) are applied from postgresql.conf to all configured hostnames.

Reviewed-by: Cary Huang <cary.huang@highgo.ca>
Reviewed-by: Jacob Champion <jacob.champion@enterprisedb.com>
Discussion: https://postgr.es/m/1C81CD0D-407E-44F9-833A-DD0331C202E5@yesql.se
---
 doc/src/sgml/config.sgml                      |  62 ++++
 doc/src/sgml/runtime.sgml                     |  50 +++
 src/backend/Makefile                          |   1 +
 src/backend/libpq/be-secure-common.c          | 201 ++++++++++-
 src/backend/libpq/be-secure-openssl.c         | 315 ++++++++++++++++--
 src/backend/libpq/be-secure.c                 |   8 +-
 src/backend/libpq/meson.build                 |   1 +
 src/backend/libpq/pg_hosts.conf.sample        |   5 +
 src/backend/utils/misc/guc.c                  |  26 ++
 src/backend/utils/misc/guc_tables.c           |  31 ++
 src/backend/utils/misc/postgresql.conf.sample |   1 +
 src/bin/initdb/initdb.c                       |  16 +-
 src/include/libpq/hba.h                       |  19 ++
 src/include/libpq/libpq-be.h                  |   3 +-
 src/include/libpq/libpq.h                     |  11 +-
 src/include/utils/guc.h                       |   1 +
 src/test/ssl/meson.build                      |   1 +
 src/test/ssl/t/004_sni.pl                     |  92 +++++
 src/tools/pgindent/typedefs.list              |   2 +
 19 files changed, 798 insertions(+), 48 deletions(-)
 create mode 100644 src/backend/libpq/pg_hosts.conf.sample
 create mode 100644 src/test/ssl/t/004_sni.pl

diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index e0c8325a39..1cecaccee7 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1652,6 +1652,68 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-ssl-snimode" xreflabel="ssl_snimode">
+      <term><varname>ssl_snimode</varname> (<type>enum</type>)
+      <indexterm>
+       <primary><varname>ssl_snimode</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        This parameter determines if the server will inspect the <acronym>SNI</acronym> TLS extension
+        when establishing the connection, and how it should be interpreted.
+        Valid values are currently: <literal>off</literal>, <literal>default</literal> and <literal>strict</literal>.
+       </para>
+       <para>
+        <variablelist>
+         <varlistentry id="guc-ssl-snimode-off">
+          <term><literal>off</literal></term>
+          <listitem>
+           <para>
+            SNI is not enabled and no configuration from
+            <filename>pg_hosts.conf</filename> is loaded.  Configuration of SSL
+            for all connections is done with <xref linkend="guc-ssl-cert-file"/>,
+            <xref linkend="guc-ssl-key-file"/> and <xref linkend="guc-ssl-ca-file"/>.
+           </para>
+          </listitem>
+         </varlistentry>
+
+         <varlistentry id="guc-ssl-snimode-default">
+          <term><literal>default</literal></term>
+          <listitem>
+           <para>
+            SNI is enabled and hostname configuration is loaded from
+            <filename>pg_hosts.conf</filename>. <xref linkend="guc-ssl-cert-file"/>,
+            <xref linkend="guc-ssl-key-file"/> and <xref linkend="guc-ssl-ca-file"/>
+            are loaded as the default configuration.  Any connection specifying
+            <xref linkend="libpq-connect-sslsni"/> to <literal>1</literal>
+            a hostname which is missing in <filename>pg_hosts.conf</filename>
+            will be attempted using the default configuration. If the hostname
+            matches an entry from <filename>pg_hosts.conf</filename>, then the
+            configuration from that entry will be used for setting up the
+            connection.
+           </para>
+          </listitem>
+         </varlistentry>
+
+         <varlistentry id="guc-ssl-snimode-strict">
+          <term><literal>strict</literal></term>
+          <listitem>
+           <para>
+            SNI is enabled and all connections are required to set <xref
+            linkend="libpq-connect-sslsni"/> to <literal>1</literal> and
+            specify a hostname matching an entry in
+            <filename>pg_hosts.conf</filename>. Any connection without <xref
+            linkend="libpq-connect-sslsni"/> or with a hostname missing from
+            <filename>pg_hosts.conf</filename> will be rejected.
+           </para>
+          </listitem>
+         </varlistentry>
+        </variablelist>
+       </para>
+      </listitem>
+     </varlistentry>
     </variablelist>
     </sect2>
    </sect1>
diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml
index 94135e9d5e..0ac79ae28d 100644
--- a/doc/src/sgml/runtime.sgml
+++ b/doc/src/sgml/runtime.sgml
@@ -2444,6 +2444,12 @@ pg_dumpall -p 5432 | psql -d postgres -p 5433
       <entry>client certificate must not be on this list</entry>
      </row>
 
+     <row>
+      <entry><filename>$PGDATA/pg_hosts.conf</filename></entry>
+      <entry>SNI configuration</entry>
+      <entry>defines which certificates to use for which server hostname</entry>
+     </row>
+
     </tbody>
    </tgroup>
   </table>
@@ -2571,6 +2577,50 @@ openssl x509 -req -in server.csr -text -days 365 \
    </para>
   </sect2>
 
+  <sect2 id="ssl-sni">
+   <title>SNI Configuration</title>
+
+   <para>
+    <productname>PostgreSQL</productname> can be configured for
+    <acronym>SNI</acronym> using the <filename>pg_hosts.conf</filename>
+    configuration file. <productname>PostgreSQL</productname> inspects the TLS
+    hostname extension in the SSL connection handshake, and selects the right
+    SSL certificate, key and CA certificate to use for the connection.
+   </para>
+
+   <para>
+    SNI configuration is defined in the hosts configuration file, which is
+    named <filename>pg_hosts.conf</filename> and is stored in the clusters
+    data directory.  The hosts configuration file contains lines of the general
+    forms:
+<synopsis>
+<replaceable>hostname</replaceable> <replaceable>SSL_certificate</replaceable> <replaceable>SSL_key</replaceable> <replaceable>SSL_CA_certificate</replaceable> <replaceable>SSL_passphrase_cmd</replaceable> <replaceable>SSL_passphrase_cmd_reload</replaceable>
+<replaceable>include</replaceable> <replaceable>file</replaceable>
+<replaceable>include_if_exists</replaceable> <replaceable>file</replaceable>
+<replaceable>include_dir</replaceable> <replaceable>directory</replaceable>
+</synopsis>
+    Comments, whitespace and line continuations are handled in the same way as
+    in <filename>pg_hba.conf</filename>.  <replaceable>hostname</replaceable>
+    is matched against the hostname TLS extension in the SSL handshake.
+    <replaceable>SSL_certificate</replaceable>,
+    <replaceable>SSL_key</replaceable>,
+    <replaceable>SSL_CA_certificate</replaceable>,
+    <replaceable>SSL_passphrase_cmd</replaceable>, and
+    <replaceable>SSL_passphrase_cmd_reload</replaceable>
+    are treated like
+    <xref linkend="guc-ssl-cert-file"/>,
+    <xref linkend="guc-ssl-key-file"/>,
+    <xref linkend="guc-ssl-ca-file"/>,
+    <xref linkend="guc-ssl-passphrase-command"/>, and
+    <xref linkend="guc-ssl-passphrase-command-supports-reload"/> respectively.
+    All fields except <replaceable>SSL_passphrase_cmd</replaceable> and
+    <replaceable>SSL_passphrase_cmd_reload</replaceable> are required. If
+    <replaceable>SSL_passphrase_cmd</replaceable> is defined but not
+    <replaceable>SSL_passphrase_cmd_reload</replaceable> then the default
+    value for <replaceable>SSL_passphrase_cmd_reload</replaceable> is
+    <literal>off</literal>.
+   </para>
+  </sect2>
  </sect1>
 
  <sect1 id="gssapi-enc">
diff --git a/src/backend/Makefile b/src/backend/Makefile
index 84302cc6da..bc8accbde0 100644
--- a/src/backend/Makefile
+++ b/src/backend/Makefile
@@ -186,6 +186,7 @@ endif
 	$(MAKE) -C utils install-data
 	$(INSTALL_DATA) $(srcdir)/libpq/pg_hba.conf.sample '$(DESTDIR)$(datadir)/pg_hba.conf.sample'
 	$(INSTALL_DATA) $(srcdir)/libpq/pg_ident.conf.sample '$(DESTDIR)$(datadir)/pg_ident.conf.sample'
+	$(INSTALL_DATA) $(srcdir)/libpq/pg_hosts.conf.sample '$(DESTDIR)$(datadir)/pg_hosts.conf.sample'
 	$(INSTALL_DATA) $(srcdir)/utils/misc/postgresql.conf.sample '$(DESTDIR)$(datadir)/postgresql.conf.sample'
 
 ifeq ($(with_llvm), yes)
diff --git a/src/backend/libpq/be-secure-common.c b/src/backend/libpq/be-secure-common.c
index 0cb201acb1..7c900edd45 100644
--- a/src/backend/libpq/be-secure-common.c
+++ b/src/backend/libpq/be-secure-common.c
@@ -24,8 +24,13 @@
 
 #include "common/percentrepl.h"
 #include "common/string.h"
+#include "libpq/hba.h"
 #include "libpq/libpq.h"
 #include "storage/fd.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+static HostsLine *parse_hosts_line(TokenizedAuthLine *tok_line, int elevel);
 
 /*
  * Run ssl_passphrase_command
@@ -37,19 +42,20 @@
  * value is the length of the actual result.
  */
 int
-run_ssl_passphrase_command(const char *prompt, bool is_server_start, char *buf, int size)
+run_ssl_passphrase_command(const char *prompt, bool is_server_start, char *buf, int size, void *userdata)
 {
 	int			loglevel = is_server_start ? ERROR : LOG;
 	char	   *command;
 	FILE	   *fh;
 	int			pclose_rc;
 	size_t		len = 0;
+	char	   *cmd = (char *) userdata;
 
 	Assert(prompt);
 	Assert(size > 0);
 	buf[0] = '\0';
 
-	command = replace_percent_placeholders(ssl_passphrase_command, "ssl_passphrase_command", "p", prompt);
+	command = replace_percent_placeholders(cmd, "ssl_passphrase_command", "p", prompt);
 
 	fh = OpenPipeStream(command, "r");
 	if (fh == NULL)
@@ -175,3 +181,194 @@ check_ssl_key_file_permissions(const char *ssl_key_file, bool isServerStart)
 
 	return true;
 }
+
+/*
+ * parse_hosts_line
+ *
+ * Parses a loaded line from the pg_hosts.conf configuration and pulls out the
+ * hostname, certificate, key and CA parts in order to build an SNI config in
+ * the TLS backend. Validation of the parsed values is left for the TLS backend
+ * to implement.
+ */
+static HostsLine *
+parse_hosts_line(TokenizedAuthLine *tok_line, int elevel)
+{
+	HostsLine  *parsedline;
+	List	   *tokens;
+	ListCell   *field;
+	AuthToken  *token;
+
+	parsedline = palloc0(sizeof(HostsLine));
+	parsedline->sourcefile = pstrdup(tok_line->file_name);
+	parsedline->linenumber = tok_line->line_num;
+	parsedline->rawline = pstrdup(tok_line->raw_line);
+
+	/* Initialize optional fields */
+	parsedline->ssl_passphrase_cmd = NULL;
+	parsedline->ssl_passphrase_reload = false;
+
+	/* Hostname */
+	field = list_head(tok_line->fields);
+	tokens = lfirst(field);
+	token = linitial(tokens);
+	parsedline->hostname = pstrdup(token->string);
+
+	/* SSL Certificate (Required) */
+	field = lnext(tok_line->fields, field);
+	if (!field)
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("missing entry at end of line"),
+				errcontext("line %d of configuration file \"%s\"",
+						   tok_line->line_num, tok_line->file_name));
+		return NULL;
+	}
+	tokens = lfirst(field);
+	token = linitial(tokens);
+	parsedline->ssl_cert = pstrdup(token->string);
+
+	/* SSL key (Required) */
+	field = lnext(tok_line->fields, field);
+	if (!field)
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("missing entry at end of line"),
+				errcontext("line %d of configuration file \"%s\"",
+						   tok_line->line_num, tok_line->file_name));
+		return NULL;
+	}
+	tokens = lfirst(field);
+	token = linitial(tokens);
+	parsedline->ssl_key = pstrdup(token->string);
+
+	/* SSL CA (Required) */
+	field = lnext(tok_line->fields, field);
+	if (!field)
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("missing entry at end of line"),
+				errcontext("line %d of configuration file \"%s\"",
+						   tok_line->line_num, tok_line->file_name));
+		return NULL;
+	}
+	tokens = lfirst(field);
+	token = linitial(tokens);
+	parsedline->ssl_ca = pstrdup(token->string);
+
+	/* SSL Passphrase Command (optional) */
+	field = lnext(tok_line->fields, field);
+	if (field)
+	{
+		tokens = lfirst(field);
+		token = linitial(tokens);
+		parsedline->ssl_passphrase_cmd = pstrdup(token->string);
+
+		/*
+		 * SSL Passphrase Command support reload (optional). This field is
+		 * only supported if there was a passphrase command parsed first, so
+		 * nest it under the previous token.
+		 */
+		field = lnext(tok_line->fields, field);
+		if (field)
+		{
+			tokens = lfirst(field);
+			token = linitial(tokens);
+
+			if (token->string[0] == '1'
+				|| pg_strcasecmp(token->string, "true") == 0
+				|| pg_strcasecmp(token->string, "on") == 0)
+				parsedline->ssl_passphrase_reload = true;
+			else if (token->string[0] == '0'
+					 || pg_strcasecmp(token->string, "false") == 0
+					 || pg_strcasecmp(token->string, "off") == 0)
+				parsedline->ssl_passphrase_reload = false;
+			else
+				ereport(elevel,
+						errcode(ERRCODE_CONFIG_FILE_ERROR),
+						errmsg("incorrect syntax for boolean value SSL_passphrase_cmd_reload"),
+						errcontext("line %d of configuration file \"%s\"",
+								   tok_line->line_num, tok_line->file_name));
+		}
+	}
+
+	return parsedline;
+}
+
+/*
+ * load_hosts
+ *
+ * Reads pg_hosts.conf and passes back a List of parsed lines, or NIL in case
+ * of errors.
+ */
+List *
+load_hosts(void)
+{
+	FILE	   *file;
+	ListCell   *line;
+	List	   *hosts_lines = NIL;
+	List	   *parsed_lines = NIL;
+	HostsLine  *newline;
+	bool		ok = true;
+	MemoryContext oldcxt;
+	MemoryContext hostcxt;
+
+	file = open_auth_file(HostsFileName, LOG, 0, NULL);
+	if (file == NULL)
+	{
+		/* An error has already been logged so no need to add one here. */
+		return NIL;
+	}
+
+	tokenize_auth_file(HostsFileName, file, &hosts_lines, LOG, 0);
+
+	hostcxt = AllocSetContextCreate(PostmasterContext,
+									"hosts file parser context",
+									ALLOCSET_SMALL_SIZES);
+	oldcxt = MemoryContextSwitchTo(hostcxt);
+
+	foreach(line, hosts_lines)
+	{
+		TokenizedAuthLine *tok_line = (TokenizedAuthLine *) lfirst(line);
+
+		if (tok_line->err_msg != NULL)
+		{
+			ok = false;
+			continue;
+		}
+
+		if ((newline = parse_hosts_line(tok_line, LOG)) == NULL)
+		{
+			ok = false;
+			continue;
+		}
+
+		parsed_lines = lappend(parsed_lines, newline);
+	}
+
+	free_auth_file(file, 0);
+	MemoryContextSwitchTo(oldcxt);
+
+	/*
+	 * If we didn't find any SNI configuration then that's not an error since
+	 * the pg_hosts file is additive to the default SSL configuration.
+	 */
+	if (ok && parsed_lines == NIL)
+	{
+		ereport(DEBUG1,
+				errmsg("no SNI configuration added from configuration file  \"%s\"",
+					   HostsFileName));
+		MemoryContextDelete(hostcxt);
+		return NIL;
+	}
+
+	if (!ok)
+	{
+		MemoryContextDelete(hostcxt);
+		return NIL;
+	}
+
+	return parsed_lines;
+}
diff --git a/src/backend/libpq/be-secure-openssl.c b/src/backend/libpq/be-secure-openssl.c
index 91a86d62a3..5acfdeb625 100644
--- a/src/backend/libpq/be-secure-openssl.c
+++ b/src/backend/libpq/be-secure-openssl.c
@@ -51,9 +51,18 @@
 #endif
 #include <openssl/x509v3.h>
 
+typedef struct HostContext
+{
+	const char *hostname;
+	const char *ssl_passphrase;
+	SSL_CTX    *context;
+	bool		default_host;
+	bool		ssl_loaded_verify_locations;
+	bool		ssl_passphrase_support_reload;
+} HostContext;
 
 /* default init hook can be overridden by a shared library */
-static void default_openssl_tls_init(SSL_CTX *context, bool isServerStart);
+static void default_openssl_tls_init(SSL_CTX *context, bool isServerStart, HostsLine *hosts);
 openssl_tls_init_hook_typ openssl_tls_init_hook = default_openssl_tls_init;
 
 static int	port_bio_read(BIO *h, char *buf, int size);
@@ -73,6 +82,7 @@ static int	alpn_cb(SSL *ssl,
 					const unsigned char *in,
 					unsigned int inlen,
 					void *userdata);
+static int	sni_servername_cb(SSL *ssl, int *al, void *arg);
 static bool initialize_dh(SSL_CTX *context, bool isServerStart);
 static bool initialize_ecdh(SSL_CTX *context, bool isServerStart);
 static const char *SSLerrmessageExt(unsigned long ecode, const char *replacement);
@@ -80,12 +90,16 @@ static const char *SSLerrmessage(unsigned long ecode);
 
 static char *X509_NAME_to_cstring(X509_NAME *name);
 
+static List *contexts = NIL;
 static SSL_CTX *SSL_context = NULL;
+static HostContext *Host_context = NULL;
 static bool dummy_ssl_passwd_cb_called = false;
 static bool ssl_is_server_start;
 
 static int	ssl_protocol_version_to_openssl(int v);
 static const char *ssl_protocol_version_to_string(int v);
+static SSL_CTX *ssl_init_context(bool isServerStart, HostsLine *host);
+static void free_contexts(void);
 
 /* for passing data back from verify_cb() */
 static const char *cert_errdetail;
@@ -96,11 +110,155 @@ static const char *cert_errdetail;
 
 int
 be_tls_init(bool isServerStart)
+{
+	SSL_CTX    *ctx;
+	List	   *sni_hosts = NIL;
+
+	/*
+	 * If there are contexts loaded when we init they should be released. This
+	 * should only be possible when reloading, but to keep any subtle bugs at
+	 * arms length we check unconditionally with an assert for non-production
+	 * builds.
+	 */
+	if (contexts != NIL)
+	{
+		Assert(isServerStart == false);
+		free_contexts();
+	}
+
+	/*
+	 * When ssl_snimode is off or default we load the certificate and key
+	 * specified in postgresql.conf and set that as the default host.
+	 */
+	if (ssl_snimode == SSL_SNIMODE_OFF || ssl_snimode == SSL_SNIMODE_DEFAULT)
+	{
+		HostContext *host_context;
+		HostsLine	line;
+
+		line.ssl_cert = ssl_cert_file;
+		line.ssl_key = ssl_key_file;
+		line.ssl_ca = ssl_ca_file;
+		line.ssl_passphrase_cmd = ssl_passphrase_command;
+		line.ssl_passphrase_reload = ssl_passphrase_command_supports_reload;
+
+		ctx = ssl_init_context(isServerStart, &line);
+		if (ctx == NULL)
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					(errcode(ERRCODE_CONFIG_FILE_ERROR),
+					 errmsg("could not load default certificate")));
+			return -1;
+		}
+
+		host_context = palloc0(sizeof(HostContext));
+
+		host_context->hostname = pstrdup("*");
+		host_context->context = ctx;
+		host_context->default_host = true;
+
+		/*
+		 * Set flag to remember whether CA store has been loaded into
+		 * SSL_context.
+		 */
+		if (ssl_ca_file[0])
+			host_context->ssl_loaded_verify_locations = true;
+
+		/*
+		 * The contexts list is not used in ssl_snimode off but we add the
+		 * entry there anyways for consistency with the other modes.
+		 */
+		contexts = lappend(contexts, host_context);
+
+		/*
+		 * Install the default certificate which for ssl_snimode default can
+		 * be overridden in the callback if a hostname match is found.
+		 */
+		SSL_context = ctx;
+		Host_context = host_context;
+	}
+
+	/*
+	 * In default or strict ssl_snimode we load all certificates/keys which
+	 * are configured in pg_hosts.conf. In strict mode it is considered a
+	 * fatal error in case there are no configured entries.
+	 */
+	if (ssl_snimode == SSL_SNIMODE_STRICT || ssl_snimode == SSL_SNIMODE_DEFAULT)
+	{
+		ListCell   *line;
+
+		/*
+		 * Load pg_hosts.conf and parse each row, returning the set of hosts
+		 * as a list.
+		 */
+		sni_hosts = load_hosts();
+
+		/*
+		 * In strict ssl_snimode there needs to be a working pg_hosts file,
+		 */
+		if (sni_hosts == NIL && ssl_snimode == SSL_SNIMODE_STRICT)
+		{
+			ereport(isServerStart ? FATAL : LOG,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+					errmsg("could not load pg_hosts.conf file"));
+			return -1;
+		}
+
+		foreach(line, sni_hosts)
+		{
+			HostContext *host_context;
+			HostsLine  *host = lfirst(line);
+
+			SSL_context = ssl_init_context(isServerStart, host);
+			if (SSL_context == NULL)
+			{
+				ereport(isServerStart ? FATAL : LOG,
+						errcode(ERRCODE_CONFIG_FILE_ERROR),
+						errmsg("unable to load certificate from pg_hosts.conf file"));
+				return -1;
+			}
+
+			host_context = palloc(sizeof(HostContext));
+			host_context->hostname = pstrdup(host->hostname);
+			host_context->context = SSL_context;
+			host_context->default_host = false;
+			if (host->ssl_passphrase_cmd != NULL)
+				host_context->ssl_passphrase = pstrdup(host->ssl_passphrase_cmd);
+			host_context->ssl_passphrase_support_reload = host->ssl_passphrase_reload;
+
+			/*
+			 * Set flag to remember whether CA store has been loaded into
+			 * SSL_context.
+			 */
+			if (host->ssl_ca)
+				host_context->ssl_loaded_verify_locations = true;
+
+			contexts = lappend(contexts, host_context);
+		}
+	}
+
+	/* Make sure we have at least one certificate loaded */
+	if (list_length(contexts) < 1)
+	{
+		ereport(isServerStart ? FATAL : LOG,
+				(errcode(ERRCODE_CONFIG_FILE_ERROR),
+				 errmsg("no SSL contexts loaded")));
+		return -1;
+	}
+
+	return 0;
+}
+
+static SSL_CTX *
+ssl_init_context(bool isServerStart, HostsLine *host_line)
 {
 	SSL_CTX    *context;
 	int			ssl_ver_min = -1;
 	int			ssl_ver_max = -1;
 
+	const char *ctx_ssl_cert_file = host_line->ssl_cert;
+	const char *ctx_ssl_key_file = host_line->ssl_key;
+	const char *ctx_ssl_ca_file = host_line->ssl_ca;
+
 	/*
 	 * Create a new SSL context into which we'll load all the configuration
 	 * settings.  If we fail partway through, we can avoid memory leakage by
@@ -126,10 +284,17 @@ be_tls_init(bool isServerStart)
 	 */
 	SSL_CTX_set_mode(context, SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER);
 
+	/*
+	 * Install SNI TLS extension callback in case the server is configured to
+	 * validate hostnames.
+	 */
+	if (ssl_snimode != SSL_SNIMODE_OFF)
+		SSL_CTX_set_tlsext_servername_callback(context, sni_servername_cb);
+
 	/*
 	 * Call init hook (usually to set password callback)
 	 */
-	(*openssl_tls_init_hook) (context, isServerStart);
+	(*openssl_tls_init_hook) (context, isServerStart, host_line);
 
 	/* used by the callback */
 	ssl_is_server_start = isServerStart;
@@ -137,16 +302,16 @@ be_tls_init(bool isServerStart)
 	/*
 	 * Load and verify server's certificate and private key
 	 */
-	if (SSL_CTX_use_certificate_chain_file(context, ssl_cert_file) != 1)
+	if (SSL_CTX_use_certificate_chain_file(context, ctx_ssl_cert_file) != 1)
 	{
 		ereport(isServerStart ? FATAL : LOG,
 				(errcode(ERRCODE_CONFIG_FILE_ERROR),
 				 errmsg("could not load server certificate file \"%s\": %s",
-						ssl_cert_file, SSLerrmessage(ERR_get_error()))));
+						ctx_ssl_cert_file, SSLerrmessage(ERR_get_error()))));
 		goto error;
 	}
 
-	if (!check_ssl_key_file_permissions(ssl_key_file, isServerStart))
+	if (!check_ssl_key_file_permissions(ctx_ssl_key_file, isServerStart))
 		goto error;
 
 	/*
@@ -155,19 +320,19 @@ be_tls_init(bool isServerStart)
 	dummy_ssl_passwd_cb_called = false;
 
 	if (SSL_CTX_use_PrivateKey_file(context,
-									ssl_key_file,
+									ctx_ssl_key_file,
 									SSL_FILETYPE_PEM) != 1)
 	{
 		if (dummy_ssl_passwd_cb_called)
 			ereport(isServerStart ? FATAL : LOG,
 					(errcode(ERRCODE_CONFIG_FILE_ERROR),
 					 errmsg("private key file \"%s\" cannot be reloaded because it requires a passphrase",
-							ssl_key_file)));
+							ctx_ssl_key_file)));
 		else
 			ereport(isServerStart ? FATAL : LOG,
 					(errcode(ERRCODE_CONFIG_FILE_ERROR),
 					 errmsg("could not load private key file \"%s\": %s",
-							ssl_key_file, SSLerrmessage(ERR_get_error()))));
+							ctx_ssl_key_file, SSLerrmessage(ERR_get_error()))));
 		goto error;
 	}
 
@@ -319,17 +484,17 @@ be_tls_init(bool isServerStart)
 	/*
 	 * Load CA store, so we can verify client certificates if needed.
 	 */
-	if (ssl_ca_file[0])
+	if (ctx_ssl_ca_file[0])
 	{
 		STACK_OF(X509_NAME) * root_cert_list;
 
-		if (SSL_CTX_load_verify_locations(context, ssl_ca_file, NULL) != 1 ||
-			(root_cert_list = SSL_load_client_CA_file(ssl_ca_file)) == NULL)
+		if (SSL_CTX_load_verify_locations(context, ctx_ssl_ca_file, NULL) != 1 ||
+			(root_cert_list = SSL_load_client_CA_file(ctx_ssl_ca_file)) == NULL)
 		{
 			ereport(isServerStart ? FATAL : LOG,
 					(errcode(ERRCODE_CONFIG_FILE_ERROR),
 					 errmsg("could not load root certificate file \"%s\": %s",
-							ssl_ca_file, SSLerrmessage(ERR_get_error()))));
+							ctx_ssl_ca_file, SSLerrmessage(ERR_get_error()))));
 			goto error;
 		}
 
@@ -401,38 +566,29 @@ be_tls_init(bool isServerStart)
 		}
 	}
 
-	/*
-	 * Success!  Replace any existing SSL_context.
-	 */
-	if (SSL_context)
-		SSL_CTX_free(SSL_context);
-
-	SSL_context = context;
-
-	/*
-	 * Set flag to remember whether CA store has been loaded into SSL_context.
-	 */
-	if (ssl_ca_file[0])
-		ssl_loaded_verify_locations = true;
-	else
-		ssl_loaded_verify_locations = false;
-
-	return 0;
+	return context;
 
 	/* Clean up by releasing working context. */
 error:
 	if (context)
 		SSL_CTX_free(context);
-	return -1;
+	return NULL;
 }
 
 void
 be_tls_destroy(void)
 {
-	if (SSL_context)
-		SSL_CTX_free(SSL_context);
+	ListCell   *cell;
+
+	foreach(cell, contexts)
+	{
+		HostContext *host_context = lfirst(cell);
+
+		SSL_CTX_free(host_context->context);
+		pfree(host_context);
+	}
+
 	SSL_context = NULL;
-	ssl_loaded_verify_locations = false;
 }
 
 int
@@ -1132,7 +1288,7 @@ ssl_external_passwd_cb(char *buf, int size, int rwflag, void *userdata)
 
 	Assert(rwflag == 0);
 
-	return run_ssl_passphrase_command(prompt, ssl_is_server_start, buf, size);
+	return run_ssl_passphrase_command(prompt, ssl_is_server_start, buf, size, userdata);
 }
 
 /*
@@ -1369,6 +1525,60 @@ alpn_cb(SSL *ssl,
 	}
 }
 
+static int
+sni_servername_cb(SSL *ssl, int *al, void *arg)
+{
+	const char *tlsext_hostname;
+	ListCell   *cell;
+	HostContext *host_context;
+
+	Assert(ssl_snimode != SSL_SNIMODE_OFF);
+
+	tlsext_hostname = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name);
+
+	if (!tlsext_hostname)
+	{
+		if (ssl_snimode == SSL_SNIMODE_STRICT)
+		{
+			ereport(COMMERROR,
+					(errcode(ERRCODE_PROTOCOL_VIOLATION),
+					 errmsg("no hostname provided in callback")));
+			return SSL_TLSEXT_ERR_ALERT_FATAL;
+		}
+		else
+			return SSL_TLSEXT_ERR_OK;
+	}
+
+	foreach(cell, contexts)
+	{
+		host_context = lfirst(cell);
+
+		if (strcmp(host_context->hostname, tlsext_hostname) == 0)
+		{
+			Host_context = host_context;
+			SSL_context = host_context->context;
+			SSL_set_SSL_CTX(ssl, SSL_context);
+			return SSL_TLSEXT_ERR_OK;
+		}
+	}
+
+	if (ssl_snimode == SSL_SNIMODE_STRICT)
+	{
+		ereport(COMMERROR,
+				(errcode(ERRCODE_PROTOCOL_VIOLATION),
+				 errmsg("no matching pg_hosts entry found for hostname: \"%s\"",
+						tlsext_hostname)));
+		return SSL_TLSEXT_ERR_ALERT_FATAL;
+	}
+
+	/*
+	 * In ssl_snimode "default" we can return without doing anything since we
+	 * already installed the context for the default host when parsing the
+	 * hosts file.
+	 */
+	Assert(SSL_context);
+	return SSL_TLSEXT_ERR_OK;
+}
 
 /*
  * Set DH parameters for generating ephemeral DH keys.  The
@@ -1578,6 +1788,12 @@ be_tls_get_peer_serial(Port *port, char *ptr, size_t len)
 		ptr[0] = '\0';
 }
 
+bool
+be_tls_loaded_verify_locations(void)
+{
+	return Host_context->ssl_loaded_verify_locations;
+}
+
 char *
 be_tls_get_certificate_hash(Port *port, size_t *len)
 {
@@ -1771,17 +1987,23 @@ ssl_protocol_version_to_string(int v)
 
 
 static void
-default_openssl_tls_init(SSL_CTX *context, bool isServerStart)
+default_openssl_tls_init(SSL_CTX *context, bool isServerStart, HostsLine *host)
 {
 	if (isServerStart)
 	{
-		if (ssl_passphrase_command[0])
+		if (host->ssl_passphrase_cmd != NULL)
+		{
 			SSL_CTX_set_default_passwd_cb(context, ssl_external_passwd_cb);
+			SSL_CTX_set_default_passwd_cb_userdata(context, host->ssl_passphrase_cmd);
+		}
 	}
 	else
 	{
-		if (ssl_passphrase_command[0] && ssl_passphrase_command_supports_reload)
+		if (host->ssl_passphrase_cmd != NULL && host->ssl_passphrase_reload)
+		{
 			SSL_CTX_set_default_passwd_cb(context, ssl_external_passwd_cb);
+			SSL_CTX_set_default_passwd_cb_userdata(context, host->ssl_passphrase_cmd);
+		}
 		else
 
 			/*
@@ -1793,3 +2015,22 @@ default_openssl_tls_init(SSL_CTX *context, bool isServerStart)
 			SSL_CTX_set_default_passwd_cb(context, dummy_ssl_passwd_cb);
 	}
 }
+
+static void
+free_contexts(void)
+{
+	if (contexts == NIL)
+		return;
+
+	foreach_ptr(HostContext, host, contexts)
+	{
+		if (host->hostname)
+			pfree(unconstify(char *, host->hostname));
+		if (host->ssl_passphrase)
+			pfree(unconstify(char *, host->ssl_passphrase));
+		SSL_CTX_free(host->context);
+	}
+
+	list_free_deep(contexts);
+	contexts = NIL;
+}
diff --git a/src/backend/libpq/be-secure.c b/src/backend/libpq/be-secure.c
index 2139f81f24..ad3066b63c 100644
--- a/src/backend/libpq/be-secure.c
+++ b/src/backend/libpq/be-secure.c
@@ -43,10 +43,6 @@ char	   *ssl_dh_params_file;
 char	   *ssl_passphrase_command;
 bool		ssl_passphrase_command_supports_reload;
 
-#ifdef USE_SSL
-bool		ssl_loaded_verify_locations = false;
-#endif
-
 /* GUC variable controlling SSL cipher list */
 char	   *SSLCipherSuites = NULL;
 char	   *SSLCipherList = NULL;
@@ -60,6 +56,8 @@ bool		SSLPreferServerCiphers;
 int			ssl_min_protocol_version = PG_TLS1_2_VERSION;
 int			ssl_max_protocol_version = PG_TLS_ANY;
 
+int			ssl_snimode = SSL_SNIMODE_DEFAULT;
+
 /* ------------------------------------------------------------ */
 /*			 Procedures common to all secure sessions			*/
 /* ------------------------------------------------------------ */
@@ -99,7 +97,7 @@ bool
 secure_loaded_verify_locations(void)
 {
 #ifdef USE_SSL
-	return ssl_loaded_verify_locations;
+	return be_tls_loaded_verify_locations();
 #else
 	return false;
 #endif
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 7c65314512..1c6269262c 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -30,5 +30,6 @@ endif
 install_data(
   'pg_hba.conf.sample',
   'pg_ident.conf.sample',
+  'pg_hosts.conf.sample',
   install_dir: dir_data,
 )
diff --git a/src/backend/libpq/pg_hosts.conf.sample b/src/backend/libpq/pg_hosts.conf.sample
new file mode 100644
index 0000000000..608210686e
--- /dev/null
+++ b/src/backend/libpq/pg_hosts.conf.sample
@@ -0,0 +1,5 @@
+# PostgreSQL SNI Hostname mappings
+# ================================
+
+# HOSTNAME       SSL CERTIFICATE             SSL KEY
+
diff --git a/src/backend/utils/misc/guc.c b/src/backend/utils/misc/guc.c
index c10c0844ab..b3e1cf0b25 100644
--- a/src/backend/utils/misc/guc.c
+++ b/src/backend/utils/misc/guc.c
@@ -55,6 +55,7 @@
 #define CONFIG_FILENAME "postgresql.conf"
 #define HBA_FILENAME	"pg_hba.conf"
 #define IDENT_FILENAME	"pg_ident.conf"
+#define HOSTS_FILENAME	"pg_hosts.conf"
 
 #ifdef EXEC_BACKEND
 #define CONFIG_EXEC_PARAMS "global/config_exec_params"
@@ -1968,6 +1969,31 @@ SelectConfigFiles(const char *userDoption, const char *progname)
 	}
 	SetConfigOption("ident_file", fname, PGC_POSTMASTER, PGC_S_OVERRIDE);
 
+	/*
+	 * Likewise for pg_hosts.conf
+	 */
+	if (HostsFileName)
+	{
+		fname = make_absolute_path(HostsFileName);
+		fname_is_malloced = true;
+	}
+	else if (configdir)
+	{
+		fname = guc_malloc(FATAL,
+						   strlen(configdir) + strlen(HOSTS_FILENAME) + 2);
+		sprintf(fname, "%s/%s", configdir, HOSTS_FILENAME);
+		fname_is_malloced = false;
+	}
+	else
+	{
+		write_stderr("%s does not know where to find the \"hosts\" configuration file.\n"
+					 "This can be specified as \"hosts_file\" in \"%s\", "
+					 "or by the -D invocation option, or by the "
+					 "PGDATA environment variable.\n",
+					 progname, ConfigFileName);
+	}
+	SetConfigOption("hosts_file", fname, PGC_POSTMASTER, PGC_S_OVERRIDE);
+
 	if (fname_is_malloced)
 		free(fname);
 	else
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 8cf1afbad2..dca5a15f5c 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -474,6 +474,13 @@ static const struct config_enum_entry wal_compression_options[] = {
 	{NULL, 0, false}
 };
 
+static const struct config_enum_entry ssl_snimode_options[] = {
+	{"off", SSL_SNIMODE_OFF, false},
+	{"default", SSL_SNIMODE_DEFAULT, false},
+	{"strict", SSL_SNIMODE_STRICT, false},
+	{NULL, 0, false}
+};
+
 /*
  * Options for enum values stored in other modules
  */
@@ -538,6 +545,7 @@ char	   *cluster_name = "";
 char	   *ConfigFileName;
 char	   *HbaFileName;
 char	   *IdentFileName;
+char	   *HostsFileName;
 char	   *external_pid_file;
 
 char	   *application_name;
@@ -4562,6 +4570,17 @@ struct config_string ConfigureNamesString[] =
 		NULL, NULL, NULL
 	},
 
+	{
+		{"hosts_file", PGC_POSTMASTER, FILE_LOCATIONS,
+			gettext_noop("Sets the server's \"hosts\" configuration file."),
+			NULL,
+			GUC_SUPERUSER_ONLY
+		},
+		&HostsFileName,
+		NULL,
+		NULL, NULL, NULL
+	},
+
 	{
 		{"external_pid_file", PGC_POSTMASTER, FILE_LOCATIONS,
 			gettext_noop("Writes the postmaster PID to the specified file."),
@@ -5204,6 +5223,18 @@ struct config_enum ConfigureNamesEnum[] =
 		NULL, NULL, NULL
 	},
 
+	{
+		{"ssl_snimode", PGC_SIGHUP, CONN_AUTH_SSL,
+			gettext_noop("Sets the SNI mode to use."),
+			NULL,
+			GUC_SUPERUSER_ONLY,
+		},
+		&ssl_snimode,
+		SSL_SNIMODE_DEFAULT,
+		ssl_snimode_options,
+		NULL, NULL, NULL
+	},
+
 	{
 		{"recovery_init_sync_method", PGC_SIGHUP, ERROR_HANDLING_OPTIONS,
 			gettext_noop("Sets the method for synchronizing the data directory before crash recovery."),
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index a2ac7575ca..902c4eccef 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -120,6 +120,7 @@
 #ssl_dh_params_file = ''
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
+#ssl_snimode = default
 
 
 #------------------------------------------------------------------------------
diff --git a/src/bin/initdb/initdb.c b/src/bin/initdb/initdb.c
index 9a91830783..984763da08 100644
--- a/src/bin/initdb/initdb.c
+++ b/src/bin/initdb/initdb.c
@@ -176,6 +176,7 @@ static int	encodingid;
 static char *bki_file;
 static char *hba_file;
 static char *ident_file;
+static char *hosts_file;
 static char *conf_file;
 static char *dictionary_file;
 static char *info_schema_file;
@@ -1512,6 +1513,14 @@ setup_config(void)
 
 	snprintf(path, sizeof(path), "%s/pg_ident.conf", pg_data);
 
+	writefile(path, conflines);
+	if (chmod(path, pg_file_create_mode) != 0)
+		pg_fatal("could not change permissions of \"%s\": %m", path);
+
+	/* pg_hosts.conf */
+	conflines = readfile(hosts_file);
+	snprintf(path, sizeof(path), "%s/pg_hosts.conf", pg_data);
+
 	writefile(path, conflines);
 	if (chmod(path, pg_file_create_mode) != 0)
 		pg_fatal("could not change permissions of \"%s\": %m", path);
@@ -2771,6 +2780,7 @@ setup_data_file_paths(void)
 	set_input(&bki_file, "postgres.bki");
 	set_input(&hba_file, "pg_hba.conf.sample");
 	set_input(&ident_file, "pg_ident.conf.sample");
+	set_input(&hosts_file, "pg_hosts.conf.sample");
 	set_input(&conf_file, "postgresql.conf.sample");
 	set_input(&dictionary_file, "snowball_create.sql");
 	set_input(&info_schema_file, "information_schema.sql");
@@ -2786,12 +2796,13 @@ setup_data_file_paths(void)
 				"PGDATA=%s\nshare_path=%s\nPGPATH=%s\n"
 				"POSTGRES_SUPERUSERNAME=%s\nPOSTGRES_BKI=%s\n"
 				"POSTGRESQL_CONF_SAMPLE=%s\n"
-				"PG_HBA_SAMPLE=%s\nPG_IDENT_SAMPLE=%s\n",
+				"PG_HBA_SAMPLE=%s\nPG_IDENT_SAMPLE=%s\n"
+				"PG_HOSTS_SAMPLE=%s\n",
 				PG_VERSION,
 				pg_data, share_path, bin_path,
 				username, bki_file,
 				conf_file,
-				hba_file, ident_file);
+				hba_file, ident_file, hosts_file);
 		if (show_setting)
 			exit(0);
 	}
@@ -2799,6 +2810,7 @@ setup_data_file_paths(void)
 	check_input(bki_file);
 	check_input(hba_file);
 	check_input(ident_file);
+	check_input(hosts_file);
 	check_input(conf_file);
 	check_input(dictionary_file);
 	check_input(info_schema_file);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index 8ea837ae82..d1eb750368 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -146,6 +146,25 @@ typedef struct IdentLine
 	AuthToken  *pg_user;
 } IdentLine;
 
+typedef struct HostsLine
+{
+	int			linenumber;
+
+	char	   *sourcefile;
+	char	   *rawline;
+
+	/* Required fields */
+	bool		default_host;
+	char	   *hostname;
+	char	   *ssl_key;
+	char	   *ssl_cert;
+	char	   *ssl_ca;
+
+	/* Optional fields */
+	char	   *ssl_passphrase_cmd;
+	bool		ssl_passphrase_reload;
+} HostsLine;
+
 /*
  * TokenizedAuthLine represents one line lexed from an authentication
  * configuration file.  Each item in the "fields" list is a sub-list of
diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h
index 9109b2c334..cf0e87a28c 100644
--- a/src/include/libpq/libpq-be.h
+++ b/src/include/libpq/libpq-be.h
@@ -314,6 +314,7 @@ extern const char *be_tls_get_cipher(Port *port);
 extern void be_tls_get_peer_subject_name(Port *port, char *ptr, size_t len);
 extern void be_tls_get_peer_issuer_name(Port *port, char *ptr, size_t len);
 extern void be_tls_get_peer_serial(Port *port, char *ptr, size_t len);
+extern bool be_tls_loaded_verify_locations(void);
 
 /*
  * Get the server certificate hash for SCRAM channel binding type
@@ -326,7 +327,7 @@ extern char *be_tls_get_certificate_hash(Port *port, size_t *len);
 
 /* init hook for SSL, the default sets the password callback if appropriate */
 #ifdef USE_OPENSSL
-typedef void (*openssl_tls_init_hook_typ) (SSL_CTX *context, bool isServerStart);
+typedef void (*openssl_tls_init_hook_typ) (SSL_CTX *context, bool isServerStart, HostsLine *host);
 extern PGDLLIMPORT openssl_tls_init_hook_typ openssl_tls_init_hook;
 #endif
 
diff --git a/src/include/libpq/libpq.h b/src/include/libpq/libpq.h
index 07e5b12536..98da8b61eb 100644
--- a/src/include/libpq/libpq.h
+++ b/src/include/libpq/libpq.h
@@ -107,6 +107,7 @@ extern PGDLLIMPORT char *ssl_crl_dir;
 extern PGDLLIMPORT char *ssl_key_file;
 extern PGDLLIMPORT int ssl_min_protocol_version;
 extern PGDLLIMPORT int ssl_max_protocol_version;
+extern PGDLLIMPORT int ssl_snimode;
 extern PGDLLIMPORT char *ssl_passphrase_command;
 extern PGDLLIMPORT bool ssl_passphrase_command_supports_reload;
 extern PGDLLIMPORT char *ssl_dh_params_file;
@@ -134,12 +135,20 @@ enum ssl_protocol_versions
 	PG_TLS1_3_VERSION,
 };
 
+enum ssl_snimode
+{
+	SSL_SNIMODE_OFF = 0,
+	SSL_SNIMODE_DEFAULT,
+	SSL_SNIMODE_STRICT
+};
+
 /*
  * prototypes for functions in be-secure-common.c
  */
 extern int	run_ssl_passphrase_command(const char *prompt, bool is_server_start,
-									   char *buf, int size);
+									   char *buf, int size, void *userdata);
 extern bool check_ssl_key_file_permissions(const char *ssl_key_file,
 										   bool isServerStart);
+extern List *load_hosts(void);
 
 #endif							/* LIBPQ_H */
diff --git a/src/include/utils/guc.h b/src/include/utils/guc.h
index 840b0fe57f..4c33ba7ca4 100644
--- a/src/include/utils/guc.h
+++ b/src/include/utils/guc.h
@@ -284,6 +284,7 @@ extern PGDLLIMPORT char *cluster_name;
 extern PGDLLIMPORT char *ConfigFileName;
 extern PGDLLIMPORT char *HbaFileName;
 extern PGDLLIMPORT char *IdentFileName;
+extern PGDLLIMPORT char *HostsFileName;
 extern PGDLLIMPORT char *external_pid_file;
 
 extern PGDLLIMPORT char *application_name;
diff --git a/src/test/ssl/meson.build b/src/test/ssl/meson.build
index b3c5503f79..55f5887d9c 100644
--- a/src/test/ssl/meson.build
+++ b/src/test/ssl/meson.build
@@ -13,6 +13,7 @@ tests += {
       't/001_ssltests.pl',
       't/002_scram.pl',
       't/003_sslinfo.pl',
+      't/004_sni.pl',
     ],
   },
 }
diff --git a/src/test/ssl/t/004_sni.pl b/src/test/ssl/t/004_sni.pl
new file mode 100644
index 0000000000..d2efcd850c
--- /dev/null
+++ b/src/test/ssl/t/004_sni.pl
@@ -0,0 +1,92 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use SSL::Server;
+
+# This is the hostname used to connect to the server. This cannot be a
+# hostname, because the server certificate is always for the domain
+# postgresql-ssl-regression.test.
+my $SERVERHOSTADDR = '127.0.0.1';
+# This is the pattern to use in pg_hba.conf to match incoming connections.
+my $SERVERHOSTCIDR = '127.0.0.1/32';
+
+if ($ENV{with_ssl} ne 'openssl')
+{
+	plan skip_all => 'OpenSSL not supported by this build';
+}
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bssl\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test SSL not enabled in PG_TEST_EXTRA';
+}
+
+my $ssl_server = SSL::Server->new();
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+
+# PGHOST is enforced here to set up the node, subsequent connections
+# will use a dedicated connection string.
+$ENV{PGHOST} = $node->host;
+$ENV{PGPORT} = $node->port;
+$node->start;
+
+$ssl_server->configure_test_server_for_ssl($node, $SERVERHOSTADDR,
+	$SERVERHOSTCIDR, 'trust');
+
+$ssl_server->switch_server_cert($node, certfile => 'server-cn-only');
+
+my $connstr =
+  "dbname=trustdb hostaddr=$SERVERHOSTADDR host=localhost sslsni=1";
+
+$node->append_conf('postgresql.conf', "ssl_snimode=default");
+$node->reload;
+
+$node->connect_ok(
+	"$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require",
+	"connect with correct server CA cert file sslmode=require");
+
+ok(unlink($node->data_dir . '/pg_hosts.conf'));
+$node->append_conf('pg_hosts.conf', "localhost server.crt server.key root.crt");
+$node->append_conf('postgresql.conf', "ssl_snimode=strict");
+$node->reload;
+
+$node->connect_fails(
+	"$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require",
+	"connect with correct server CA cert file sslmode=require",
+	expected_stderr => qr/unexpected eof/);
+
+ok(unlink($node->data_dir . '/pg_hosts.conf'));
+$node->append_conf('pg_hosts.conf', "localhost server-cn-only.crt server-cn-only.key root_ca.crt");
+$node->reload;
+
+$node->connect_ok(
+	"$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require",
+	"connect with correct server CA cert file sslmode=require");
+
+ok(unlink($node->data_dir . '/pg_hosts.conf'));
+$node->append_conf('pg_hosts.conf', 'localhost server-cn-only.crt server-password.key root+client_ca.crt "echo wrongpassword" no');
+my $result = $node->restart(fail_ok => 1);
+is($result, 0, 'restart fails with password-protected key when using the wrong passphrase command');
+
+ok(unlink($node->data_dir . '/pg_hosts.conf'));
+$node->append_conf('pg_hosts.conf', 'localhost server-cn-only.crt server-password.key root+client_ca.crt "echo secret1" no');
+$result = $node->restart(fail_ok => 1);
+is($result, 1, 'restart succeeds with password-protected key when using the correct passphrase command');
+
+$node->connect_ok(
+	"$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require",
+	"connect with correct server CA cert file sslmode=require");
+
+done_testing();
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 2d4c870423..15ccf4cf46 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -1158,6 +1158,8 @@ HeapTupleHeader
 HeapTupleHeaderData
 HeapTupleTableSlot
 HistControl
+HostContext
+HostsLine
 HotStandbyState
 I32
 ICU_Convert_Func
-- 
2.39.3 (Apple Git-146)

