From: Lennart Poettering Date: Thu, 26 Feb 2026 14:54:14 +0000 (+0100) Subject: resolved: add ability to define additional local RRs via drop-ins X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=0718a21c13ef6402fd153835781d13b2f916653d;p=thirdparty%2Fsystemd.git resolved: add ability to define additional local RRs via drop-ins This is an extension of the /etc/hosts concept, but can provide any kind of RRs (well, actually, we only parse A/AAAA/PTR for now, but the concept is open for more). Fixes: #17791 --- diff --git a/man/resolved.conf.xml b/man/resolved.conf.xml index 9adc0143c7c..f8899fe662c 100644 --- a/man/resolved.conf.xml +++ b/man/resolved.conf.xml @@ -363,12 +363,33 @@ DNSStubListenerExtra=udp:[2001:db8:0:f102::13]:9953 ReadEtcHosts= Takes a boolean argument. If yes (the default), systemd-resolved will read /etc/hosts, and try to resolve - hosts or address by using the entries in the file before sending query to DNS servers. + hosts or addresses by using the entries in the file before sending query to DNS servers. + + ReadStaticRecords= + Takes a boolean argument. If yes (the default), + systemd-resolved will read + /etc/systemd/resolve/static.d/*.rr, + /run/systemd/resolve/static.d/*.rr, + /usr/local/lib/systemd/resolve/static.d/*.rr, + /usr/lib/systemd/resolve/static.d/*.rr, and try to resolve lookups by using the + entries in these files before sending query to DNS servers. This functionality is very similar to the + one controlled by ReadEtcHosts=, but allows more flexible control of DNS resource + records fields beyond just A/AAAA/PTR. See + systemd.rr5 for + details. + + If both this option and ReadEtcHosts= are enabled then this mechanism takes + precedence: any records discovered via static resource records will take precedence over records + under the same name from /etc/hosts. + + + + ResolveUnicastSingleLabel= Takes a boolean argument. When false (the default), @@ -418,6 +439,7 @@ DNSStubListenerExtra=udp:[2001:db8:0:f102::13]:9953 systemd-resolved.service8 systemd-networkd.service8 dnssec-trust-anchors.d5 + systemd.rr5 resolv.conf5 diff --git a/man/rules/meson.build b/man/rules/meson.build index d2d26abe5da..682f55c774d 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1280,6 +1280,7 @@ manpages = [ ['systemd.pcrlock', '5', ['systemd.pcrlock.d'], ''], ['systemd.preset', '5', [], ''], ['systemd.resource-control', '5', [], ''], + ['systemd.rr', '5', [], 'ENABLE_RESOLVE'], ['systemd.scope', '5', [], ''], ['systemd.service', '5', [], ''], ['systemd.slice', '5', [], ''], diff --git a/man/systemd-resolved.service.xml b/man/systemd-resolved.service.xml index a5ab48d2fa0..1d27d2c3c59 100644 --- a/man/systemd-resolved.service.xml +++ b/man/systemd-resolved.service.xml @@ -131,6 +131,16 @@ The hostname _localdnsproxy is resolved to the IP address 127.0.0.54, i.e. the address the local DNS proxy (see above) is listening on. + The files matching /etc/systemd/resolve/static.d/*.rr, + /run/systemd/resolve/static.d/*.rr, + /usr/local/lib/systemd/resolve/static.d/*.rr, + /usr/lib/systemd/resolve/static.d/*.rr may be used to define arbitrary + records. See + systemd.rr5 for + details. Support for this may be disabled with ReadStaticRecords=no, see + resolved.conf5. + + The mappings defined in /etc/hosts are resolved to their configured addresses and back, but they will not affect lookups for non-address types (like MX). Support for /etc/hosts may be disabled with ReadEtcHosts=no, @@ -510,6 +520,7 @@ search foobar.com barbar.com systemd1 resolved.conf5 systemd.dns-delegate5 + systemd.rr5 systemd.dnssd5 dnssec-trust-anchors.d5 nss-resolve8 diff --git a/man/systemd.rr.xml b/man/systemd.rr.xml new file mode 100644 index 00000000000..d6718ccf48e --- /dev/null +++ b/man/systemd.rr.xml @@ -0,0 +1,95 @@ + + + + + + + + systemd.rr + systemd + + + + systemd.rr + 5 + + + + systemd.rr + Local static DNS resource record definitions + + + + + /etc/systemd/resolve/static.d/*.rr + /run/systemd/resolve/static.d/*.rr + /usr/local/lib/systemd/resolve/static.d/*.rr + /usr/lib/systemd/resolve/static.d/*.rr + + + + + Description + + *.rr files may be used to define resource record sets ("RRsets") that shall be + resolvable locally, similar in style to address records defined by /etc/hosts (see + hosts5 for + details). These files are read by + systemd-resolved.service8, + and are used to synthesize local responses to local queries matching the defined resource record set. + + These drop-in files are in JSON format. Each file may either contain a single top-level DNS RR + object, or an array of one or more DNS RR objects. Each RR object has at least a key + subobject consisting of a name string field and a type integer + field (which contains the RR type in numeric form). Depending on the chosen type the RR object also has + the following fields: + + + For A/AAAA RRs, the RR object should have an address field set to + either an IP address formatted as string, or an array consisting of 4 or 16 8-bit unsigned integers for + the IP address. + + For PTR/NS/CNAME/DNAME RRs, the RR object should have a name field + set to the name the record shall point to. + + + This JSON serialization of DNS RRs matches the one returned by resolvectl. + + Currently no other RR types are supported. + + + + Examples + + Simple A Record + To make local address lookups for foobar.example.com resolve to the + 192.168.100.1 IPv4 address, create + /run/systemd/resolve/static.d/foobar_example_com.rr: + + +{ + "key" : { + "type" : 1, + "name" : "foobar.example.com" + }, + "address" : [ 192, 168, 100, 1 ] +} + + + + + + See Also + + systemd1 + systemd-resolved.service8 + resolved.conf5 + hosts5 + resolvectl1 + + + + diff --git a/src/resolve/meson.build b/src/resolve/meson.build index be2979343f3..b9b2e24b181 100644 --- a/src/resolve/meson.build +++ b/src/resolve/meson.build @@ -36,6 +36,7 @@ systemd_resolved_extract_sources = files( 'resolved-mdns.c', 'resolved-resolv-conf.c', 'resolved-socket-graveyard.c', + 'resolved-static-records.c', 'resolved-util.c', 'resolved-varlink.c', ) diff --git a/src/resolve/resolved-dns-query.c b/src/resolve/resolved-dns-query.c index a0ef7504471..6ec6569ae76 100644 --- a/src/resolve/resolved-dns-query.c +++ b/src/resolve/resolved-dns-query.c @@ -21,6 +21,7 @@ #include "resolved-etc-hosts.h" #include "resolved-hook.h" #include "resolved-manager.h" +#include "resolved-static-records.h" #include "resolved-timeouts.h" #include "set.h" #include "string-util.h" @@ -910,6 +911,33 @@ static int dns_query_try_etc_hosts(DnsQuery *q) { return 1; } +static int dns_query_try_static_records(DnsQuery *q) { + int r; + + assert(q); + + if (FLAGS_SET(q->flags, SD_RESOLVED_NO_SYNTHESIZE)) + return 0; + + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + r = manager_static_records_lookup( + q->manager, + q->question_bypass ? q->question_bypass->question : q->question_utf8, + &answer); + if (r <= 0) + return r; + + dns_query_reset_answer(q); + + q->answer = TAKE_PTR(answer); + q->answer_rcode = DNS_RCODE_SUCCESS; + q->answer_protocol = dns_synthesize_protocol(q->flags); + q->answer_family = dns_synthesize_family(q->flags); + q->answer_query_flags = SD_RESOLVED_AUTHENTICATED|SD_RESOLVED_CONFIDENTIAL|SD_RESOLVED_SYNTHETIC; + + return 1; +} + static int dns_query_go_scopes(DnsQuery *q) { int r; @@ -1038,6 +1066,14 @@ int dns_query_go(DnsQuery *q) { q->state != DNS_TRANSACTION_NULL) return 0; + r = dns_query_try_static_records(q); + if (r < 0) + return r; + if (r > 0) { + dns_query_complete(q, DNS_TRANSACTION_SUCCESS); + return 1; + } + r = dns_query_try_etc_hosts(q); if (r < 0) return r; diff --git a/src/resolve/resolved-gperf.gperf b/src/resolve/resolved-gperf.gperf index c548320449b..8b8a66d0369 100644 --- a/src/resolve/resolved-gperf.gperf +++ b/src/resolve/resolved-gperf.gperf @@ -31,6 +31,7 @@ Resolve.DNSOverTLS, config_parse_dns_over_tls_mode, 0, Resolve.Cache, config_parse_dns_cache_mode, DNS_CACHE_MODE_YES, offsetof(Manager, enable_cache) Resolve.DNSStubListener, config_parse_dns_stub_listener_mode, 0, offsetof(Manager, dns_stub_listener_mode) Resolve.ReadEtcHosts, config_parse_bool, 0, offsetof(Manager, read_etc_hosts) +Resolve.ReadStaticRecords, config_parse_bool, 0, offsetof(Manager, read_static_records) Resolve.ResolveUnicastSingleLabel, config_parse_bool, 0, offsetof(Manager, resolve_unicast_single_label) Resolve.DNSStubListenerExtra, config_parse_dns_stub_listener_extra, 0, offsetof(Manager, dns_extra_stub_listeners) Resolve.CacheFromLocalhost, config_parse_bool, 0, offsetof(Manager, cache_from_localhost) diff --git a/src/resolve/resolved-manager.c b/src/resolve/resolved-manager.c index 19ff92bfca5..25a51ed02b0 100644 --- a/src/resolve/resolved-manager.c +++ b/src/resolve/resolved-manager.c @@ -49,6 +49,7 @@ #include "resolved-mdns.h" #include "resolved-resolv-conf.h" #include "resolved-socket-graveyard.h" +#include "resolved-static-records.h" #include "resolved-util.h" #include "resolved-varlink.h" #include "set.h" @@ -637,6 +638,7 @@ static void manager_set_defaults(Manager *m) { m->enable_cache = DNS_CACHE_MODE_YES; m->dns_stub_listener_mode = DNS_STUB_LISTENER_YES; m->read_etc_hosts = true; + m->read_static_records = true; m->resolve_unicast_single_label = false; m->cache_from_localhost = false; m->stale_retention_usec = 0; @@ -660,6 +662,7 @@ static int manager_dispatch_reload_signal(sd_event_source *s, const struct signa m->delegates = hashmap_free(m->delegates); dns_trust_anchor_flush(&m->trust_anchor); manager_etc_hosts_flush(m); + manager_static_records_flush(m); manager_set_defaults(m); @@ -730,6 +733,7 @@ int manager_new(Manager **ret) { .read_resolv_conf = true, .need_builtin_fallbacks = true, .etc_hosts_last = USEC_INFINITY, + .static_records_last = USEC_INFINITY, .sigrtmin18_info.memory_pressure_handler = manager_memory_pressure, .sigrtmin18_info.memory_pressure_userdata = m, @@ -918,6 +922,7 @@ Manager* manager_free(Manager *m) { dns_trust_anchor_flush(&m->trust_anchor); manager_etc_hosts_flush(m); + manager_static_records_flush(m); while ((sb = hashmap_first(m->dns_service_browsers))) dns_service_browser_free(sb); diff --git a/src/resolve/resolved-manager.h b/src/resolve/resolved-manager.h index 4f595e6d04c..d72e9104d79 100644 --- a/src/resolve/resolved-manager.h +++ b/src/resolve/resolved-manager.h @@ -123,6 +123,12 @@ typedef struct Manager { struct stat etc_hosts_stat; bool read_etc_hosts; + /* Data from {/etc,/run,/usr/local/lib,/usr/lib}/systemd/resolve/static.d/ */ + Hashmap *static_records; + usec_t static_records_last; + Set *static_records_stat; + bool read_static_records; + /* List of refused DNS Record Types */ Set *refuse_record_types; diff --git a/src/resolve/resolved-static-records.c b/src/resolve/resolved-static-records.c new file mode 100644 index 00000000000..4aa6f2e4212 --- /dev/null +++ b/src/resolve/resolved-static-records.c @@ -0,0 +1,226 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-json.h" + +#include "alloc-util.h" +#include "conf-files.h" +#include "constants.h" +#include "dns-answer.h" +#include "dns-domain.h" +#include "dns-question.h" +#include "dns-rr.h" +#include "errno-util.h" +#include "fd-util.h" +#include "fileio.h" +#include "hashmap.h" +#include "json-util.h" +#include "log.h" +#include "resolved-manager.h" +#include "resolved-static-records.h" +#include "set.h" +#include "stat-util.h" + +/* This implements a mechanism to extend what systemd-resolved resolves locally, via .rr drop-ins in + * {/etc,/run,/usr/local/lib,/usr/lib}/systemd/resolve/static.d/. These files are in JSON format, and are RR + * serializations, that match the usual way we serialize RRs to JSON. + * + * Note that this deliberately doesn't use the (probably more user-friendly) classic DNS zone file format, + * to keep things a bit simpler, and symmetric to the places we currently already generate JSON + * serializations of DNS RRs. Also note the semantics are different from DNS zone file format, for example + * regarding delegation (i.e. the RRs defined here have no effect on subdomains), which is probably nicer for + * one-off mappings of domains to specific resources. Or in other words, this is supposed to be a drop-in + * based alternative to /etc/hosts, not a one to DNS zone files. (The JSON format is also a lot more + * extensible to us, for example we could teach it to map certain lookups to specific DNS errors, or extend + * it so that subdomains always get NXDOMAIN or similar). + * + * (That said, if there's a good reason, we can also support *.zone files too one day). + */ + +/* Recheck static records at most once every 2s */ +#define STATIC_RECORDS_RECHECK_USEC (2*USEC_PER_SEC) + +DEFINE_PRIVATE_HASH_OPS_WITH_VALUE_DESTRUCTOR( + answer_by_name_hash_ops, + char, + dns_name_hash_func, + dns_name_compare_func, + DnsAnswer, + dns_answer_unref); + +static int load_static_record_file_item(sd_json_variant *rj, Hashmap **records) { + int r; + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *rr = NULL; + r = dns_resource_record_from_json(rj, &rr); + if (r < 0) + return log_error_errno(r, "Failed to parse DNS record from JSON: %m"); + + _cleanup_(dns_answer_unrefp) DnsAnswer *a = + hashmap_remove(*records, dns_resource_key_name(rr->key)); + + r = dns_answer_add_extend_full(&a, rr, /* ifindex= */ 0, DNS_ANSWER_AUTHENTICATED, /* rrsig= */ NULL, /* until= */ USEC_INFINITY); + if (r < 0) + return log_error_errno(r, "Failed to append RR to DNS answer: %m"); + + DnsAnswerItem *item = ASSERT_PTR(ordered_set_first(a->items)); + + r = hashmap_ensure_put(records, &answer_by_name_hash_ops, dns_resource_key_name(item->rr->key), a); + if (r < 0) + return log_error_errno(r, "Failed to add RR to static record set: %m"); + + TAKE_PTR(a); + + log_debug("Added static resource record: %s", dns_resource_record_to_string(rr)); + return 1; +} + +static int load_static_record_file(const ConfFile *cf, Hashmap **records, Set **stats) { + int r; + + assert(cf); + assert(records); + assert(stats); + + /* Have we seen this file before? Then we might as well skip loading it again, it wouldn't have any + * additional effect anyway. (Note: masking/overriding has already been applied before we reach this + * point, here everything is purely additive.) */ + if (set_contains(*stats, &cf->st)) + return 0; + + _cleanup_free_ struct stat *st_copy = memdup(&cf->st, sizeof(cf->st)); + if (!st_copy) + return log_oom(); + + if (set_ensure_consume(stats, &inode_unmodified_hash_ops, TAKE_PTR(st_copy)) < 0) + return log_oom(); + + _cleanup_fclose_ FILE *f = NULL; + r = xfopenat(cf->fd, /* path= */ NULL, "re", /* open_flags= */ 0, &f); + if (r < 0) { + log_warning_errno(r, "Failed to open '%s', skipping: %m", cf->result); + return 0; + } + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *j = NULL; + unsigned line = 0, column = 0; + r = sd_json_parse_file(f, cf->result, /* flags= */ 0, &j, &line, &column); + if (r < 0) { + if (line > 0) + log_syntax(/* unit= */ NULL, LOG_WARNING, cf->result, line, r, "Failed to parse JSON, skipping: %m"); + else + log_warning_errno(r, "Failed to parse JSON file '%s', skipping: %m", cf->result); + return 0; + } + + if (sd_json_variant_is_array(j)) { + sd_json_variant *i; + int ret = 0; + JSON_VARIANT_ARRAY_FOREACH(i, j) + RET_GATHER(ret, load_static_record_file_item(i, records)); + if (ret < 0) + return ret; + } else if (sd_json_variant_is_object(j)) { + r = load_static_record_file_item(j, records); + if (r < 0) + return r; + } else { + log_warning("JSON file '%s' contains neither array nor object, skipping.", cf->result); + return 0; + } + + return 1; +} + +static int manager_static_records_read(Manager *m) { + int r; + + usec_t ts; + assert_se(sd_event_now(m->event, CLOCK_BOOTTIME, &ts) >= 0); + + /* See if we checked the static records db recently already */ + if (m->static_records_last != USEC_INFINITY && usec_add(m->static_records_last, STATIC_RECORDS_RECHECK_USEC) > ts) + return 0; + + m->static_records_last = ts; + + ConfFile **files = NULL; + size_t n_files = 0; + CLEANUP_ARRAY(files, n_files, conf_file_free_many); + + r = conf_files_list_nulstr_full( + ".rr", + /* root= */ NULL, + CONF_FILES_REGULAR|CONF_FILES_FILTER_MASKED|CONF_FILES_WARN, + CONF_PATHS_NULSTR("systemd/resolve/static.d/"), + &files, + &n_files); + if (r < 0) + return log_error_errno(r, "Failed to enumerate static record drop-ins: %m"); + + /* Let's suppress reloads if nothing changed. For that keep the set of inodes from the previous + * reload around, and see if there are any changes on them. */ + bool reload; + if (set_size(m->static_records_stat) != n_files) + reload = true; + else { + reload = false; + FOREACH_ARRAY(f, files, n_files) + if (!set_contains(m->static_records_stat, &(*f)->st)) { + reload = true; + break; + } + } + + if (!reload) { + log_debug("No static record files changed, not re-reading."); + return 0; + } + + _cleanup_(hashmap_freep) Hashmap *records = NULL; + _cleanup_(set_freep) Set *stats = NULL; + FOREACH_ARRAY(f, files, n_files) + (void) load_static_record_file(*f, &records, &stats); + + hashmap_free(m->static_records); + m->static_records = TAKE_PTR(records); + + set_free(m->static_records_stat); + m->static_records_stat = TAKE_PTR(stats); + + return 0; +} + +int manager_static_records_lookup(Manager *m, DnsQuestion *q, DnsAnswer **answer) { + int r; + + assert(m); + assert(q); + assert(answer); + + if (!m->read_static_records) + return 0; + + (void) manager_static_records_read(m); + + const char *n = dns_question_first_name(q); + if (!n) + return 0; + + DnsAnswer *f = hashmap_get(m->static_records, n); + if (!f) + return 0; + + r = dns_answer_extend(answer, f); + if (r < 0) + return r; + + return 1; +} + +void manager_static_records_flush(Manager *m) { + assert(m); + + m->static_records = hashmap_free(m->static_records); + m->static_records_stat = set_free(m->static_records_stat); + m->static_records_last = USEC_INFINITY; +} diff --git a/src/resolve/resolved-static-records.h b/src/resolve/resolved-static-records.h new file mode 100644 index 00000000000..f50c70ef459 --- /dev/null +++ b/src/resolve/resolved-static-records.h @@ -0,0 +1,7 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "resolved-forward.h" + +void manager_static_records_flush(Manager *m); +int manager_static_records_lookup(Manager *m, DnsQuestion* q, DnsAnswer **answer); diff --git a/src/resolve/resolved.conf.in b/src/resolve/resolved.conf.in index 656bc7c0eb7..147d30845b1 100644 --- a/src/resolve/resolved.conf.in +++ b/src/resolve/resolved.conf.in @@ -39,6 +39,7 @@ #DNSStubListener=yes #DNSStubListenerExtra= #ReadEtcHosts=yes +#ReadStaticRecords=yes #ResolveUnicastSingleLabel=no #StaleRetentionSec=0 #RefuseRecordTypes= diff --git a/test/units/TEST-75-RESOLVED.sh b/test/units/TEST-75-RESOLVED.sh index b3656da9404..bb1cf9576c2 100755 --- a/test/units/TEST-75-RESOLVED.sh +++ b/test/units/TEST-75-RESOLVED.sh @@ -1487,6 +1487,55 @@ EOF grep -qF "1.2.3.4" "$RUN_OUT" } +testcase_static_record() { + mkdir -p /run/systemd/resolve/static.d/ + cat >/run/systemd/resolve/static.d/statictest.rr </run/systemd/resolve/static.d/statictest2.rr </run/systemd/resolve/static.d/garbage.rr </run/systemd/resolve/static.d/garbage2.rr <