From cdb1c72199d7b6a90d887c4445c5ff0c6d8e36e0 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi 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 + +#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