From 98e7cc36f019e48a3f20db9787ccd38d899957f5 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Thu, 13 Aug 2026 13:51:30 -0700 Subject: [PATCH 1/4] timesyncd: resolve NTP hostnames through resolved's varlink interface With DNSSEC on, resolved can't validate anything until the clock is right, and without an RTC the only thing that fixes the clock is talking to the NTP servers we're trying to resolve. On glibc the timesyncd unit sets SYSTEMD_NSS_RESOLVE_VALIDATE=0 and nss-resolve turns that into a per-query flag, which breaks the chicken/egg. musl (and other systems not using nss-resolve) have no NSS, so nothing reads it there and we're stuck. So talk to resolved directly when it's there, and pass SD_RESOLVED_NO_VALIDATE until we've synced the clock ourselves. NTP-related lookups after that are validated normally, which is better than what we get from nss. If resolved isn't around we just keep using getaddrinfo(). The reply handler duplicates the tail of the getaddrinfo handler, since the two differ in how they report failures. Varlink discards what the callback returns, so it has to move on to the next host itself. This also drops SYSTEMD_NSS_RESOLVE_VALIDATE=0 from the unit because nss-resolve talks to resolved over the same socket we do, so the getaddrinfo() path is only ever taken when resolved is unreachable, and then nothing validates anyway. --- src/timesync/timesyncd-manager.c | 136 +++++++++++++++++++++++++++-- src/timesync/timesyncd-manager.h | 1 + units/systemd-timesyncd.service.in | 4 - 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/src/timesync/timesyncd-manager.c b/src/timesync/timesyncd-manager.c index b0f22516df..7b2ed4f831 100644 --- a/src/timesync/timesyncd-manager.c +++ b/src/timesync/timesyncd-manager.c @@ -10,8 +10,10 @@ #include "sd-bus.h" #include "sd-daemon.h" +#include "sd-json.h" #include "sd-messages.h" #include "sd-network.h" +#include "sd-varlink.h" #include "alloc-util.h" #include "clock-util.h" @@ -23,6 +25,7 @@ #include "format-util.h" #include "fs-util.h" #include "hashmap.h" +#include "in-addr-util.h" #include "list.h" #include "log.h" #include "logarithm.h" @@ -30,6 +33,8 @@ #include "random-util.h" #include "ratelimit.h" #include "resolve-private.h" +#include "resolve-varlink-util.h" +#include "resolved-def.h" #include "socket-util.h" #include "string-util.h" #include "strv.h" @@ -780,6 +785,119 @@ static int manager_resolve_handler(sd_resolve_query *q, int ret, const struct ad return manager_begin(m); } +static int manager_resolve_varlink_reply( + sd_varlink *link, + sd_json_variant *parameters, + const char *error_id, + sd_varlink_reply_flags_t flags, + void *userdata) { + + Manager *m = ASSERT_PTR(userdata); + int r; + + assert(m->current_server_name); + + _unused_ _cleanup_(sd_varlink_unrefp) sd_varlink *link_free = TAKE_PTR(m->resolve_varlink); + + if (!isempty(error_id)) { + log_debug("Failed to resolve %s: %s", m->current_server_name->string, error_id); + + /* Try next one */ + return manager_connect(m); + } + + _cleanup_(resolve_hostname_reply_done) ResolveHostnameReply reply = {}; + r = dispatch_resolve_hostname_reply(/* name = */ NULL, parameters, SD_JSON_LOG, &reply); + if (r < 0) { + log_warning_errno(r, "Failed to parse reply for %s, ignoring: %m", m->current_server_name->string); + + return manager_connect(m); + } + + FOREACH_ARRAY(entry, reply.addresses, reply.n_addresses) { + _cleanup_free_ char *pretty = NULL; + union sockaddr_union sa = {}; + ServerAddress *a; + + if (!IN_SET(entry->family, AF_INET, AF_INET6)) { + log_debug("Ignoring unsuitable address protocol for %s.", m->current_server_name->string); + continue; + } + + r = sockaddr_set_in_addr(&sa, entry->family, &entry->in_addr.address, 123); + if (r < 0) { + log_warning_errno(r, "Failed to convert address for %s, trying next host: %m", m->current_server_name->string); + return manager_connect(m); + } + + /* Only link-local addresses need a scope, setting it on a global one pins us to one interface. */ + if (entry->family == AF_INET6 && in6_addr_is_link_local(&entry->in_addr.address.in6)) + sa.in6.sin6_scope_id = entry->ifindex; + + r = server_address_new(m->current_server_name, &a, &sa, sockaddr_len(&sa)); + if (r < 0) { + log_warning_errno(r, "Failed to add server address, trying next host: %m"); + return manager_connect(m); + } + + (void) server_address_pretty(a, &pretty); + log_debug("Resolved address %s for %s.", strna(pretty), m->current_server_name->string); + } + + if (!m->current_server_name->addresses) { + log_error("Failed to find suitable address for host %s.", m->current_server_name->string); + + /* Try next host */ + return manager_connect(m); + } + + manager_set_server_address(m, m->current_server_name->addresses); + + return manager_begin(m); +} + +/* Resolve the current server name through resolved's Varlink interface. This returns 0 if resolved is + * unavailable */ +static int manager_resolve_varlink(Manager *m) { + _cleanup_(sd_varlink_close_unrefp) sd_varlink *link = NULL; + int r; + + assert(m); + assert(m->current_server_name); + + r = sd_varlink_connect_address(&link, "/run/systemd/resolve/io.systemd.Resolve"); + if (r < 0) { + log_debug_errno(r, "Failed to connect to systemd-resolved, using getaddrinfo(): %m"); + return 0; + } + + (void) sd_varlink_set_description(link, "timesyncd-resolve"); + (void) sd_varlink_set_relative_timeout(link, SD_RESOLVED_QUERY_TIMEOUT_USEC); + + r = sd_varlink_attach_event(link, m->event, SD_EVENT_PRIORITY_NORMAL); + if (r < 0) + return log_error_errno(r, "Failed to attach Varlink connection to event loop: %m"); + + r = sd_varlink_bind_reply(link, manager_resolve_varlink_reply); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink reply callback: %m"); + + sd_varlink_set_userdata(link, m); + + /* Ask resolved to skip DNSSEC validation as long as our clock is not synchronized yet. */ + r = sd_varlink_invokebo( + link, + "io.systemd.Resolve.ResolveHostname", + SD_JSON_BUILD_PAIR_STRING("name", m->current_server_name->string), + SD_JSON_BUILD_PAIR_CONDITION(!socket_ipv6_is_enabled(), "family", SD_JSON_BUILD_INTEGER(AF_INET)), + SD_JSON_BUILD_PAIR_UNSIGNED("flags", m->synchronized ? 0 : SD_RESOLVED_NO_VALIDATE)); + if (r < 0) + return log_error_errno(r, "Failed to call ResolveHostname(): %m"); + + m->resolve_varlink = TAKE_PTR(link); + return 1; +} + static int manager_retry_connect(sd_event_source *source, usec_t usec, void *userdata) { Manager *m = ASSERT_PTR(userdata); @@ -868,15 +986,22 @@ int manager_connect(Manager *m) { manager_set_server_name(m, f); } - /* Tell the resolver to reread /etc/resolv.conf, in - * case it changed. */ - res_init(); - /* Flush out any previously resolved addresses */ server_name_flush_addresses(m->current_server_name); log_debug("Resolving %s...", m->current_server_name->string); + /* Prefer resolved's varlink interface. If that's unavailable, then fall through to getaddrinfo below. */ + r = manager_resolve_varlink(m); + if (r < 0) + return r; + if (r > 0) + return 1; + + /* Tell the resolver to reread /etc/resolv.conf, in + * case it changed. */ + res_init(); + struct addrinfo hints = { .ai_flags = AI_NUMERICSERV|AI_ADDRCONFIG, .ai_socktype = SOCK_DGRAM, @@ -901,6 +1026,7 @@ void manager_disconnect(Manager *m) { assert(m); m->resolve_query = sd_resolve_query_unref(m->resolve_query); + m->resolve_varlink = sd_varlink_close_unref(m->resolve_varlink); m->event_timer = sd_event_source_unref(m->event_timer); @@ -1035,7 +1161,7 @@ static bool manager_is_connected(Manager *m) { /* Return true when the manager is sending a request, resolving a server name, or * in a poll interval. */ - return m->server_socket >= 0 || m->resolve_query || m->event_timer; + return m->server_socket >= 0 || m->resolve_query || m->resolve_varlink || m->event_timer; } static int manager_network_event_handler(sd_event_source *s, int fd, uint32_t revents, void *userdata) { diff --git a/src/timesync/timesyncd-manager.h b/src/timesync/timesyncd-manager.h index 9966b64ac6..8c1e30f48f 100644 --- a/src/timesync/timesyncd-manager.h +++ b/src/timesync/timesyncd-manager.h @@ -41,6 +41,7 @@ typedef struct Manager { /* peer */ sd_resolve_query *resolve_query; + sd_varlink *resolve_varlink; sd_event_source *event_receive; ServerName *current_server_name; ServerAddress *current_server_address; diff --git a/units/systemd-timesyncd.service.in b/units/systemd-timesyncd.service.in index 560bcee92f..bc64e24140 100644 --- a/units/systemd-timesyncd.service.in +++ b/units/systemd-timesyncd.service.in @@ -22,10 +22,6 @@ Wants=time-set.target AmbientCapabilities=CAP_SYS_TIME BusName=org.freedesktop.timesync1 CapabilityBoundingSet=CAP_SYS_TIME -# Turn off DNSSEC validation for hostname look-ups, since those need the -# correct time to work, but we likely won't acquire that without NTP. Let's -# break this chicken-and-egg cycle here. -Environment=SYSTEMD_NSS_RESOLVE_VALIDATE=0 ExecStart={{LIBEXECDIR}}/systemd-timesyncd LockPersonality=yes MemoryDenyWriteExecute=yes From 6279910be7b26239ce7eb442b227a879cb66e24e Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Thu, 13 Aug 2026 14:01:59 -0700 Subject: [PATCH 2/4] timesyncd: order after systemd-resolved Our first resolve happens way before sockets.target, and systemd-resolved-varlink.socket has no ordering of its own, so there's nothing making sure the socket is listening by then. Without this the connect fails and we quietly fall back to getaddrinfo(), which is exactly the case the varlink path is there for. Order after both the service and the socket since either one is enough on its own but neither covers every install situation. For example resolved binds the socket itself when nothing hands it one, and on systems where only the socket is enabled the service isn't started at boot at all. --- units/systemd-timesyncd.service.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/units/systemd-timesyncd.service.in b/units/systemd-timesyncd.service.in index bc64e24140..c180971047 100644 --- a/units/systemd-timesyncd.service.in +++ b/units/systemd-timesyncd.service.in @@ -13,7 +13,7 @@ Documentation=man:systemd-timesyncd.service(8) ConditionCapability=CAP_SYS_TIME ConditionVirtualization=!container DefaultDependencies=no -After=systemd-sysusers.service +After=systemd-sysusers.service systemd-resolved.service systemd-resolved-varlink.socket Before=time-set.target sysinit.target shutdown.target Conflicts=shutdown.target Wants=time-set.target From 8027ff6ecd12058d08e0bb6dce046b86828f7dd4 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Fri, 14 Aug 2026 15:19:56 -0700 Subject: [PATCH 3/4] timesyncd: don't ask for AAAA when IPv6 is disabled ipv6_is_supported only tells us the kernel has IPv6, not that it's usable. ipv6_is_enabled also checks the sysctl that turns it off, so we stop resolving addresses we can't use. --- src/timesync/timesyncd-manager.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/timesync/timesyncd-manager.c b/src/timesync/timesyncd-manager.c index 7b2ed4f831..4b3978bebc 100644 --- a/src/timesync/timesyncd-manager.c +++ b/src/timesync/timesyncd-manager.c @@ -1005,7 +1005,7 @@ int manager_connect(Manager *m) { struct addrinfo hints = { .ai_flags = AI_NUMERICSERV|AI_ADDRCONFIG, .ai_socktype = SOCK_DGRAM, - .ai_family = socket_ipv6_is_supported() ? AF_UNSPEC : AF_INET, + .ai_family = socket_ipv6_is_enabled() ? AF_UNSPEC : AF_INET, }; r = resolve_getaddrinfo(m->resolve, &m->resolve_query, m->current_server_name->string, "123", &hints, manager_resolve_handler, NULL, m); From d52a6931643a384b1184b1c30badb739ff885f78 Mon Sep 17 00:00:00 2001 From: Clayton Craft Date: Fri, 14 Aug 2026 14:25:01 -0700 Subject: [PATCH 4/4] test: cover both of timesyncd's resolver paths This checks that we resolve through resolved when it's running, and fall back to getaddrinfo when it isn't. --- test/units/TEST-45-TIMEDATE.sh | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/units/TEST-45-TIMEDATE.sh b/test/units/TEST-45-TIMEDATE.sh index 820c3ae9f5..0af99fc96c 100755 --- a/test/units/TEST-45-TIMEDATE.sh +++ b/test/units/TEST-45-TIMEDATE.sh @@ -248,6 +248,19 @@ assert_timesyncd_state() { return 1 } +assert_timesyncd_log() { + local cursor="${1:?}" + local match="${2:?}" + + for _ in {0..29}; do + journalctl --sync + journalctl -q -u systemd-timesyncd.service --after-cursor="$(<"$cursor")" --grep "$match" >/dev/null && return 0 + sleep .5 + done + + return 1 +} + testcase_ntp() { # timesyncd has ConditionVirtualization=!container by default; drop/mock that for testing if systemd-detect-virt --container --quiet; then @@ -409,6 +422,64 @@ EOF rm -f /run/systemd/network/ntp99.* } +teardown_timesyncd_resolve() { + set +eu + + systemctl start systemd-resolved-varlink.socket + systemctl start systemd-resolved.service + + rm -rf /run/systemd/system/systemd-timesyncd.service.d + systemctl daemon-reload + systemctl stop systemd-timesyncd +} + +testcase_timesyncd_resolve() { + local cursor + + if systemd-detect-virt -cq; then + echo "This test case requires a VM, skipping..." + return 0 + fi + + if ! command -v resolvectl >/dev/null; then + echo "This test requires systemd-resolved, skipping..." + return 0 + fi + + trap teardown_timesyncd_resolve RETURN + + mkdir -p /run/systemd/system/systemd-timesyncd.service.d + cat >/run/systemd/system/systemd-timesyncd.service.d/10-debug.conf <