From 9de22c35c7879719b35cc714513a87c413cfba21 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Fri, 9 Oct 2026 14:03:18 +0000 Subject: [POC PATCH v2 3/6] Add JSON format support for SNI host configuration Allow hosts_file to name a JSON document, customarily pg_hosts.json, in the same way as hba_file. Its "hosts" key holds entries keyed by host name that use the names of the corresponding ssl_* parameters for their fields, with a "defaults" object filling in fields shared by all entries, and legacy pg_hosts.conf files can be imported. Also add the pg_hosts_file_entries view, which works for both the legacy and the JSON format; there was no way to inspect the hosts configuration from SQL before. parse_hosts_line() is exported for the imports of the JSON section and for the view. --- doc/src/sgml/config.sgml | 4 +- doc/src/sgml/runtime.sgml | 64 ++++ doc/src/sgml/system-views.sgml | 161 +++++++++ src/backend/catalog/system_views.sql | 5 + src/backend/libpq/be-secure-common.c | 190 ++++++++--- src/backend/libpq/hba_json.c | 449 ++++++++++++++++++++++++- src/backend/nls.mk | 8 +- src/backend/utils/adt/hbafuncs.c | 259 ++++++++++++++ src/include/catalog/pg_proc.dat | 7 + src/include/libpq/hba.h | 7 + src/test/regress/expected/rules.out | 11 + src/test/regress/expected/sysviews.out | 8 + src/test/regress/sql/sysviews.sql | 4 + src/test/ssl/meson.build | 1 + src/test/ssl/t/005_json_hosts.pl | 342 +++++++++++++++++++ 15 files changed, 1464 insertions(+), 56 deletions(-) create mode 100644 src/test/ssl/t/005_json_hosts.pl diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index dda1eaa7628..9a607fdeb60 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -594,7 +594,9 @@ include_dir 'conf.d' Specifies the configuration file for host-based SSL configuration - (customarily called pg_hosts.conf). + (customarily called pg_hosts.conf, or + pg_hosts.json for the JSON format; a file + whose name ends in .json is read as JSON). This parameter can only be set at server start. See also . diff --git a/doc/src/sgml/runtime.sgml b/doc/src/sgml/runtime.sgml index d9984910cc4..853ed446a96 100644 --- a/doc/src/sgml/runtime.sgml +++ b/doc/src/sgml/runtime.sgml @@ -2701,6 +2701,70 @@ openssl x509 -req -in server.csr -text -days 365 \ postgresql.conf. + + The host entries can also be given as a JSON document, in the same way + as the client authentication rules (see ): when + names a file whose name ends in .json (customarily + pg_hosts.json), it is read as a document whose + hosts key holds the entries, with an optional + include key as described there: + +"hosts": { + "import": ["pg_hosts.conf"], + "defaults": {"ssl_ca_file": "/etc/postgresql/tls/internal-ca.crt"}, + "entries": { + "acme.db.example.com": {"ssl_cert_file": "acme.crt", "ssl_key_file": "acme.key"}, + "globex.db.example.com": {"ssl_cert_file": "globex.crt", "ssl_key_file": "globex.key", + "ssl_passphrase_command": "pass show pg/globex-key", + "ssl_passphrase_command_supports_reload": true}, + "*": {"ssl_cert_file": "server.crt", "ssl_key_file": "server.key"} + } +} + + The section is an object with up to three keys. + entries is an object whose keys are host names, + including the special values * and + /no_sni/ of , and + whose values are objects with the keys + ssl_cert_file, ssl_key_file, + ssl_ca_file and + ssl_passphrase_command (strings) and + ssl_passphrase_command_supports_reload (a boolean), + each with the meaning of the configuration parameter of the same name + for the connections to that host; relative file names are relative to + the data directory, as for the parameters. + ssl_cert_file and ssl_key_file + are required, the others are optional. defaults is + an object with the same keys; its values apply to every entry that does + not set the key itself. + + + + import names files in the format described above + to read in, as a string, an object with a file key + and an optional optional key, or an array of either, + with paths relative to the JSON file; the imported lines are used as + written, defaults does not apply to them. Every + document, included ones too, can have a hosts + section: the defaults of all documents are merged, + later documents overriding earlier ones key by key, and the imports + and entries of each document follow those of the documents read + before it. A host name can appear only once across all documents and + imported files, compared case-insensitively; a second occurrence is an + error. As with any other error in the section, the server does not + start, or keeps the previous configuration on reload. Without a + hosts section in any document, the + ssl_* parameters apply to all connections, as they + do when is missing. + + + + The pg_hosts_file_entries + view shows the entries of the current hosts configuration in either + format, with the errors found in them. + + It is currently not possible to set different clientname values for the different certificates. Any clientname diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml index fbfc8d08c7c..971fb9fc453 100644 --- a/doc/src/sgml/system-views.sgml +++ b/doc/src/sgml/system-views.sgml @@ -101,6 +101,11 @@ summary of client authentication configuration file contents + + pg_hosts_file_entries + summary of SNI host configuration file contents + + pg_ident_file_mappings summary of client user name mapping configuration file contents @@ -1574,6 +1579,162 @@ AND c1.path[c2.level] = c2.path[c2.level]; + + <structname>pg_hosts_file_entries</structname> + + + pg_hosts_file_entries + + + + The view pg_hosts_file_entries provides a + summary of the contents of the SNI host configuration file, + pg_hosts.conf. + A row appears in this view for each host name of each non-empty, + non-comment line in the file, with annotations indicating whether the + entry could be applied successfully; the rows of a line with several + host names share an entry_number. When the + entries come from the hosts section of a JSON + document (see ), a row appears for each entry, + and for each host name of each imported line. + + + + This view can be helpful for checking whether planned changes in the + host configuration file will work, or for diagnosing a previous + failure. Note that this view reports on the current + contents of the file, not on what was last loaded by the server. + + + + By default, the pg_hosts_file_entries view can + be read only by superusers. + + + + <structname>pg_hosts_file_entries</structname> Columns + + + + + Column Type + + + Description + + + + + + + + entry_number int4 + + + Number of this entry, in the order the entries are tried, if valid, + otherwise NULL + + + + + + file_name text + + + Name of the file containing this entry + + + + + + line_number int4 + + + Line number of this entry in file_name + + + + + + hostname text + + + Host name the entry applies to, or * for the + default entry, or /no_sni/ for the entry used + by connections without a host name + + + + + + ssl_cert_file text + + + Server certificate file + + + + + + ssl_key_file text + + + Server private key file + + + + + + ssl_ca_file text + + + Client certificate authority file, if any + + + + + + ssl_passphrase_command text + + + Command obtaining the passphrase of the key, if any + + + + + + ssl_passphrase_command_supports_reload bool + + + Whether the passphrase command can be run on reload + + + + + + error text + + + If not NULL, an error message indicating why this + entry could not be processed + + + + +
+ + + Usually, a row with non-null error indicates + that the host configuration could not be loaded, as every entry of it + must be valid. + + + + See for more information about SNI host + configuration. + +
+ <structname>pg_ident_file_mappings</structname> diff --git a/src/backend/catalog/system_views.sql b/src/backend/catalog/system_views.sql index 809b9c0f1e4..f6722297cf7 100644 --- a/src/backend/catalog/system_views.sql +++ b/src/backend/catalog/system_views.sql @@ -672,6 +672,11 @@ CREATE VIEW pg_ident_file_mappings AS REVOKE ALL ON pg_ident_file_mappings FROM PUBLIC; +CREATE VIEW pg_hosts_file_entries AS + SELECT * FROM pg_hosts_file_entries() AS A; + +REVOKE ALL ON pg_hosts_file_entries FROM PUBLIC; + CREATE VIEW pg_timezone_abbrevs AS SELECT * FROM pg_timezone_abbrevs_zone() z UNION ALL diff --git a/src/backend/libpq/be-secure-common.c b/src/backend/libpq/be-secure-common.c index 64fd9364b79..e6108f7641d 100644 --- a/src/backend/libpq/be-secure-common.c +++ b/src/backend/libpq/be-secure-common.c @@ -27,9 +27,9 @@ #include "libpq/libpq.h" #include "storage/fd.h" #include "utils/builtins.h" +#include "utils/config_json.h" #include "utils/guc.h" - -static HostsLine *parse_hosts_line(TokenizedAuthLine *tok_line, int elevel); +#include "utils/memutils.h" /* * Run ssl_passphrase_command @@ -183,6 +183,40 @@ check_ssl_key_file_permissions(const char *ssl_key_file, bool isServerStart) return true; } +/* + * Report a problem with a hosts line at elevel, and record it in the line, + * so that the view can show it in place. The message is translated here. + */ +static void hosts_line_error(TokenizedAuthLine *tok_line, int elevel, + const char *fmt, ...) pg_attribute_printf(3, 4); + +static void +hosts_line_error(TokenizedAuthLine *tok_line, int elevel, const char *fmt, ...) +{ + StringInfoData msg; + + fmt = _(fmt); + initStringInfo(&msg); + for (;;) + { + va_list args; + int needed; + + va_start(args, fmt); + needed = appendStringInfoVA(&msg, fmt, args); + va_end(args); + if (needed == 0) + break; + enlargeStringInfo(&msg, needed); + } + tok_line->err_msg = msg.data; + ereport(elevel, + errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg.data), + errcontext("line %d of configuration file \"%s\"", + tok_line->line_num, tok_line->file_name)); +} + /* * parse_hosts_line * @@ -190,8 +224,11 @@ check_ssl_key_file_permissions(const char *ssl_key_file, bool isServerStart) * 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. + * + * Returns NULL on error, with tok_line->err_msg set to the message. The + * result is allocated in the current memory context. */ -static HostsLine * +HostsLine * parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) { HostsLine *parsedline; @@ -217,11 +254,8 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) if ((tokens->length > 1) && (strcmp(hostname->string, "*") == 0 || strcmp(hostname->string, "/no_sni/") == 0)) { - ereport(elevel, - errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("default and non-SNI entries cannot be mixed with other entries"), - errcontext("line %d of configuration file \"%s\"", - tok_line->line_num, tok_line->file_name)); + hosts_line_error(tok_line, elevel, + "default and non-SNI entries cannot be mixed with other entries"); return NULL; } @@ -232,21 +266,15 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) 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)); + hosts_line_error(tok_line, elevel, + "missing entry at end of line"); return NULL; } tokens = lfirst(field); if (tokens->length > 1) { - ereport(elevel, - errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("multiple values specified for SSL certificate"), - errcontext("line %d of configuration file \"%s\"", - tok_line->line_num, tok_line->file_name)); + hosts_line_error(tok_line, elevel, + "multiple values specified for SSL certificate"); return NULL; } token = linitial(tokens); @@ -256,21 +284,15 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) 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)); + hosts_line_error(tok_line, elevel, + "missing entry at end of line"); return NULL; } tokens = lfirst(field); if (tokens->length > 1) { - ereport(elevel, - errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("multiple values specified for SSL key"), - errcontext("line %d of configuration file \"%s\"", - tok_line->line_num, tok_line->file_name)); + hosts_line_error(tok_line, elevel, + "multiple values specified for SSL key"); return NULL; } token = linitial(tokens); @@ -283,11 +305,8 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) tokens = lfirst(field); if (tokens->length > 1) { - ereport(elevel, - errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("multiple values specified for SSL CA"), - errcontext("line %d of configuration file \"%s\"", - tok_line->line_num, tok_line->file_name)); + hosts_line_error(tok_line, elevel, + "multiple values specified for SSL CA"); return NULL; } token = linitial(tokens); @@ -300,11 +319,8 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) tokens = lfirst(field); if (tokens->length > 1) { - ereport(elevel, - errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("multiple values specified for SSL passphrase command"), - errcontext("line %d of configuration file \"%s\"", - tok_line->line_num, tok_line->file_name)); + hosts_line_error(tok_line, elevel, + "multiple values specified for SSL passphrase command"); return NULL; } token = linitial(tokens); @@ -328,21 +344,15 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) */ if (lnext(tok_line->fields, field)) { - ereport(elevel, - errcode(ERRCODE_CONFIG_FILE_ERROR), - errmsg("extra fields at end of line"), - errcontext("line %d of configuration file \"%s\"", - tok_line->line_num, tok_line->file_name)); + hosts_line_error(tok_line, elevel, + "extra fields at end of line"); return NULL; } if (tokens->length > 1 || !parse_bool(token->string, &parsedline->ssl_passphrase_reload)) { - 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)); + hosts_line_error(tok_line, elevel, + "incorrect syntax for boolean value SSL_passphrase_cmd_reload"); return NULL; } } @@ -351,6 +361,84 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) return parsedline; } +/* + * load_hosts_json + * + * The JSON counterpart of load_hosts(): the entries come from the "hosts" + * sections of the JSON documents of hosts_file. No section anywhere is the + * same as a missing file, a section without entries the same as an empty + * one. The documents and the compiler's scratch data are temporary; the + * entries themselves are allocated in the caller's context, as the ones + * from a legacy file are. + */ +static HostsFileLoadResult +load_hosts_json(List **hosts, char **err_msg) +{ + MemoryContext oldcxt = CurrentMemoryContext; + MemoryContext jsoncxt; + List *documents; + List *entries; + ListCell *lc; + char *err; + bool found; + bool ok = true; + + jsoncxt = AllocSetContextCreate(CurrentMemoryContext, + "hosts json load context", + ALLOCSET_START_SMALL_SIZES); + MemoryContextSwitchTo(jsoncxt); + if (!config_json_load_section("hosts", HostsFileName, LOG, &documents, + &err)) + { + /* error already logged */ + MemoryContextSwitchTo(oldcxt); + if (err_msg) + *err_msg = pstrdup(err); + MemoryContextDelete(jsoncxt); + return HOSTSFILE_LOAD_FAILED; + } + MemoryContextSwitchTo(oldcxt); + + entries = parse_hosts_json(documents, LOG, &found, &err); + MemoryContextDelete(jsoncxt); + + /* + * A broken section must never pass for a missing one, as that would + * silently fall back to the ssl_* parameters; so the error comes first. + */ + if (err != NULL) + { + if (err_msg) + *err_msg = err; + return HOSTSFILE_LOAD_FAILED; + } + if (!found) + return HOSTSFILE_MISSING; + + /* entries with errors have been reported; any of them fails the load */ + foreach(lc, entries) + { + HostsLine *entry = lfirst(lc); + + if (entry->err_msg != NULL) + ok = false; + else + *hosts = lappend(*hosts, entry); + } + + if (!ok) + { + if (err_msg) + *err_msg = pstrdup(_("\"hosts\" section contains errors")); + return HOSTSFILE_LOAD_FAILED; + } + + if (*hosts == NIL) + return HOSTSFILE_EMPTY; + + return HOSTSFILE_LOAD_OK; +} + /* * load_hosts * @@ -360,6 +448,9 @@ parse_hosts_line(TokenizedAuthLine *tok_line, int elevel) * a HostsFileLoadResult value detailing the result of the operation. When * the hosts configuration failed to load, the err_msg variable may have more * information in case it was passed as non-NULL. + * + * When hosts_file names a JSON file, the entries come from its "hosts" + * sections instead; see load_hosts_json(). */ HostsFileLoadResult load_hosts(List **hosts, char **err_msg) @@ -384,6 +475,9 @@ load_hosts(List **hosts, char **err_msg) } *hosts = NIL; + if (config_json_auth_source(HostsFileName) != NULL) + return load_hosts_json(hosts, err_msg); + /* * 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/hba_json.c b/src/backend/libpq/hba_json.c index 7b87c79e2f4..859c02f1ba5 100644 --- a/src/backend/libpq/hba_json.c +++ b/src/backend/libpq/hba_json.c @@ -1,7 +1,7 @@ /*------------------------------------------------------------------------- * * hba_json.c - * Compile the JSON hba tree and ident maps. + * Compile the JSON hba tree, ident maps and hosts entries. * * The "hba" section of a JSON configuration is a tree of nodes. Each node * narrows the match (transport, ssl, gss, databases, users, address) and @@ -17,6 +17,12 @@ * Every pair becomes one (map, system user, pg user) line for * parse_ident_line(). * + * The "hosts" section is flat as well: "entries" is an object from host + * name to an object with the fields of a pg_hosts.conf line, named after + * the ssl_* parameters they stand in for. It has no classic token form to + * go through, so it is compiled straight into HostsLines; imported legacy + * lines go through parse_hosts_line() as they do for pg_hosts.conf. + * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California * @@ -41,8 +47,13 @@ * method emits nothing of its own; it only groups its children. * * Import nodes splice a legacy file in at their position. Legacy lines - * are complete rules and inherit nothing. The "import" list of the - * ident section works the same way. + * are complete rules and inherit nothing. The "import" lists of the + * ident and hosts sections work the same way. + * + * The "defaults" object of the hosts section is merged key by key across + * all documents, later documents overriding, and then applied under every + * entry's own keys. Host names must be unique across the documents and + * the imported files, compared case-insensitively as the SNI match is. * *------------------------------------------------------------------------- */ @@ -58,8 +69,8 @@ #include "utils/memutils.h" /* - * Everything the tokenize_*_json() functions allocate while compiling - * lives here. + * Everything the tokenize_*_json() functions and parse_hosts_json() + * allocate while compiling lives here. */ static MemoryContext hba_json_context = NULL; @@ -1116,6 +1127,383 @@ compile_ident_documents(List *documents, List **tok_lines, int elevel, return true; } +/* + * Keys of a hosts entry, and of "defaults": the ssl_* parameters an entry + * overrides for its host. + */ +static const char *const hosts_entry_keys[] = { + "ssl_cert_file", "ssl_key_file", "ssl_ca_file", "ssl_passphrase_command", + "ssl_passphrase_command_supports_reload", NULL +}; + +/* + * Record a per-entry error: report it at elevel, and mark the entry, so + * that the view shows it in place and the load fails. "at" is the node + * the message is about; NULL for an imported legacy line, which has a + * file position only. The message is translated here. + */ +static void hosts_entry_error(HostsLine *entry, JsonConfNode *at, + int elevel, const char *fmt, ...) + pg_attribute_printf(4, 5); + +static void +hosts_entry_error(HostsLine *entry, JsonConfNode *at, int elevel, + const char *fmt, ...) +{ + StringInfoData msg; + + fmt = _(fmt); + initStringInfo(&msg); + for (;;) + { + va_list args; + int needed; + + va_start(args, fmt); + needed = appendStringInfoVA(&msg, fmt, args); + va_end(args); + if (needed == 0) + break; + enlargeStringInfo(&msg, needed); + } + entry->err_msg = msg.data; + if (at != NULL) + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg.data), + config_json_errcontext(at))); + else + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg.data), + errcontext("line %d of configuration file \"%s\"", + entry->linenumber, entry->sourcefile))); +} + +/* + * Apply the keys of an entry or "defaults" object to "out", on top of + * whatever it holds: a key that is not given keeps its value, which is how + * defaults merge across documents and under an entry. Returns false with + * *err set and *at pointing at the offending value for an unrecognized key + * or a value of the wrong type. + */ +static bool +hosts_collect(JsonConfNode *obj, HostsLine *out, char **err, + JsonConfNode **at) +{ + ListCell *k, + *v; + + forboth(k, obj->keys, v, obj->values) + { + const char *key = lfirst(k); + JsonConfNode *val = lfirst(v); + + *at = val; + if (!string_in(key, hosts_entry_keys)) + { + *err = psprintf(_("unrecognized key \"%s\""), key); + return false; + } + + if (strcmp(key, "ssl_passphrase_command_supports_reload") == 0) + { + if (val->type != JCN_BOOL) + { + *err = psprintf(_("\"%s\" must be a boolean"), key); + return false; + } + out->ssl_passphrase_reload = val->boolval; + } + else + { + char **field; + + if (val->type != JCN_STRING) + { + *err = psprintf(_("\"%s\" must be a string"), key); + return false; + } + if (strcmp(key, "ssl_cert_file") == 0) + field = &out->ssl_cert; + else if (strcmp(key, "ssl_key_file") == 0) + field = &out->ssl_key; + else if (strcmp(key, "ssl_ca_file") == 0) + field = &out->ssl_ca; + else + field = &out->ssl_passphrase_cmd; + *field = pstrdup(val->strval); + } + } + return true; +} + +/* + * Compile one key of "entries" into an entry. The entry starts out as a + * copy of the merged defaults, its own keys go on top, and then the + * certificate and key must be there. The entry is returned even on + * error, with err_msg set. + */ +static HostsLine * +compile_hosts_entry(const char *hostname, JsonConfNode *node, + const HostsLine *defaults, int elevel) +{ + HostsLine *entry = palloc0_object(HostsLine); + char *err = NULL; + JsonConfNode *at = NULL; + + entry->sourcefile = pstrdup(node->file); + entry->linenumber = node->line; + entry->node_path = config_json_path(node); + entry->rawline = pstrdup(entry->node_path); + entry->hostnames = list_make1(pstrdup(hostname)); + + if (hostname[0] == '\0') + { + hosts_entry_error(entry, node, elevel, "host name cannot be empty"); + return entry; + } + if (node->type != JCN_OBJECT) + { + hosts_entry_error(entry, node, elevel, + "hosts entry must be an object, not %s", + config_json_type_name(node->type)); + return entry; + } + + /* the defaults, then the entry's own keys on top */ + entry->ssl_cert = defaults->ssl_cert; + entry->ssl_key = defaults->ssl_key; + entry->ssl_ca = defaults->ssl_ca; + entry->ssl_passphrase_cmd = defaults->ssl_passphrase_cmd; + entry->ssl_passphrase_reload = defaults->ssl_passphrase_reload; + if (!hosts_collect(node, entry, &err, &at)) + { + hosts_entry_error(entry, at, elevel, "%s", err); + return entry; + } + + if (entry->ssl_cert == NULL || entry->ssl_key == NULL) + { + hosts_entry_error(entry, node, elevel, + "missing required key \"%s\"", + entry->ssl_cert == NULL ? + "ssl_cert_file" : "ssl_key_file"); + return entry; + } + + return entry; +} + +/* + * The entry for an imported legacy line: parse_hosts_line()'s result, or + * for a line that did not tokenize or parse, a placeholder that carries + * the message and whatever host names the line did have, for the view. + */ +static HostsLine * +import_hosts_line(TokenizedAuthLine *tok, int elevel) +{ + HostsLine *entry; + + if (tok->err_msg == NULL && + (entry = parse_hosts_line(tok, elevel)) != NULL) + return entry; + + entry = palloc0_object(HostsLine); + entry->sourcefile = pstrdup(tok->file_name); + entry->linenumber = tok->line_num; + entry->rawline = pstrdup(tok->raw_line); + if (tok->node_path != NULL) + entry->node_path = pstrdup(tok->node_path); + entry->err_msg = pstrdup(tok->err_msg); + if (tok->fields != NIL) + { + ListCell *lc; + + foreach(lc, (List *) linitial(tok->fields)) + entry->hostnames = lappend(entry->hostnames, + pstrdup(((AuthToken *) lfirst(lc))->string)); + } + return entry; +} + +/* + * Append an entry to the list, after checking its host names against + * those seen so far. A repeated name marks the entry, unless it already + * has an error, which is reported first; its names still count as seen, + * since fixing the entry would not make the duplicate go away. "at" is + * the entry's node, or NULL for an imported line. + */ +static void +add_hosts_entry(HostsLine *entry, JsonConfNode *at, List **entries, + List **seen, int elevel) +{ + ListCell *lc; + + foreach(lc, entry->hostnames) + { + const char *hostname = lfirst(lc); + ListCell *s; + bool dup = false; + + foreach(s, *seen) + { + if (pg_strcasecmp(lfirst(s), hostname) == 0) + { + dup = true; + break; + } + } + if (dup && entry->err_msg == NULL) + hosts_entry_error(entry, at, elevel, + "duplicate hosts entry \"%s\"", hostname); + *seen = lappend(*seen, (char *) hostname); + } + *entries = lappend(*entries, entry); +} + +/* + * Check the shape of one document's "hosts" section. Returns the section, + * NULL if the document has none, or sets *file_err and returns NULL with + * *ok cleared. + */ +static JsonConfNode * +hosts_section(JsonConfNode *doc, int elevel, bool *ok, char **file_err) +{ + static const char *const keys[] = {"import", "defaults", "entries", NULL}; + JsonConfNode *hosts = config_json_get(doc, "hosts"); + JsonConfNode *val; + ListCell *k, + *v; + + if (hosts == NULL) + return NULL; + if (hosts->type != JCN_OBJECT) + { + *ok = section_shape_error(hosts, elevel, file_err, + "\"%s\" must be an object", "hosts"); + return NULL; + } + forboth(k, hosts->keys, v, hosts->values) + { + if (!string_in(lfirst(k), keys)) + { + *ok = section_shape_error(lfirst(v), elevel, file_err, + "unrecognized key \"%s\"", + (char *) lfirst(k)); + return NULL; + } + } + if ((val = config_json_get(hosts, "defaults")) != NULL && + val->type != JCN_OBJECT) + { + *ok = section_shape_error(val, elevel, file_err, + "\"%s\" must be an object", "defaults"); + return NULL; + } + if ((val = config_json_get(hosts, "entries")) != NULL && + val->type != JCN_OBJECT) + { + *ok = section_shape_error(val, elevel, file_err, + "\"%s\" must be an object", "entries"); + return NULL; + } + return hosts; +} + +/* + * The "hosts" section of every document. The defaults of all documents + * are merged first, since an entry in an early document may rely on a + * default set by a later one; then come each document's imports and + * entries, in load order. + */ +static bool +compile_hosts_documents(List *documents, List **entries, int elevel, + char **file_err) +{ + HostsLine defaults = {0}; + List *seen = NIL; + ListCell *lc; + bool ok = true; + + foreach(lc, documents) + { + JsonConfNode *hosts = hosts_section(lfirst(lc), elevel, &ok, file_err); + JsonConfNode *defs; + char *err; + JsonConfNode *at; + + if (!ok) + return false; + if (hosts == NULL) + continue; + + /* a bad default would spoil every entry, so it is a file-level error */ + defs = config_json_get(hosts, "defaults"); + if (defs != NULL && !hosts_collect(defs, &defaults, &err, &at)) + return section_shape_error(at, elevel, file_err, "%s", err); + } + + foreach(lc, documents) + { + JsonConfNode *hosts = config_json_get(lfirst(lc), "hosts"); + JsonConfNode *imports, + *ents; + List *tok_lines = NIL; + ListCell *k, + *v; + + if (hosts == NULL) + continue; + + imports = config_json_get(hosts, "import"); + if (imports != NULL && + !import_auth_lines(imports, &tok_lines, elevel, file_err)) + return false; + foreach(k, tok_lines) + add_hosts_entry(import_hosts_line(lfirst(k), elevel), NULL, + entries, &seen, elevel); + + ents = config_json_get(hosts, "entries"); + if (ents == NULL) + continue; + forboth(k, ents->keys, v, ents->values) + add_hosts_entry(compile_hosts_entry(lfirst(k), lfirst(v), + &defaults, elevel), + lfirst(v), entries, &seen, elevel); + } + return true; +} + +/* A copy of an entry and everything it points to, in the current context. */ +static HostsLine * +copy_hosts_entry(const HostsLine *src) +{ + HostsLine *dst = palloc0_object(HostsLine); + ListCell *lc; + + dst->linenumber = src->linenumber; + dst->sourcefile = pstrdup(src->sourcefile); + if (src->rawline != NULL) + dst->rawline = pstrdup(src->rawline); + foreach(lc, src->hostnames) + dst->hostnames = lappend(dst->hostnames, pstrdup(lfirst(lc))); + if (src->ssl_cert != NULL) + dst->ssl_cert = pstrdup(src->ssl_cert); + if (src->ssl_key != NULL) + dst->ssl_key = pstrdup(src->ssl_key); + if (src->ssl_ca != NULL) + dst->ssl_ca = pstrdup(src->ssl_ca); + if (src->ssl_passphrase_cmd != NULL) + dst->ssl_passphrase_cmd = pstrdup(src->ssl_passphrase_cmd); + dst->ssl_passphrase_reload = src->ssl_passphrase_reload; + if (src->node_path != NULL) + dst->node_path = pstrdup(src->node_path); + if (src->err_msg != NULL) + dst->err_msg = pstrdup(src->err_msg); + return dst; +} + /* * Set up the contexts a section compiler needs, and switch to the one its * output goes to. Returns the caller's context; free_json_section_tokens() @@ -1201,6 +1589,57 @@ tokenize_ident_json(List *documents, List **tok_lines, int elevel, tok_lines, elevel, file_err); } +/* + * parse_hosts_json + * Compile the "hosts" sections of the given documents into entries. + * + * Unlike the other sections there is no token form for a caller to parse, + * so the result is final: a list of HostsLines, in the order the SNI + * match tries them, with the entries that have a problem included and + * marked by err_msg. *found tells whether any document has a "hosts" key + * at all, whatever its shape, which load_hosts() distinguishes from a + * section without entries. Returns NIL with *file_err set for file-level + * problems (a section, "defaults" or "entries" of the wrong shape, a + * malformed import list); *found is set in that case too, so that a + * caller checking it first cannot mistake a broken section for a missing + * one. + * + * The entries, and *file_err, are allocated in the caller's context; the + * documents may be freed afterwards, as everything else this needed is + * released before returning. + */ +List * +parse_hosts_json(List *documents, int elevel, bool *found, char **file_err) +{ + MemoryContext oldcxt; + List *entries = NIL; + List *result = NIL; + ListCell *lc; + + *file_err = NULL; + + /* decided before compiling, so that no error path can leave it unset */ + *found = false; + foreach(lc, documents) + { + if (config_json_get(lfirst(lc), "hosts") != NULL) + *found = true; + } + + oldcxt = json_section_begin(); + if (!compile_hosts_documents(documents, &entries, elevel, file_err)) + entries = NIL; + MemoryContextSwitchTo(oldcxt); + + if (*file_err != NULL) + *file_err = pstrdup(*file_err); + foreach(lc, entries) + result = lappend(result, copy_hosts_entry(lfirst(lc))); + + free_json_section_tokens(); + return result; +} + /* * Release what tokenize_hba_json() or tokenize_ident_json() allocated, * including the tokenizer context the imports used. diff --git a/src/backend/nls.mk b/src/backend/nls.mk index 26578061db9..f90012111bf 100644 --- a/src/backend/nls.mk +++ b/src/backend/nls.mk @@ -19,7 +19,9 @@ GETTEXT_TRIGGERS = $(BACKEND_COMMON_GETTEXT_TRIGGERS) \ error_cb:2 \ json_setting_error:5 \ hba_node_error:5 \ - section_shape_error:4 + section_shape_error:4 \ + hosts_entry_error:4 \ + hosts_line_error:3 GETTEXT_FLAGS = $(BACKEND_COMMON_GETTEXT_FLAGS) \ GUC_check_errmsg:1:c-format \ GUC_check_errdetail:1:c-format \ @@ -31,7 +33,9 @@ GETTEXT_FLAGS = $(BACKEND_COMMON_GETTEXT_FLAGS) \ error_cb:2:c-format \ json_setting_error:5:c-format \ hba_node_error:5:c-format \ - section_shape_error:4:c-format + section_shape_error:4:c-format \ + hosts_entry_error:4:c-format \ + hosts_line_error:3:c-format gettext-files: generated-parser-sources generated-headers find $(srcdir) $(srcdir)/../common $(srcdir)/../port $(srcdir)/../include/ \( -name '*.c' -o -name "proctypelist.h" \) -print | LC_ALL=C sort >$@ diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c index 85b646aa5fc..a588fe53ea5 100644 --- a/src/backend/utils/adt/hbafuncs.c +++ b/src/backend/utils/adt/hbafuncs.c @@ -14,11 +14,14 @@ */ #include "postgres.h" +#include + #include "access/htup_details.h" #include "catalog/objectaddress.h" #include "common/ip.h" #include "funcapi.h" #include "libpq/hba.h" +#include "libpq/libpq.h" #include "utils/array.h" #include "utils/builtins.h" #include "utils/config_json.h" @@ -36,6 +39,11 @@ static void fill_ident_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, int map_number, char *filename, int lineno, IdentLine *ident, const char *err_msg); static void fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc); +static void fill_hosts_entry(Tuplestorestate *tuple_store, TupleDesc tupdesc, + int entry_number, const char *filename, + int lineno, const char *hostname, + HostsLine *host, const char *err_msg); +static void fill_hosts_view(Tuplestorestate *tuple_store, TupleDesc tupdesc); /* @@ -673,3 +681,254 @@ pg_ident_file_mappings(PG_FUNCTION_ARGS) PG_RETURN_NULL(); } + +/* Number of columns in pg_hosts_file_entries view */ +#define NUM_PG_HOSTS_FILE_ENTRIES_ATTS 10 + +/* + * fill_hosts_entry + * Build one row of pg_hosts_file_entries view, add it to tuplestore. + * + * tuple_store: where to store data + * tupdesc: tuple descriptor for the view + * entry_number: unique identifier among all valid entries + * filename: configuration file name (must always be valid) + * lineno: line number of configuration file (must always be valid) + * hostname: the host name this row is for (NULL if none could be read) + * host: parsed entry data (can be NULL, in which case err_msg should be set) + * err_msg: error message (NULL if none) + * + * A legacy line with several host names gives one row per name, all with + * the same entry_number. With an error, only the host name is shown. + * + * Note: leaks memory, but we don't care since this is run in a short-lived + * memory context. + */ +static void +fill_hosts_entry(Tuplestorestate *tuple_store, TupleDesc tupdesc, + int entry_number, const char *filename, int lineno, + const char *hostname, HostsLine *host, const char *err_msg) +{ + Datum values[NUM_PG_HOSTS_FILE_ENTRIES_ATTS]; + bool nulls[NUM_PG_HOSTS_FILE_ENTRIES_ATTS]; + HeapTuple tuple; + int index; + + Assert(tupdesc->natts == NUM_PG_HOSTS_FILE_ENTRIES_ATTS); + + memset(values, 0, sizeof(values)); + memset(nulls, 0, sizeof(nulls)); + index = 0; + + /* entry_number, nothing on error */ + if (err_msg) + nulls[index++] = true; + else + values[index++] = Int32GetDatum(entry_number); + + /* file_name */ + values[index++] = CStringGetTextDatum(filename); + + /* line_number */ + values[index++] = Int32GetDatum(lineno); + + /* hostname */ + if (hostname) + values[index++] = CStringGetTextDatum(hostname); + else + nulls[index++] = true; + + if (host != NULL && err_msg == NULL) + { + values[index++] = CStringGetTextDatum(host->ssl_cert); + values[index++] = CStringGetTextDatum(host->ssl_key); + if (host->ssl_ca) + values[index++] = CStringGetTextDatum(host->ssl_ca); + else + nulls[index++] = true; + if (host->ssl_passphrase_cmd) + values[index++] = CStringGetTextDatum(host->ssl_passphrase_cmd); + else + nulls[index++] = true; + values[index++] = BoolGetDatum(host->ssl_passphrase_reload); + } + else + { + /* no parsing result, so set the fields derived from it to nulls */ + memset(&nulls[index], true, + (NUM_PG_HOSTS_FILE_ENTRIES_ATTS - index - 1) * sizeof(bool)); + index = NUM_PG_HOSTS_FILE_ENTRIES_ATTS - 1; + } + + /* error */ + Assert(index == NUM_PG_HOSTS_FILE_ENTRIES_ATTS - 1); + if (err_msg) + values[index] = CStringGetTextDatum(err_msg); + else + nulls[index] = true; + + tuple = heap_form_tuple(tupdesc, values, nulls); + tuplestore_puttuple(tuple_store, tuple); +} + +/* + * One row per host name of an entry; a problem that left no host name, + * such as an import file that could not be read, gets one row without. + */ +static void +fill_hosts_entry_rows(Tuplestorestate *tuple_store, TupleDesc tupdesc, + int entry_number, const char *filename, int lineno, + List *hostnames, HostsLine *host, const char *err_msg) +{ + ListCell *lc; + + if (hostnames == NIL) + { + fill_hosts_entry(tuple_store, tupdesc, entry_number, filename, lineno, + NULL, host, err_msg); + return; + } + foreach(lc, hostnames) + fill_hosts_entry(tuple_store, tupdesc, entry_number, filename, lineno, + lfirst(lc), host, err_msg); +} + +/* + * fill_hosts_view + * Read the SNI hosts configuration and fill the tuplestore with view + * records. + * + * The entries come from hosts_file, in either format. + */ +static void +fill_hosts_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) +{ + int entry_number = 0; + ListCell *lc; + MemoryContext hostscxt; + MemoryContext oldcxt; + + /* Everything parsed here is short-lived */ + hostscxt = AllocSetContextCreate(CurrentMemoryContext, + "hosts parser context", + ALLOCSET_SMALL_SIZES); + oldcxt = MemoryContextSwitchTo(hostscxt); + + /* + * As for the other views, a file that cannot be loaded at all is an error + * rather than a row; the problems of individual entries are rows. + */ + if (config_json_auth_source(HostsFileName) != NULL) + { + List *documents; + List *entries; + char *file_err; + bool found; + + if (!config_json_load_section("hosts", HostsFileName, DEBUG3, + &documents, &file_err)) + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load configuration file \"%s\"", + HostsFileName), + errdetail_internal("%s", file_err))); + entries = parse_hosts_json(documents, DEBUG3, &found, &file_err); + if (file_err != NULL) + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load configuration file \"%s\"", + HostsFileName), + errdetail_internal("%s", file_err))); + + foreach(lc, entries) + { + HostsLine *host = lfirst(lc); + + /* No error, set a new entry number */ + if (host->err_msg == NULL) + entry_number++; + + fill_hosts_entry_rows(tuple_store, tupdesc, entry_number, + host->sourcefile, host->linenumber, + host->hostnames, host, host->err_msg); + } + } + else + { + struct stat st; + FILE *file; + List *hosts_lines = NIL; + + /* a missing file is an empty one, as load_hosts() treats it */ + if (stat(HostsFileName, &st) != 0 && errno == ENOENT) + { + MemoryContextSwitchTo(oldcxt); + MemoryContextDelete(hostscxt); + return; + } + + file = open_auth_file(HostsFileName, ERROR, 0, NULL); + tokenize_auth_file(HostsFileName, file, &hosts_lines, DEBUG3, 0); + + foreach(lc, hosts_lines) + { + TokenizedAuthLine *tok_line = (TokenizedAuthLine *) lfirst(lc); + HostsLine *host = NULL; + List *hostnames = NIL; + + /* don't parse lines that already have errors */ + if (tok_line->err_msg == NULL) + host = parse_hosts_line(tok_line, DEBUG3); + + /* No error, set a new entry number */ + if (tok_line->err_msg == NULL) + entry_number++; + + /* + * For a line that did not parse, show the host names it did have, + * from the first field. + */ + if (host != NULL) + hostnames = host->hostnames; + else if (tok_line->fields != NIL) + { + ListCell *t; + + foreach(t, (List *) linitial(tok_line->fields)) + hostnames = lappend(hostnames, + ((AuthToken *) lfirst(t))->string); + } + + fill_hosts_entry_rows(tuple_store, tupdesc, entry_number, + tok_line->file_name, tok_line->line_num, + hostnames, host, tok_line->err_msg); + } + + /* Free tokenizer memory */ + free_auth_file(file, 0); + } + + MemoryContextSwitchTo(oldcxt); + MemoryContextDelete(hostscxt); +} + +/* + * SQL-accessible SRF to return all the entries in the pg_hosts.conf file. + */ +Datum +pg_hosts_file_entries(PG_FUNCTION_ARGS) +{ + ReturnSetInfo *rsi; + + /* + * Build tuplestore to hold the result rows. We must use the Materialize + * mode to be safe against file changes while the cursor is open. + */ + InitMaterializedSRF(fcinfo, 0); + + /* Fill the tuplestore */ + rsi = (ReturnSetInfo *) fcinfo->resultinfo; + fill_hosts_view(rsi->setResult, rsi->setDesc); + + PG_RETURN_NULL(); +} diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index fa56ec23d5e..e2aed4fa027 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -6683,6 +6683,13 @@ proargmodes => '{o,o,o,o,o,o,o}', proargnames => '{map_number,file_name,line_number,map_name,sys_name,pg_username,error}', prosrc => 'pg_ident_file_mappings', proacl => '{POSTGRES=X}' }, +{ oid => '8801', descr => 'show SNI host entries', + proname => 'pg_hosts_file_entries', prorows => '100', proretset => 't', + provolatile => 'v', prorettype => 'record', proargtypes => '', + proallargtypes => '{int4,text,int4,text,text,text,text,text,bool,text}', + proargmodes => '{o,o,o,o,o,o,o,o,o,o}', + proargnames => '{entry_number,file_name,line_number,hostname,ssl_cert_file,ssl_key_file,ssl_ca_file,ssl_passphrase_command,ssl_passphrase_command_supports_reload,error}', + prosrc => 'pg_hosts_file_entries', proacl => '{POSTGRES=X}' }, { oid => '1371', descr => 'view system lock information', proname => 'pg_lock_status', prorows => '1000', proretset => 't', provolatile => 'v', prorettype => 'record', proargtypes => '', diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h index 42870bbe602..cf77b8447a0 100644 --- a/src/include/libpq/hba.h +++ b/src/include/libpq/hba.h @@ -163,6 +163,10 @@ typedef struct HostsLine char *ssl_passphrase_cmd; bool ssl_passphrase_reload; + char *node_path; /* JSON node this entry was compiled from, or + * NULL for a legacy line */ + char *err_msg; /* why the entry is unusable, or NULL */ + /* Internal bookkeeping */ void *ssl_ctx; /* associated SSL_CTX* for the above settings */ } HostsLine; @@ -199,6 +203,7 @@ extern int check_usermap(const char *usermap_name, bool case_insensitive); extern HbaLine *parse_hba_line(TokenizedAuthLine *tok_line, int elevel); extern IdentLine *parse_ident_line(TokenizedAuthLine *tok_line, int elevel); +extern HostsLine *parse_hosts_line(TokenizedAuthLine *tok_line, int elevel); extern FILE *open_auth_file(const char *filename, int elevel, int depth, char **err_msg); extern void free_auth_file(FILE *file, int depth); @@ -217,6 +222,8 @@ extern bool tokenize_hba_json(List *documents, List **tok_lines, int elevel, char **file_err); extern bool tokenize_ident_json(List *documents, List **tok_lines, int elevel, char **file_err); +extern List *parse_hosts_json(List *documents, int elevel, bool *found, + char **file_err); extern void free_json_section_tokens(void); extern bool import_auth_lines(struct JsonConfNode *imports, List **tok_lines, int elevel, char **file_err); diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 3d58f41a52c..935406548cf 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -1374,6 +1374,17 @@ pg_hba_file_rules| SELECT rule_number, node_path, error FROM pg_hba_file_rules() a(rule_number, file_name, line_number, type, database, user_name, address, netmask, auth_method, options, node_path, error); +pg_hosts_file_entries| SELECT entry_number, + file_name, + line_number, + hostname, + ssl_cert_file, + ssl_key_file, + ssl_ca_file, + ssl_passphrase_command, + ssl_passphrase_command_supports_reload, + error + FROM pg_hosts_file_entries() a(entry_number, file_name, line_number, hostname, ssl_cert_file, ssl_key_file, ssl_ca_file, ssl_passphrase_command, ssl_passphrase_command_supports_reload, error); pg_ident_file_mappings| SELECT map_number, file_name, line_number, diff --git a/src/test/regress/expected/sysviews.out b/src/test/regress/expected/sysviews.out index 1e327c2afa4..a3dd2f97f4e 100644 --- a/src/test/regress/expected/sysviews.out +++ b/src/test/regress/expected/sysviews.out @@ -101,6 +101,14 @@ select count(*) >= 0 as ok, count(*) FILTER (WHERE error IS NOT NULL) = 0 AS no_ t | t (1 row) +-- There may be no entries, and there should be no errors. +select count(*) >= 0 as ok, count(*) FILTER (WHERE error IS NOT NULL) = 0 AS no_err + from pg_hosts_file_entries; + ok | no_err +----+-------- + t | t +(1 row) + -- There will surely be at least one active lock select count(*) > 0 as ok from pg_locks; ok diff --git a/src/test/regress/sql/sysviews.sql b/src/test/regress/sql/sysviews.sql index 507e400ad4a..1dd32d064ae 100644 --- a/src/test/regress/sql/sysviews.sql +++ b/src/test/regress/sql/sysviews.sql @@ -58,6 +58,10 @@ select count(*) > 0 as ok, count(*) FILTER (WHERE error IS NOT NULL) = 0 AS no_e select count(*) >= 0 as ok, count(*) FILTER (WHERE error IS NOT NULL) = 0 AS no_err from pg_ident_file_mappings; +-- There may be no entries, and there should be no errors. +select count(*) >= 0 as ok, count(*) FILTER (WHERE error IS NOT NULL) = 0 AS no_err + from pg_hosts_file_entries; + -- There will surely be at least one active lock select count(*) > 0 as ok from pg_locks; diff --git a/src/test/ssl/meson.build b/src/test/ssl/meson.build index d7e7ce23433..a90da513834 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_json_hosts.pl', ], }, } diff --git a/src/test/ssl/t/005_json_hosts.pl b/src/test/ssl/t/005_json_hosts.pl new file mode 100644 index 00000000000..4aa484e9d00 --- /dev/null +++ b/src/test/ssl/t/005_json_hosts.pl @@ -0,0 +1,342 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test the JSON format of the SNI hosts configuration in the file named +# by hosts_file, and the pg_hosts_file_entries view for both formats. + +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 hostaddr 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(); + +if ($ssl_server->is_libressl) +{ + plan skip_all => 'SNI not supported when building with LibreSSL'; +} + +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 = + "user=ssltestuser dbname=trustdb hostaddr=$SERVERHOSTADDR sslsni=1"; + +# pg_hba.conf only admits SSL connections, so the view is queried over one, +# as the (superuser) OS user. The default entry serves the same certificate +# throughout, so no SNI name is needed. +my $query_connstr = "dbname=trustdb hostaddr=$SERVERHOSTADDR sslsni=0 " + . "sslmode=require sslrootcert=ssl/root+server_ca.crt"; + +sub view_query +{ + my ($sql) = @_; + return $node->safe_psql('trustdb', $sql, connstr => $query_connstr); +} + +# With EXEC_BACKEND, each new backend loads the SSL configuration from the +# files itself, and goes without SSL while they are invalid. Checks made +# while a file is broken use a session opened beforehand. +my $exec_backend = view_query('SHOW debug_exec_backend'); +my $session; + +sub session_query +{ + my ($sql) = @_; + $session->{stderr} = ''; + my $res = $session->query($sql); + return ($res, $session->{stderr}); +} + +my $datadir = $node->data_dir; +my $hosts_json = "$datadir/pg_hosts.json"; + +sub write_file +{ + my ($path, $content) = @_; + open my $fh, '>', $path or die "cannot write $path: $!"; + print $fh $content; + close $fh; +} + +# $hosts is the JSON text of the "hosts" value; @extra are further +# include entries (already JSON-quoted). +sub set_hosts +{ + my ($hosts, @extra) = @_; + my $includes = join(', ', @extra); + write_file($hosts_json, + qq({"include": [$includes],\n "hosts": $hosts}\n)); +} + +############################################################################## +# Legacy pg_hosts.conf in the view +############################################################################## + +# The view reads the file itself, so no reload is needed. +$node->append_conf('pg_hosts.conf', + "* server-cn-only.crt server-cn-only.key"); +$node->append_conf('pg_hosts.conf', + "example.org,example.net server-cn-only.crt server-cn-only.key"); +$node->append_conf('pg_hosts.conf', "bad.example.org server-cn-only.crt"); + +my $res = view_query( + q{ + SELECT entry_number, hostname, ssl_key_file, error + FROM pg_hosts_file_entries ORDER BY line_number, hostname}); +is( $res, + qq{1|*|server-cn-only.key|\n} + . qq{2|example.net|server-cn-only.key|\n} + . qq{2|example.org|server-cn-only.key|\n} + . qq{|bad.example.org||missing entry at end of line}, + 'pg_hosts_file_entries works for the legacy format'); +ok(unlink("$datadir/pg_hosts.conf"), 'legacy hosts file removed'); + +############################################################################## +# JSON hosts file +############################################################################## + +$node->append_conf('postgresql.conf', <<"EOF"); +ssl_sni = on +hosts_file = '$hosts_json' +EOF + +# example.org gets its CA from the defaults, the default entry overrides +# the certificate, the no-SNI entry takes everything from the defaults. +# example.net and example.com come from an imported legacy file that reads +# its host names from an @file. +write_file("$datadir/hosts_extra.conf", + "\@hostnames.txt server-cn-only+server_ca.crt server-cn-only.key\n"); +write_file("$datadir/hostnames.txt", "example.net\nexample.com\n"); + +my $hosts = <<'EOF'; +{"import": ["hosts_extra.conf"], + "defaults": {"ssl_ca_file": "root_ca.crt", + "ssl_cert_file": "server-cn-only.crt", + "ssl_key_file": "server-cn-only.key"}, + "entries": { + "example.org": {"ssl_cert_file": "server-cn-only+server_ca.crt"}, + "*": {"ssl_cert_file": "server-cn-only.crt"}, + "/no_sni/": {} + }} +EOF +set_hosts($hosts); +$node->restart; + +$node->connect_ok( + "$connstr host=example.org sslrootcert=ssl/root_ca.crt sslmode=verify-ca", + 'per-host certificate selected from JSON entries'); +$node->connect_ok( + "$connstr host=example.net sslrootcert=ssl/root_ca.crt sslmode=verify-ca", + 'certificate of an imported legacy entry selected'); +$node->connect_ok( + "$connstr host=example.com sslrootcert=ssl/root_ca.crt sslmode=verify-ca", + 'certificate of an imported @file host name selected'); +$node->connect_ok( + "$connstr sslsni=0 sslrootcert=ssl/root+server_ca.crt sslmode=require", + 'no-SNI entry built entirely from defaults selected'); +$node->connect_ok( + "$connstr host=other.example.org sslrootcert=ssl/root+server_ca.crt " + . "sslmode=require", + 'default entry used without matching name'); +$node->connect_fails( + "$connstr host=other.example.org sslrootcert=ssl/root_ca.crt " + . "sslmode=verify-ca", + 'default entry has no intermediate CA', + expected_stderr => qr/certificate verify failed/); + +$res = view_query( + q{ + SELECT entry_number, regexp_replace(file_name, '.*/', ''), hostname, + regexp_replace(ssl_cert_file, '.*/', ''), ssl_ca_file, + ssl_passphrase_command_supports_reload, error + FROM pg_hosts_file_entries ORDER BY entry_number, hostname}); +is( $res, + qq{1|hosts_extra.conf|example.com|server-cn-only+server_ca.crt||f|\n} + . qq{1|hosts_extra.conf|example.net|server-cn-only+server_ca.crt||f|\n} + . qq{2|pg_hosts.json|example.org|server-cn-only+server_ca.crt|} + . qq{root_ca.crt|f|\n} + . qq{3|pg_hosts.json|*|server-cn-only.crt|root_ca.crt|f|\n} + . qq{4|pg_hosts.json|/no_sni/|server-cn-only.crt|root_ca.crt|f|}, + 'pg_hosts_file_entries lists JSON entries with defaults applied'); + +############################################################################## +# Duplicates and other errors +############################################################################## + +# A duplicate hostname across an include is an error, the old +# configuration stays active. Matching is case-insensitive. +mkdir "$datadir/conf.d"; +$session = $node->background_psql( + 'trustdb', + connstr => $query_connstr, + on_error_stop => 0); +write_file( + "$datadir/conf.d/dup.json", + qq({"hosts": {"entries": {"EXAMPLE.org": ) + . qq({"ssl_cert_file": "server-cn-only.crt", ) + . qq("ssl_key_file": "server-cn-only.key"}}}}\n)); +set_hosts($hosts, '"conf.d/dup.json"'); +my $log_offset = -s $node->logfile; +$node->reload; +$node->wait_for_log(qr/duplicate hosts entry "EXAMPLE.org"/, $log_offset); +SKIP: +{ + skip 'new backends load the SSL configuration with EXEC_BACKEND', 2 + if $exec_backend eq 'on'; + + $node->connect_ok( + "$connstr host=example.org sslrootcert=ssl/root_ca.crt " + . "sslmode=verify-ca", + 'old hosts configuration kept after failed reload'); +} +($res) = session_query( + q{SELECT hostname, error FROM pg_hosts_file_entries + WHERE error IS NOT NULL}); +is($res, 'EXAMPLE.org|duplicate hosts entry "EXAMPLE.org"', + 'duplicate shown in view'); + +# A duplicate of an imported legacy hostname is caught too. +write_file( + "$datadir/conf.d/dup.json", + qq({"hosts": {"entries": {"example.net": ) + . qq({"ssl_cert_file": "server-cn-only.crt", ) + . qq("ssl_key_file": "server-cn-only.key"}}}}\n)); +($res) = session_query( + q{SELECT hostname, error FROM pg_hosts_file_entries + WHERE error IS NOT NULL}); +is($res, 'example.net|duplicate hosts entry "example.net"', + 'duplicate of an imported hostname shown in view'); + +# Per-entry problems: a non-object entry, an unrecognized key, and a +# wrong type. +write_file( + "$datadir/conf.d/dup.json", + qq({"hosts": {"entries": {\n) + . qq( "a.example.org": "server-cn-only.crt",\n) + . qq( "b.example.org": {"ssl_cert_file": "server-cn-only.crt", ) + . qq("ssl_key_file": "server-cn-only.key", "ssl_crl_file": "x"},\n) + . qq( "c.example.org": {"ssl_cert_file": "server-cn-only.crt", ) + . qq("ssl_key_file": 1}}}}\n)); +($res) = session_query( + q{SELECT hostname, error FROM pg_hosts_file_entries + WHERE error IS NOT NULL ORDER BY hostname}); +is( $res, + qq{a.example.org|hosts entry must be an object, not string\n} + . qq{b.example.org|unrecognized key "ssl_crl_file"\n} + . qq{c.example.org|"ssl_key_file" must be a string}, + 'per-entry errors shown in view'); + +# A section of the wrong shape is a file-level error, as is a key the +# file cannot hold. +write_file("$datadir/conf.d/dup.json", + qq({"hosts": {"entries": {}, "rules": []}}\n)); +my (undef, $stderr) = + session_query('SELECT * FROM pg_hosts_file_entries'); +like( + $stderr, + qr/unrecognized key "rules" \(node \$\.hosts\.rules,/, + 'section shape error reported by the view'); +write_file("$datadir/conf.d/dup.json", + qq({"hosts": {"entries": {}}, "hba": {"children": []}}\n)); +(undef, $stderr) = session_query('SELECT * FROM pg_hosts_file_entries'); +like( + $stderr, + qr/unrecognized key "hba" \(node \$\.hba,/, + 'other section in a hosts file reported by the view'); +$session->quit; + +# A malformed section, with no other hosts section anywhere, must not +# pass for a missing one: the reload keeps the per-host certificate +# rather than falling back to the ssl_* parameters, and the server does +# not start on it. +unlink "$datadir/conf.d/dup.json"; +write_file($hosts_json, qq({"hosts": []}\n)); +$log_offset = -s $node->logfile; +$node->reload; +$node->wait_for_log(qr/could not load file .*"hosts" must be an object/, + $log_offset); +SKIP: +{ + skip 'new backends load the SSL configuration with EXEC_BACKEND', 2 + if $exec_backend eq 'on'; + + $node->connect_ok( + "$connstr host=example.org sslrootcert=ssl/root_ca.crt " + . "sslmode=verify-ca", + 'per-host certificate kept after a reload with a malformed section'); +} + +$node->stop; +$log_offset = -s $node->logfile; +my $ret = $node->start(fail_ok => 1); +is($ret, 0, 'start fails with a malformed hosts section'); +my $log = PostgreSQL::Test::Utils::slurp_file($node->logfile, $log_offset); +like( + $log, + qr/"hosts" must be an object/, + 'startup failure names the problem'); + +# Without a hosts section anywhere, the ssl_* parameters are used. +write_file($hosts_json, "{}\n"); +$node->start; +$session = $node->background_psql( + 'trustdb', + connstr => $query_connstr, + on_error_stop => 0); + +# Without defaults, an entry must name its key itself. The view reads +# the file as it is, so the running configuration stays as it was. +write_file( + $hosts_json, + qq({"hosts": {"entries": {"a.example.org": ) + . qq({"ssl_cert_file": "server-cn-only.crt"}}}}\n)); +($res) = session_query( + q{SELECT hostname, error FROM pg_hosts_file_entries}); +is($res, 'a.example.org|missing required key "ssl_key_file"', + 'missing required key shown in view'); +$session->quit; +write_file($hosts_json, "{}\n"); +$node->connect_ok( + "$connstr host=example.org sslrootcert=ssl/root+server_ca.crt " + . "sslmode=require", + 'no hosts section falls back to the ssl_* parameters'); +$res = view_query('SELECT count(*) FROM pg_hosts_file_entries'); +is($res, '0', 'view is empty without a hosts section'); + +done_testing(); -- 2.56.0