0002-Add-TOML-format-support-for-the-pg_hosts-configurati.patch
application/octet-stream
Filename: 0002-Add-TOML-format-support-for-the-pg_hosts-configurati.patch
Type: application/octet-stream
Part: 1
Patch
Format: format-patch
Series: patch 0002
Subject: Add TOML format support for the pg_hosts configuration file
| File | + | − |
|---|---|---|
| src/backend/libpq/be-secure-common.c | 227 | 0 |
| src/backend/libpq/Makefile | 2 | 1 |
| src/backend/libpq/meson.build | 1 | 0 |
| src/backend/libpq/toml_config.c | 129 | 0 |
| src/include/libpq/toml_config.h | 48 | 0 |
| src/test/ssl/meson.build | 1 | 0 |
| src/test/ssl/t/005_sni_toml.pl | 105 | 0 |
From cdb1c72199d7b6a90d887c4445c5ff0c6d8e36e0 Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Tue, 7 Jul 2026 16:55:18 +0000
Subject: [PATCH 2/2] Add TOML format support for the pg_hosts configuration
file
Support a TOML-format hosts file (selected when hosts_file ends in
".toml"), parsed via the vendored tomlc17 parser
---
src/backend/libpq/Makefile | 3 +-
src/backend/libpq/be-secure-common.c | 227 +++++++++++++++++++++++++++
src/backend/libpq/meson.build | 1 +
src/backend/libpq/toml_config.c | 129 +++++++++++++++
src/include/libpq/toml_config.h | 48 ++++++
src/test/ssl/meson.build | 1 +
src/test/ssl/t/005_sni_toml.pl | 105 +++++++++++++
7 files changed, 513 insertions(+), 1 deletion(-)
create mode 100644 src/backend/libpq/toml_config.c
create mode 100644 src/include/libpq/toml_config.h
create mode 100644 src/test/ssl/t/005_sni_toml.pl
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 98eb2a8242d..65002555f74 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -28,7 +28,8 @@ OBJS = \
pqcomm.o \
pqformat.o \
pqmq.o \
- pqsignal.o
+ pqsignal.o \
+ toml_config.o
ifeq ($(with_ssl),openssl)
OBJS += be-secure-openssl.o
diff --git a/src/backend/libpq/be-secure-common.c b/src/backend/libpq/be-secure-common.c
index 6ec887b8a47..c1bd5cf5db5 100644
--- a/src/backend/libpq/be-secure-common.c
+++ b/src/backend/libpq/be-secure-common.c
@@ -24,7 +24,9 @@
#include "common/percentrepl.h"
#include "common/string.h"
+#include "common/tomlc17.h"
#include "libpq/libpq.h"
+#include "libpq/toml_config.h"
#include "storage/fd.h"
#include "utils/builtins.h"
#include "utils/guc.h"
@@ -351,6 +353,199 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel)
return parsedline;
}
+static const char *const hosts_toml_keys[] = {
+ "ssl_certificate",
+ "ssl_key",
+ "ssl_ca",
+ "passphrase_command",
+ "passphrase_command_reload",
+ NULL,
+};
+
+/*
+ * Validate one TOML table (a host entry or the _defaults table) and copy its
+ * values into *out (a HostsLine); string values are pstrdup'd so they survive
+ * freeing the parsed TOML document. "label" is the dotted path for
+ * diagnostics, e.g. "hosts._defaults".
+ *
+ * *reload_present is set true if passphrase_command_reload was specified, so
+ * the merge can tell "omitted" from "set to false".
+ */
+static bool
+hosts_toml_collect(toml_datum_t tab, const char *label, const char *filename,
+ int elevel, HostsLine *out, bool *reload_present)
+{
+ toml_datum_t cert = toml_get(tab, "ssl_certificate");
+ toml_datum_t key = toml_get(tab, "ssl_key");
+ toml_datum_t ca = toml_get(tab, "ssl_ca");
+ toml_datum_t cmd = toml_get(tab, "passphrase_command");
+ toml_datum_t rel = toml_get(tab, "passphrase_command_reload");
+
+ *reload_present = false;
+
+ if (!toml_reject_unknown(hosts_toml_keys, tab, label, filename, elevel))
+ return false;
+
+ if (cert.type != TOML_UNKNOWN && cert.type != TOML_STRING)
+ return toml_type_error(label, "ssl_certificate", "string", filename, elevel);
+ if (key.type != TOML_UNKNOWN && key.type != TOML_STRING)
+ return toml_type_error(label, "ssl_key", "string", filename, elevel);
+ if (ca.type != TOML_UNKNOWN && ca.type != TOML_STRING)
+ return toml_type_error(label, "ssl_ca", "string", filename, elevel);
+ if (cmd.type != TOML_UNKNOWN && cmd.type != TOML_STRING)
+ return toml_type_error(label, "passphrase_command", "string", filename, elevel);
+ if (rel.type != TOML_UNKNOWN && rel.type != TOML_BOOLEAN)
+ return toml_type_error(label, "passphrase_command_reload", "boolean", filename, elevel);
+
+ if (cert.type == TOML_STRING)
+ out->ssl_cert = pstrdup(cert.u.s);
+ if (key.type == TOML_STRING)
+ out->ssl_key = pstrdup(key.u.s);
+ if (ca.type == TOML_STRING)
+ out->ssl_ca = pstrdup(ca.u.s);
+ if (cmd.type == TOML_STRING)
+ out->ssl_passphrase_cmd = pstrdup(cmd.u.s);
+ if (rel.type == TOML_BOOLEAN)
+ {
+ out->ssl_passphrase_reload = rel.u.boolean;
+ *reload_present = true;
+ }
+
+ return true;
+}
+
+/*
+ * Parse a pg_hosts TOML file into a list of HostsLine.
+ * *missing = true -> file does not exist (ENOENT); returns NIL
+ * *file_err != NULL -> whole-file open/parse error; returns NIL
+ * *had_errors = true -> some entries were invalid; they were reported at
+ * elevel and left out of the result
+ */
+static List *
+parse_hosts_toml(const char *filename, int elevel,
+ bool *missing, bool *had_errors, char **file_err)
+{
+ toml_result_t result;
+ toml_datum_t hosts_section;
+ toml_datum_t defaults_dat;
+ HostsLine defaults = {0};
+ bool defaults_reload_present = false;
+ bool have_defaults = false;
+ List *entries = NIL;
+
+ *missing = false;
+ *had_errors = false;
+ *file_err = NULL;
+
+ if (!toml_config_load(filename, elevel, &result, file_err))
+ {
+ if (*file_err == NULL)
+ *missing = true; /* ENOENT */
+ return NIL;
+ }
+
+ hosts_section = toml_get(result.toptab, "hosts");
+ if (hosts_section.type == TOML_UNKNOWN)
+ {
+ toml_free(result);
+ return NIL; /* no [hosts] table: empty */
+ }
+ if (hosts_section.type != TOML_TABLE)
+ {
+ *file_err = psprintf("\"hosts\" in \"%s\" is not a table", filename);
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("\"hosts\" in \"%s\" is not a table", filename)));
+ toml_free(result);
+ return NIL;
+ }
+
+ defaults_dat = toml_get(hosts_section, "_defaults");
+ if (defaults_dat.type == TOML_TABLE)
+ {
+ if (hosts_toml_collect(defaults_dat, "hosts._defaults", filename,
+ elevel, &defaults, &defaults_reload_present))
+ have_defaults = true;
+ else
+ *had_errors = true;
+ }
+ else if (defaults_dat.type != TOML_UNKNOWN)
+ {
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("\"hosts._defaults\" in \"%s\" must be a table", filename)));
+ *had_errors = true;
+ }
+
+ for (int i = 0; i < hosts_section.u.tab.size; i++)
+ {
+ const char *name = hosts_section.u.tab.key[i];
+ toml_datum_t val = hosts_section.u.tab.value[i];
+ HostsLine *hl;
+ bool reload_present = false;
+
+ if (strcmp(name, "_defaults") == 0)
+ continue;
+
+ if (val.type != TOML_TABLE)
+ {
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("\"hosts.%s\" in \"%s\" must be a table", name, filename)));
+ *had_errors = true;
+ continue;
+ }
+
+ hl = palloc0_object(HostsLine);
+ hl->linenumber = val.lineno;
+ hl->sourcefile = pstrdup(filename);
+ hl->rawline = pstrdup("");
+ hl->hostnames = list_make1(pstrdup(name));
+
+ {
+ char *label = psprintf("hosts.%s", name);
+ bool ok = hosts_toml_collect(val, label, filename, elevel,
+ hl, &reload_present);
+
+ pfree(label);
+ if (!ok)
+ {
+ *had_errors = true;
+ continue;
+ }
+ }
+
+ if (have_defaults)
+ {
+ if (hl->ssl_cert == NULL)
+ hl->ssl_cert = defaults.ssl_cert;
+ if (hl->ssl_key == NULL)
+ hl->ssl_key = defaults.ssl_key;
+ if (hl->ssl_ca == NULL)
+ hl->ssl_ca = defaults.ssl_ca;
+ if (hl->ssl_passphrase_cmd == NULL)
+ hl->ssl_passphrase_cmd = defaults.ssl_passphrase_cmd;
+ if (!reload_present && defaults_reload_present)
+ hl->ssl_passphrase_reload = defaults.ssl_passphrase_reload;
+ }
+
+ if (hl->ssl_cert == NULL || hl->ssl_key == NULL)
+ {
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("\"hosts.%s\" in \"%s\" is missing required \"ssl_certificate\" or \"ssl_key\"",
+ name, filename)));
+ *had_errors = true;
+ continue;
+ }
+
+ entries = lappend(entries, hl);
+ }
+
+ toml_free(result);
+ return entries;
+}
+
/*
* load_hosts
*
@@ -384,6 +579,38 @@ load_hosts(List **hosts, char **err_msg)
}
*hosts = NIL;
+ if (toml_path(HostsFileName))
+ {
+ bool missing = false;
+ bool had_errors = false;
+ char *file_err = NULL;
+ List *entries;
+
+ entries = parse_hosts_toml(HostsFileName, LOG, &missing, &had_errors,
+ &file_err);
+ if (missing)
+ return HOSTSFILE_MISSING;
+ if (file_err)
+ {
+ if (err_msg)
+ *err_msg = file_err;
+ return HOSTSFILE_LOAD_FAILED;
+ }
+
+ *hosts = entries;
+
+ if (had_errors)
+ {
+ if (err_msg)
+ *err_msg = psprintf("loading config from \"%s\" failed due to parsing error",
+ HostsFileName);
+ return HOSTSFILE_LOAD_FAILED;
+ }
+ if (entries == NIL)
+ return HOSTSFILE_EMPTY;
+ return HOSTSFILE_LOAD_OK;
+ }
+
/*
* This is not an auth file per se, but it is using the same file format
* as the pg_hba and pg_ident files and thus the same code infrastructure.
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 8571f652844..590b18b4b76 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -15,6 +15,7 @@ backend_sources += files(
'pqformat.c',
'pqmq.c',
'pqsignal.c',
+ 'toml_config.c',
)
if ssl.found()
diff --git a/src/backend/libpq/toml_config.c b/src/backend/libpq/toml_config.c
new file mode 100644
index 00000000000..50b1bc8c4ad
--- /dev/null
+++ b/src/backend/libpq/toml_config.c
@@ -0,0 +1,129 @@
+/*-------------------------------------------------------------------------
+ *
+ * toml_config.c
+ * Minimal loader for TOML configuration files.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ * src/backend/libpq/toml_config.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <errno.h>
+
+#include "libpq/toml_config.h"
+#include "storage/fd.h"
+
+bool
+toml_config_load(const char *filename, int elevel,
+ toml_result_t *result, char **err_msg)
+{
+ FILE *fp;
+
+ if (err_msg)
+ *err_msg = NULL;
+
+ fp = AllocateFile(filename, "r");
+ if (fp == NULL)
+ {
+ int save_errno = errno;
+
+ if (save_errno == ENOENT)
+ return false; /* "missing" - not an error */
+
+ if (err_msg)
+ *err_msg = psprintf("could not open TOML config file \"%s\": %m",
+ filename);
+ errno = save_errno;
+ ereport(elevel,
+ (errcode_for_file_access(),
+ errmsg("could not open TOML config file \"%s\": %m",
+ filename)));
+ return false;
+ }
+
+ *result = toml_parse_file(fp);
+ FreeFile(fp);
+
+ if (!result->ok)
+ {
+ if (err_msg)
+ *err_msg = psprintf("TOML parse error in \"%s\": %s",
+ filename, result->errmsg);
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("TOML parse error in \"%s\": %s",
+ filename, result->errmsg)));
+ toml_free(*result);
+ return false;
+ }
+
+ return true;
+}
+
+/*
+ * Reject keys of "tab" not in the NULL-terminated "allowed" list. "label" is
+ * the dotted path for diagnostics (e.g. "hosts.foo"); each offender is
+ * reported at elevel. Returns true if all keys known.
+ */
+bool
+toml_reject_unknown(const char *const allowed[], toml_datum_t tab,
+ const char *label, const char *filename, int elevel)
+{
+ bool ok = true;
+
+ for (int i = 0; i < tab.u.tab.size; i++)
+ {
+ const char *key = tab.u.tab.key[i];
+ bool known = false;
+
+ for (int j = 0; allowed[j] != NULL; j++)
+ {
+ if (strcmp(key, allowed[j]) == 0)
+ {
+ known = true;
+ break;
+ }
+ }
+ if (known)
+ continue;
+
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("\"%s.%s\" in \"%s\" is not a recognized key",
+ label, key, filename)));
+ ok = false;
+ }
+
+ return ok;
+}
+
+/*
+ * Report that "label.field" in "filename" must be of type "expected" and
+ * return false for use as a one-line bail-out.
+ */
+bool
+toml_type_error(const char *label, const char *field, const char *expected,
+ const char *filename, int elevel)
+{
+ ereport(elevel,
+ (errcode(ERRCODE_CONFIG_FILE_ERROR),
+ errmsg("\"%s.%s\" in \"%s\" must be a %s",
+ label, field, filename, expected)));
+ return false;
+}
+
+/* True if "filename" names a TOML file (".toml" extension). */
+bool
+toml_path(const char *filename)
+{
+ const char *dot;
+
+ if (filename == NULL)
+ return false;
+ dot = strrchr(filename, '.');
+ return dot != NULL && strcmp(dot, ".toml") == 0;
+}
diff --git a/src/include/libpq/toml_config.h b/src/include/libpq/toml_config.h
new file mode 100644
index 00000000000..9635c2b4f85
--- /dev/null
+++ b/src/include/libpq/toml_config.h
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * toml_config.h
+ * Minimal loader for TOML configuration files.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ *
+ * src/include/libpq/toml_config.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef TOML_CONFIG_H
+#define TOML_CONFIG_H
+
+#include "common/tomlc17.h"
+
+/*
+ * Open and parse a TOML file.
+ *
+ * return true -> parsed; caller owns *result and must
+ * toml_free(*result) when done
+ * return false, *err_msg==NULL -> file missing (ENOENT)
+ * return false, *err_msg!=NULL -> open/parse error (already ereport'd at
+ * elevel); *err_msg holds the message
+ */
+extern bool toml_config_load(const char *filename, int elevel,
+ toml_result_t *result, char **err_msg);
+
+/*
+ * Reject keys of "tab" not in the NULL-terminated "allowed" list. Reports each
+ * offender at elevel. Returns true if all keys known.
+ */
+extern bool toml_reject_unknown(const char *const allowed[], toml_datum_t tab,
+ const char *label, const char *filename,
+ int elevel);
+
+/*
+ * Report that "label.field" in "filename" must be type "expected" and return
+ * false.
+ */
+extern bool toml_type_error(const char *label, const char *field,
+ const char *expected, const char *filename,
+ int elevel);
+
+/* True if "filename" names a TOML file (".toml" extension). */
+extern bool toml_path(const char *filename);
+
+#endif /* TOML_CONFIG_H */
diff --git a/src/test/ssl/meson.build b/src/test/ssl/meson.build
index d7e7ce23433..2b7552242f7 100644
--- a/src/test/ssl/meson.build
+++ b/src/test/ssl/meson.build
@@ -14,6 +14,7 @@ tests += {
't/002_scram.pl',
't/003_sslinfo.pl',
't/004_sni.pl',
+ 't/005_sni_toml.pl',
],
},
}
diff --git a/src/test/ssl/t/005_sni_toml.pl b/src/test/ssl/t/005_sni_toml.pl
new file mode 100644
index 00000000000..21d67e2f5a6
--- /dev/null
+++ b/src/test/ssl/t/005_sni_toml.pl
@@ -0,0 +1,105 @@
+
+# Copyright (c) 2024-2026, 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;
+
+my $SERVERHOSTADDR = '127.0.0.1';
+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();
+
+if ($ssl_server->is_libressl)
+{
+ plan skip_all => 'SNI not supported when building with LibreSSL';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+
+$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 =
+ "user=ssltestuser dbname=trustdb hostaddr=$SERVERHOSTADDR sslsni=1";
+
+##############################################################################
+# pg_hosts.toml - happy path
+##############################################################################
+
+ok(unlink($node->data_dir . '/pg_hosts.conf'),
+ 'remove pg_hosts.conf so dispatch picks pg_hosts.toml');
+
+my $datadir = $node->data_dir;
+my $tomlpath = "$datadir/pg_hosts.toml";
+open(my $fh, '>', $tomlpath)
+ or die "open pg_hosts.toml for writing: $!";
+print $fh <<'TOML';
+[hosts]
+"example.org" = { ssl_certificate = "server-cn-only+server_ca.crt", ssl_key = "server-cn-only.key", ssl_ca = "root_ca.crt" }
+"*" = { ssl_certificate = "server-cn-only.crt", ssl_key = "server-cn-only.key" }
+TOML
+close($fh);
+
+$node->append_conf('pg_hba.conf', "local all all trust");
+$node->append_conf('postgresql.conf',
+ "hosts_file = '$tomlpath'\n" . "ssl_sni = on\n");
+$node->restart;
+
+$node->connect_ok(
+ "$connstr host=example.org sslrootcert=ssl/root_ca.crt sslmode=verify-ca",
+ 'pg_hosts.toml: connect to example.org and verify per-host CA');
+
+$node->connect_ok(
+ "$connstr sslrootcert=ssl/root+server_ca.crt sslmode=require",
+ 'pg_hosts.toml: connect to default host with sslmode=require');
+
+##############################################################################
+# pg_hosts.toml - broken file logs the TOML parse error on reload
+##############################################################################
+
+open($fh, '>', $tomlpath)
+ or die "open pg_hosts.toml for rewriting: $!";
+print $fh "[hosts\n"; # unterminated table header
+close($fh);
+
+my $log_offset = -s $node->logfile;
+$node->reload;
+$node->wait_for_log(qr/could not load "pg_hosts\.conf"/, $log_offset);
+my $log = PostgreSQL::Test::Utils::slurp_file($node->logfile, $log_offset);
+like(
+ $log,
+ qr/TOML parse error/,
+ "pg_hosts.toml: malformed file produces 'TOML parse error' log entry");
+
+$node->connect_ok(
+ "$connstr host=example.org sslrootcert=ssl/root_ca.crt sslmode=verify-ca",
+ 'pg_hosts.toml: connection still works after malformed reload');
+
+done_testing();
--
2.43.0