diff --git a/man/org.freedesktop.resolve1.xml b/man/org.freedesktop.resolve1.xml index 7d5e997b83..16133e1beb 100644 --- a/man/org.freedesktop.resolve1.xml +++ b/man/org.freedesktop.resolve1.xml @@ -497,6 +497,7 @@ node /org/freedesktop/resolve1 { #define SD_RESOLVED_FROM_ZONE (UINT64_C(1) << 21) #define SD_RESOLVED_FROM_TRUST_ANCHOR (UINT64_C(1) << 22) #define SD_RESOLVED_FROM_NETWORK (UINT64_C(1) << 23) +#define SD_RESOLVED_FROM_HOOK (UINT64_C(1) << 27) On input, the first five flags control the protocols to use for the look-up. They refer to diff --git a/src/resolve/meson.build b/src/resolve/meson.build index 6944f6eb7f..568a7c3c1d 100644 --- a/src/resolve/meson.build +++ b/src/resolve/meson.build @@ -28,6 +28,7 @@ systemd_resolved_extract_sources = files( 'resolved-dnssd-bus.c', 'resolved-dnssd.c', 'resolved-etc-hosts.c', + 'resolved-hook.c', 'resolved-link-bus.c', 'resolved-link.c', 'resolved-llmnr.c', diff --git a/src/resolve/resolvectl.c b/src/resolve/resolvectl.c index 13f70e3b1c..125d89f41a 100644 --- a/src/resolve/resolvectl.c +++ b/src/resolve/resolvectl.c @@ -253,13 +253,14 @@ static void print_source(uint64_t flags, usec_t rtt) { ansi_normal()); if ((flags & (SD_RESOLVED_FROM_MASK|SD_RESOLVED_SYNTHETIC)) != 0) - printf("%s-- Data from:%s%s%s%s%s%s\n", + printf("%s-- Data from:%s%s%s%s%s%s%s\n", ansi_grey(), FLAGS_SET(flags, SD_RESOLVED_SYNTHETIC) ? " synthetic" : "", FLAGS_SET(flags, SD_RESOLVED_FROM_CACHE) ? " cache" : "", FLAGS_SET(flags, SD_RESOLVED_FROM_ZONE) ? " zone" : "", FLAGS_SET(flags, SD_RESOLVED_FROM_TRUST_ANCHOR) ? " trust-anchor" : "", FLAGS_SET(flags, SD_RESOLVED_FROM_NETWORK) ? " network" : "", + FLAGS_SET(flags, SD_RESOLVED_FROM_HOOK) ? " hook" : "", ansi_normal()); } diff --git a/src/resolve/resolved-bus.c b/src/resolve/resolved-bus.c index 53d2de274f..d03002f42c 100644 --- a/src/resolve/resolved-bus.c +++ b/src/resolve/resolved-bus.c @@ -1017,7 +1017,7 @@ static void resolve_service_all_complete(DnsQuery *query) { assert(q); - if (q->block_all_complete > 0) { + if (q->hook_query || q->block_all_complete > 0) { TAKE_PTR(q); return; } @@ -1028,6 +1028,12 @@ static void resolve_service_all_complete(DnsQuery *query) { LIST_FOREACH(auxiliary_queries, aux, q->auxiliary_queries) { + if (aux->hook_query) { + /* If an auxiliary query's hook is still pending, let's wait */ + TAKE_PTR(q); + return; + } + switch (aux->state) { case DNS_TRANSACTION_PENDING: diff --git a/src/resolve/resolved-dns-query.c b/src/resolve/resolved-dns-query.c index 79a008bfb7..cb1026aadc 100644 --- a/src/resolve/resolved-dns-query.c +++ b/src/resolve/resolved-dns-query.c @@ -19,6 +19,7 @@ #include "resolved-dns-synthesize.h" #include "resolved-dns-transaction.h" #include "resolved-etc-hosts.h" +#include "resolved-hook.h" #include "resolved-manager.h" #include "resolved-timeouts.h" #include "set.h" @@ -524,6 +525,8 @@ DnsQuery *dns_query_free(DnsQuery *q) { dns_service_browser_unref(q->service_browser_request); + hook_query_free(q->hook_query); + if (q->manager) { LIST_REMOVE(queries, q->manager->dns_queries, q); q->manager->n_dns_queries--; @@ -907,24 +910,17 @@ static int dns_query_try_etc_hosts(DnsQuery *q) { return 1; } -int dns_query_go(DnsQuery *q) { - DnsScopeMatch found = DNS_SCOPE_NO; - DnsScope *first = NULL; +static int dns_query_go_scopes(DnsQuery *q) { int r; assert(q); + assert(!q->hook_query); + assert(q->state == DNS_TRANSACTION_NULL); - if (q->state != DNS_TRANSACTION_NULL) - return 0; - - r = dns_query_try_etc_hosts(q); - if (r < 0) - return r; - if (r > 0) { - dns_query_complete(q, DNS_TRANSACTION_SUCCESS); - return 1; - } + /* Start the lookup via the scopes */ + DnsScopeMatch found = DNS_SCOPE_NO; + DnsScope *first = NULL; LIST_FOREACH(scopes, s, q->manager->dns_scopes) { DnsScopeMatch match; @@ -999,6 +995,72 @@ fail: return r; } +static void on_hook_complete(HookQuery *hq, int rcode, DnsAnswer *answer, void *userdata) { + DnsQuery *q = ASSERT_PTR(userdata); + int r; + + assert(hq); + assert(q->hook_query == hq); + assert(q->state == DNS_TRANSACTION_NULL); + + q->hook_query = hook_query_free(q->hook_query); + TAKE_PTR(hq); + + if (rcode < 0) { + log_debug("Hook yielded no results, proceeding."); + r = dns_query_go_scopes(q); + if (r < 0) { + dns_query_reset_answer(q); + q->answer_errno = r; + dns_query_complete(q, DNS_TRANSACTION_ERRNO); + } + + return; + } + + dns_query_reset_answer(q); + + q->answer = dns_answer_ref(answer); + q->answer_rcode = rcode; + q->answer_protocol = dns_synthesize_protocol(q->flags); + q->answer_family = dns_synthesize_family(q->flags); + q->answer_query_flags = SD_RESOLVED_FROM_HOOK; + dns_query_complete(q, rcode == DNS_RCODE_SUCCESS ? DNS_TRANSACTION_SUCCESS : DNS_TRANSACTION_RCODE_FAILURE); +} + +int dns_query_go(DnsQuery *q) { + int r; + + assert(q); + + /* Already ongoing? Then suppress */ + if (q->hook_query || + q->state != DNS_TRANSACTION_NULL) + return 0; + + r = dns_query_try_etc_hosts(q); + if (r < 0) + return r; + if (r > 0) { + dns_query_complete(q, DNS_TRANSACTION_SUCCESS); + return 1; + } + + r = manager_hook_query( + q->manager, + q->question_bypass ? q->question_bypass->question : q->question_idna, + q->question_bypass ? q->question_bypass->question : q->question_utf8, + on_hook_complete, + q, + &q->hook_query); + if (r < 0) + return r; + if (r > 0) /* hook calls are pending */ + return 0; + + return dns_query_go_scopes(q); +} + static void dns_query_accept(DnsQuery *q, DnsQueryCandidate *c) { DnsTransactionState state = DNS_TRANSACTION_NO_SERVERS; bool has_authenticated = false, has_non_authenticated = false, has_confidential = false, has_non_confidential = false; @@ -1137,6 +1199,9 @@ void dns_query_ready(DnsQuery *q) { * after calling this function, unless the block_ready * counter was explicitly bumped before doing so. */ + if (q->hook_query) + return; + if (q->block_ready > 0) return; diff --git a/src/resolve/resolved-dns-query.h b/src/resolve/resolved-dns-query.h index d3bed93084..b8b7d40525 100644 --- a/src/resolve/resolved-dns-query.h +++ b/src/resolve/resolved-dns-query.h @@ -110,6 +110,9 @@ typedef struct DnsQuery { DnssdDiscoveredService *dnsservice_request; DnsServiceBrowser *service_browser_request; + /* Pending query to any installed hooks */ + HookQuery *hook_query; + /* Completion callback */ void (*complete)(DnsQuery* q); diff --git a/src/resolve/resolved-forward.h b/src/resolve/resolved-forward.h index 8f1fb02539..16c5380d9b 100644 --- a/src/resolve/resolved-forward.h +++ b/src/resolve/resolved-forward.h @@ -34,6 +34,7 @@ typedef struct DnsSvcParam DnsSvcParam; typedef struct DnsTransaction DnsTransaction; typedef struct DnsTxtItem DnsTxtItem; typedef struct DnsZoneItem DnsZoneItem; +typedef struct HookQuery HookQuery; typedef struct Link Link; typedef struct LinkAddress LinkAddress; typedef struct Manager Manager; diff --git a/src/resolve/resolved-hook.c b/src/resolve/resolved-hook.c new file mode 100644 index 0000000000..4a688a8f31 --- /dev/null +++ b/src/resolve/resolved-hook.c @@ -0,0 +1,881 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-event.h" +#include "sd-varlink.h" + +#include "dirent-util.h" +#include "dns-domain.h" +#include "env-util.h" +#include "errno-util.h" +#include "fd-util.h" +#include "hash-funcs.h" +#include "iovec-util.h" +#include "json-util.h" +#include "ratelimit.h" +#include "resolved-hook.h" +#include "resolved-manager.h" +#include "set.h" +#include "stat-util.h" +#include "varlink-util.h" + +/* Controls how many idle connections to keep around at max. This is purely an optimization: an established + * socket that has gone through connect()/accept() already is just quicker to use. Since we might get a flood + * of resolution requests we keep multiple connections open thus, but not too many. */ +#define HOOK_IDLE_CONNECTIONS_MAX 4U + +/* Encapsulates a specific hook, i.e. bound socket in in the /run/systemd/resolve.hook/ directory */ +typedef struct Hook { + unsigned n_ref; + + Manager *manager; + char *socket_path; + + sd_varlink *filter_link; + Set *idle_links; /* we retry to recycle varlink connections */ + + /* This hook only shall be applied to names matching the following filter parameters */ + Set *filter_domains; /* if NULL → no filtering; if empty → do not accept anything */ + unsigned filter_labels_min; /* minimum number of labels */ + unsigned filter_labels_max; /* maximum number of labels (this is useful to hook only into single-label lookups á la LLMNR) */ + + /* timestamp we last saw this in CLOCK_MONOTONIC, for GC handling */ + uint64_t seen_usec; + + /* When a hook never responds correctly, we'll eventually give up trying */ + RateLimit reconnect_ratelimit; +} Hook; + +static Hook* hook_free(Hook *h) { + if (!h) + return NULL; + + mfree(h->socket_path); + sd_varlink_unref(h->filter_link); + set_free(h->idle_links); + + set_free(h->filter_domains); + + return mfree(h); +} + +DEFINE_PRIVATE_TRIVIAL_REF_UNREF_FUNC(Hook, hook, hook_free); +DEFINE_TRIVIAL_CLEANUP_FUNC(Hook*, hook_unref); + +static Hook *hook_unlink(Hook *h) { + if (!h) + return NULL; + + if (!h->manager) + return NULL; + + if (h->socket_path) + hashmap_remove(h->manager->hooks, h->socket_path); + h->manager = NULL; + + return hook_unref(h); +} + +static int dispatch_filter_domains(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + Hook *h = ASSERT_PTR(userdata); + int r; + + if (!sd_json_variant_is_array(variant)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array.", strna(name)); + + /* Let's explicitly allocate the set here, since we want that a NULL set means: let everything + * through; but an empty set shall mean: let nothing through */ + r = set_ensure_allocated(&h->filter_domains, &dns_name_hash_ops_free); + if (r < 0) + return json_log_oom(variant, flags); + + sd_json_variant *i; + JSON_VARIANT_ARRAY_FOREACH(i, variant) { + if (!sd_json_variant_is_string(i)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), + "Element of JSON field '%s' is not a string.", strna(name)); + + r = set_put_strdup_full(&h->filter_domains, &dns_name_hash_ops_free, sd_json_variant_string(i)); + if (r < 0 && r != -EEXIST) + return json_log_oom(variant, flags); + } + + return 0; +} + +static void hook_reset_filter(Hook *h) { + assert(h); + + h->filter_domains = set_free(h->filter_domains); + h->filter_labels_min = UINT_MAX; + h->filter_labels_max = UINT_MAX; +} + +static int hook_acquire_filter(Hook *h); + +static int on_filter_reply( + sd_varlink *link, + sd_json_variant *parameters, + const char *error_id, + sd_varlink_reply_flags_t flags, + void *userdata) { + + Hook *h = ASSERT_PTR(userdata); + int r; + + if (error_id) { + if (streq(error_id, SD_VARLINK_ERROR_DISCONNECTED)) { + /* When we are are disconnected, that's fine, maybe the other side wants to clean up + * open connections every now and then, or is being restarted and thus a moment + * offline. Try to reconnect immediately to recover. However, a service that + * continously fails should not be able to get us into a busy loop, hence we apply a + * ratelimit, and when it is hit we stop reconnecting. */ + if (ratelimit_below(&h->reconnect_ratelimit)) { + log_debug("Connection terminated while querying filter of hook '%s', trying to reconnect.", h->socket_path); + + h->filter_link = sd_varlink_unref(h->filter_link); + + r = hook_acquire_filter(h); + if (r < 0) + goto terminate; + } else + log_warning("Connection terminated while querying filter of hook '%s', and reconnection attempts failed too quickly, giving up.", h->socket_path); + + goto terminate; + } + + if (streq(error_id, SD_VARLINK_ERROR_METHOD_NOT_FOUND)) { + log_debug("Hook '%s' does not implement querying filter.", h->socket_path); + goto terminate; + } + + log_warning("Received error while requesting query filter: %s", error_id); + goto terminate; + } + + if (!FLAGS_SET(flags, SD_VARLINK_REPLY_CONTINUES)) { + log_debug("Final message received while querying filter, terminating connection."); + goto terminate; + } + + hook_reset_filter(h); + + static const struct sd_json_dispatch_field dispatch_table[] = { + { "filterDomains", SD_JSON_VARIANT_ARRAY, dispatch_filter_domains, 0, SD_JSON_NULLABLE }, + { "filterLabelsMin", SD_JSON_VARIANT_INTEGER, sd_json_dispatch_uint, offsetof(Hook, filter_labels_min), 0 }, + { "filterLabelsMax", SD_JSON_VARIANT_INTEGER, sd_json_dispatch_uint, offsetof(Hook, filter_labels_max), 0 }, + {}, + }; + + r = sd_json_dispatch(parameters, dispatch_table, SD_JSON_LOG|SD_JSON_ALLOW_EXTENSIONS, h); + if (r < 0) + goto terminate; + + return 1; + +terminate: + h->filter_link = sd_varlink_unref(h->filter_link); + hook_reset_filter(h); + return 1; +} + +static int hook_varlink_connect(Hook *h, int64_t priority, sd_varlink **ret) { + int r; + + assert(h); + assert(ret); + + _cleanup_(sd_varlink_unrefp) sd_varlink *v = NULL; + r = sd_varlink_connect_address(&v, h->socket_path); + if (ERRNO_IS_NEG_DISCONNECT(r) || r == -ENOENT) { + log_debug_errno(r, "Socket '%s' is not connectible, probably stale, ignoring: %m", h->socket_path); + *ret = NULL; + return 0; /* dead socket */ + } + if (r < 0) + return log_error_errno(r, "Failed to connect to '%s': %m", h->socket_path); + + _cleanup_free_ char *bn = NULL; + r = path_extract_filename(h->socket_path, &bn); + if (r < 0) + return log_error_errno(r, "Failed to extract filename from path '%s': %m", h->socket_path); + + _cleanup_free_ char *j = strjoin("hook-", bn); + if (!j) + return log_oom(); + + (void) sd_varlink_set_description(v, j); + + r = sd_varlink_attach_event(v, h->manager->event, priority); + if (r < 0) + return log_error_errno(r, "Failed to attach Varlink connection to event loop: %m"); + + *ret = TAKE_PTR(v); + return 1; /* worked */ +} + +static int hook_acquire_filter(Hook *h) { + int r; + + assert(h); + assert(h->manager); + + if (h->filter_link) + return 0; + + _cleanup_(sd_varlink_unrefp) sd_varlink *v = NULL; + r = hook_varlink_connect(h, SD_EVENT_PRIORITY_NORMAL-10, &v); /* Give the querying of the filter a bit of priority */ + if (r <= 0) + return r; + + /* Turn off timeout, after all we want to continously monitor filter changes */ + r = sd_varlink_set_relative_timeout(v, UINT64_MAX); + if (r < 0) + return log_error_errno(r, "Failed to disable timeout on Varlink connection %m"); + + sd_varlink_set_userdata(v, h); + r = sd_varlink_bind_reply(v, on_filter_reply); + if (r < 0) + return log_error_errno(r, "Failed to set filter reply callback on Varlink connection: %m"); + + r = sd_varlink_observe( + v, + "io.systemd.Resolve.Hook.QueryFilter", + /* parameters= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to issue QueryFilter() varlink call: %m"); + + h->filter_link = TAKE_PTR(v); + return 0; +} + +static int hook_test_filter(Hook *h, DnsQuestion *question) { + int r; + + assert(h); + assert(question); + + const char *name = dns_question_first_name(question); + if (!name) + return -EINVAL; + + if (h->filter_labels_max != UINT_MAX || h->filter_labels_min != UINT_MAX) { + int n = dns_name_count_labels(name); + if (n < 0) + return n; + + if (h->filter_labels_max != UINT_MAX && (unsigned) n > h->filter_labels_max) + return false; + if (h->filter_labels_min != UINT_MAX && (unsigned) n < h->filter_labels_min) + return false; + } + + if (h->filter_domains) + for (const char *p = name;;) { + if (set_contains(h->filter_domains, p)) + break; + + r = dns_name_parent(&p); + if (r < 0) + return r; + if (r == 0) + return false; + } + + return true; +} + +static int hook_compare(const Hook *a, const Hook *b) { + assert(a); + + /* Hooks take preference based on the name of their socket */ + return path_compare(a->socket_path, b->socket_path); +} + +static void hook_recycle_varlink(Hook *h, sd_varlink *vl) { + int r; + + assert(h); + assert(vl); + + /* Disable any potential callbacks while we are recycling the thing */ + sd_varlink_set_userdata(vl, NULL); + sd_varlink_bind_reply(vl, NULL); + + if (set_size(h->idle_links) > HOOK_IDLE_CONNECTIONS_MAX) + return; + + /* If we are done with a lookup don't close the connection right-away, but keep it open so that we + * can possibly reuse it later, and can save a bit of time on future lookups. We only keep a few + * around however. */ + + r = set_ensure_put(&h->idle_links, &varlink_hash_ops, vl); + if (r < 0) + log_debug_errno(r, "Failed to add varlink connection to idle set, ignoring: %m"); + else + sd_varlink_ref(vl); +} + +static void manager_gc_hooks(Manager *m, usec_t seen_usec) { + assert(m); + + Hook *h; + HASHMAP_FOREACH(h, m->hooks) { + /* Keep hooks around that have been seen in this iteration */ + if (h->seen_usec == seen_usec) + continue; + + hook_unlink(h); + } +} + +DEFINE_HASH_OPS_WITH_VALUE_DESTRUCTOR( + hook_hash_ops, + char, string_hash_func, string_compare_func, + Hook, hook_unlink); + +static int manager_hook_add(Manager *m, const char *p, usec_t seen_usec) { + int r; + + assert(m); + assert(p); + + Hook *found = hashmap_get(m->hooks, p); + if (found) { + found->seen_usec = seen_usec; + return 0; + } + + _cleanup_free_ char *s = strdup(p); + if (!s) + return log_oom(); + + _cleanup_(hook_unrefp) Hook *h = new(Hook, 1); + if (!h) + return log_oom(); + + *h = (Hook) { + .n_ref = 1, + .socket_path = TAKE_PTR(s), + .filter_labels_min = UINT_MAX, + .filter_labels_max = UINT_MAX, + .reconnect_ratelimit = { 1 * USEC_PER_SEC, 5 }, + .seen_usec = seen_usec, + }; + + if (hashmap_ensure_put(&m->hooks, &hook_hash_ops, h->socket_path, h) < 0) + return log_oom(); + + hook_ref(h); + h->manager = m; + + r = hook_acquire_filter(h); + if (r < 0) { + hook_unlink(h); + return r; + } + + return 0; +} + +static int manager_hook_discover(Manager *m) { + /* You might wonder, why is this /run/systemd/resolve.hook/ and not /run/systemd/resolve/hook/? + * That's because of permissions: resolved runs as "systemd-resolve" user and owns + * /run/systemd/resolve/, but the hook directory is where other privileged code shall bind a socket + * in (and where root ownership hence makes sense). Hence we do not nest the directories, but put + * them side by side, so that they can have different ownership. */ + static const char dp[] = "/run/systemd/resolve.hook"; + _cleanup_closedir_ DIR *d = NULL; + int r; + + assert(m); + + usec_t seen_usec = now(CLOCK_MONOTONIC); + + struct stat st; + if (stat(dp, &st) < 0) { + if (errno == ENOENT) + r = 0; + else + r = log_warning_errno(errno, "Failed to stat %s/: %m", dp); + + goto finish; + } + + if (stat_inode_unmodified(&st, &m->hook_stat)) + return 0; + + d = opendir(dp); + if (!d) { + if (errno == ENOENT) + r = 0; + else + r = log_warning_errno(errno, "Failed to enumerate %s/ contents: %m", dp); + + goto finish; + } + + for (;;) { + errno = 0; + struct dirent *de = readdir_no_dot(d); + if (!de) { + if (errno == 0) /* EOD */ + break; + + r = log_error_errno(errno, "Failed to enumerate %s/: %m", dp); + goto finish; + } + + if (!IN_SET(de->d_type, DT_SOCK, DT_UNKNOWN)) + continue; + + _cleanup_free_ char *p = path_join(dp, de->d_name); + if (!p) { + r = log_oom(); + goto finish; + } + + (void) manager_hook_add(m, p, seen_usec); + } + + m->hook_stat = st; + r = 0; + +finish: + manager_gc_hooks(m, seen_usec); + return r; +} + +typedef struct HookQuery HookQuery; +typedef struct HookQueryCandidate HookQueryCandidate; + +/* Encapsulates a query currently being processed by various hooks */ +struct HookQuery { + /* Question */ + DnsQuestion *question_idna; + DnsQuestion *question_utf8; + + /* Selected answer */ + DnsAnswer *answer; + int answer_rcode; + Hook *answer_hook; + + /* Candidates for a reply, i.e, one entry for each hook */ + LIST_HEAD(HookQueryCandidate, candidates); + + /* Completion callback to invoke */ + void (*complete)(HookQuery *q, int answer_rcode, DnsAnswer *answer, void *userdata); + void *userdata; +}; + +/* Encapsulates the state of a hook query to one specific hook */ +struct HookQueryCandidate { + HookQuery *query; + Hook *hook; + sd_varlink *link; + LIST_FIELDS(HookQueryCandidate, candidates); +}; + +static HookQueryCandidate* hook_query_candidate_free(HookQueryCandidate *c) { + if (!c) + return NULL; + + c->link = sd_varlink_unref(c->link); + + if (c->query) + LIST_REMOVE(candidates, c->query->candidates, c); + + hook_unref(c->hook); + return mfree(c); +} + +DEFINE_TRIVIAL_CLEANUP_FUNC(HookQueryCandidate*, hook_query_candidate_free); + +HookQuery* hook_query_free(HookQuery *hq) { + if (!hq) + return NULL; + + /* Free candidates as long as there are candidates */ + while (hq->candidates) + hook_query_candidate_free(hq->candidates); + + dns_question_unref(hq->question_utf8); + dns_question_unref(hq->question_idna); + dns_answer_unref(hq->answer); + hook_unref(hq->answer_hook); + + return mfree(hq); +} + +DEFINE_TRIVIAL_CLEANUP_FUNC(HookQuery*, hook_query_free); + +static void hook_query_ready(HookQuery *hq) { + assert(hq); + + bool done = true; + LIST_FOREACH(candidates, c, hq->candidates) + if (c->link) { /* ongoing connection? */ + done = false; + break; + } + + if (!done) + return; + + /* The complete() callback quite likely will destroy 'hq', which might be what keeps the answer + * object alive. Let's take an explicit ref here hence, so that it definitely remains alive for the + * whole callback lifetime */ + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = dns_answer_ref(hq->answer); + hq->complete(hq, hq->answer_rcode, answer, hq->userdata); +} + +static int dispatch_rcode(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + int *u = ASSERT_PTR(userdata), r; + + assert(variant); + + int rcode; + r = sd_json_dispatch_int(name, variant, flags, &rcode); + if (r < 0) + return r; + + if (rcode < 0 || rcode >= _DNS_RCODE_MAX) + return json_log(variant, flags, SYNTHETIC_ERRNO(ERANGE), "JSON field '%s' contains an invalid DNS rcode.", strna(name)); + + *u = rcode; + return 0; +} + +static int dispatch_answer(const char *name, sd_json_variant *variant, sd_json_dispatch_flags_t flags, void *userdata) { + DnsAnswer **a = ASSERT_PTR(userdata); + int r; + + assert(variant); + + if (sd_json_variant_is_null(variant)) { + *a = dns_answer_unref(*a); + return 0; + } + + if (!sd_json_variant_is_array(variant)) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), "JSON field '%s' is not an array.", strna(name)); + + _cleanup_(dns_answer_unrefp) DnsAnswer *l = NULL; + sd_json_variant *e; + JSON_VARIANT_ARRAY_FOREACH(e, variant) { + if (!sd_json_variant_is_object(e)) + return json_log(e, flags, SYNTHETIC_ERRNO(EINVAL), "JSON array element is not an object"); + + _cleanup_(iovec_done) struct iovec iovec = {}; + static const sd_json_dispatch_field dispatch_table[] = { + { "raw", SD_JSON_VARIANT_STRING, json_dispatch_unbase64_iovec, 0, SD_JSON_MANDATORY }, + { "rr", SD_JSON_VARIANT_OBJECT, NULL, 0, 0 }, + {} + }; + + r = sd_json_dispatch(e, dispatch_table, flags|SD_JSON_ALLOW_EXTENSIONS, &iovec); + if (r < 0) + return r; + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *rr = NULL; + r = dns_resource_record_new_from_raw(&rr, iovec.iov_base, iovec.iov_len); + if (r < 0) + return json_log(variant, flags, SYNTHETIC_ERRNO(EINVAL), + "JSON field '%s' contains an invalid resource record.", strna(name)); + + if (dns_answer_add_extend(&l, rr, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL) < 0) + return json_log_oom(e, flags); + } + + dns_answer_unref(*a); + *a = TAKE_PTR(l); + + return 0; +} + +typedef struct QueryReplyParameters { + int rcode; + DnsAnswer *answer; +} QueryReplyParameters; + +static void query_reply_parameters_done(QueryReplyParameters *p) { + assert(p); + + p->answer = dns_answer_unref(p->answer); +} + +static int on_query_reply( + sd_varlink *link, + sd_json_variant *parameters, + const char *error_id, + sd_varlink_reply_flags_t flags, + void *userdata) { + + HookQueryCandidate *qc = ASSERT_PTR(userdata); + HookQuery *q = ASSERT_PTR(qc->query); /* save early in case we destroy 'qc' half-way through this function */ + int r; + + assert(link); + + _cleanup_(query_reply_parameters_done) QueryReplyParameters p = { + .rcode = -1, + }; + + if (error_id) { + log_notice("Query on hook '%s' failed with error '%s', ignoring.", qc->hook->socket_path, error_id); + r = -EBADR; + goto destroy; + } + + static const sd_json_dispatch_field dispatch_table[] = { + { "rcode", _SD_JSON_VARIANT_TYPE_INVALID, dispatch_rcode, offsetof(QueryReplyParameters, rcode), 0 }, + { "answer", SD_JSON_VARIANT_ARRAY, dispatch_answer, offsetof(QueryReplyParameters, answer), 0 }, + {}, + }; + + r = sd_json_dispatch(parameters, dispatch_table, SD_JSON_LOG|SD_JSON_ALLOW_EXTENSIONS, &p); + if (r < 0) + goto destroy; + + if (p.rcode < 0) { + /* If no rcode is specified, then this means "continue with regular DNS based resolving" to us */ + log_debug("Query on hook '%s' returned empty reply, skipping.", qc->hook->socket_path); + r = 0; + goto destroy; + } + + bool win = false; + if (p.rcode == DNS_RCODE_SUCCESS) + /* if this is a successful lookup, let it win if the so far best lookup was a failure or + * empty, or ordered later than us */ + win = q->answer_rcode != DNS_RCODE_SUCCESS || + dns_answer_isempty(q->answer) || + (!dns_answer_isempty(p.answer) && + hook_compare(qc->hook, q->answer_hook) < 0); + else + /* if this is a failure lookup, let it win if we so far haven't seen any reply at all, or the + * winner so far us ordered later than us. */ + win = q->answer_rcode < 0 || + hook_compare(qc->hook, q->answer_hook) < 0; + + if (win) { + /* This reply wins over whatever was stored before. Let's track that */ + dns_answer_unref(q->answer); + q->answer = TAKE_PTR(p.answer); + q->answer_rcode = p.rcode; + hook_unref(q->answer_hook); + q->answer_hook = hook_ref(qc->hook); + } + + hook_recycle_varlink(qc->hook, qc->link); + qc->link = sd_varlink_unref(qc->link); + + /* Check if we are ready now, and have processed all hooks on this query (this might destroy our + * candidate and our hook query!) */ + hook_query_ready(q); + return 0; + +destroy: + qc = hook_query_candidate_free(qc); + hook_query_ready(q); + return r; +} + +static int dns_questions_to_json(DnsQuestion *a, DnsQuestion *b, sd_json_variant **ret) { + int r; + + assert(ret); + + /* Takes both questions and turns them into a JSON array of objects with the key. Note this takes two + * questions, one in IDNA and one in UTF-8 encoding, and merges them, removing duplicates. */ + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *l = NULL; + + DnsResourceKey *key; + DNS_QUESTION_FOREACH(key, a) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + r = dns_resource_key_to_json(key, &v); + if (r < 0) + return r; + + r = sd_json_variant_append_arraybo(&l, SD_JSON_BUILD_PAIR_VARIANT("key", v)); + if (r < 0) + return r; + } + + if (a != b) { + DNS_QUESTION_FOREACH(key, b) { + if (dns_question_contains_key(a, key)) + continue; + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *v = NULL; + r = dns_resource_key_to_json(key, &v); + if (r < 0) + return r; + + r = sd_json_variant_append_arraybo(&l, SD_JSON_BUILD_PAIR_VARIANT("key", v)); + if (r < 0) + return r; + } + } + + *ret = TAKE_PTR(l); + return 0; +} + +static int hook_query_add_candidate(HookQuery *hq, Hook *h) { + int r; + + assert(hq); + assert(h); + + _cleanup_(sd_varlink_unrefp) sd_varlink *vl = NULL; + for (;;) { + /* Before we create a new connection, let's see if there are still idle connections we can + * use. */ + vl = set_steal_first(h->idle_links); + if (!vl) { + /* Nope, there's nothing, let's create a new connection */ + r = hook_varlink_connect(h, SD_EVENT_PRIORITY_NORMAL, &vl); + if (r <= 0) + return r; + + break; + } + + r = sd_varlink_is_connected(vl); + if (r < 0) + return log_error_errno(r, "Failed to check if varlink connection is connected: %m"); + if (r > 0) + break; + + vl = sd_varlink_unref(vl); + } + + /* Set a short timeout for hooks. Hooks should not be able to cause the DNS part of the lookup to fail. */ + r = sd_varlink_set_relative_timeout(vl, SD_RESOLVED_QUERY_TIMEOUT_USEC/4); + if (r < 0) + return log_error_errno(r, "Failed to set Varlink connection timeout: %m"); + + r = sd_varlink_bind_reply(vl, on_query_reply); + if (r < 0) + return log_error_errno(r, "Failed to bind reply callback to Varlink connection: %m"); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *jq = NULL; + r = dns_questions_to_json(hq->question_idna, hq->question_utf8, &jq); + if (r < 0) + return log_error_errno(r, "Failed to convert question to JSON: %m"); + + r = sd_varlink_invokebo( + vl, + "io.systemd.Resolve.Hook.ResolveRecord", + SD_JSON_BUILD_PAIR_VARIANT("question", jq)); + if (r < 0) + return log_error_errno(r, "Failed to enqueue question onto Varlink connection: %m"); + + _cleanup_(hook_query_candidate_freep) HookQueryCandidate *qc = new(HookQueryCandidate, 1); + if (!qc) + return log_oom(); + + qc->query = hq; + qc->hook = hook_ref(h); + qc->link = TAKE_PTR(vl); + LIST_PREPEND(candidates, hq->candidates, qc); + + sd_varlink_set_userdata(qc->link, qc); + + TAKE_PTR(qc); + + return 0; +} + +static bool use_hooks(void) { + static int cache = -1; + int r; + + if (cache >= 0) + return cache; + + r = secure_getenv_bool("SYSTEMD_RESOLVED_HOOK"); + if (r < 0) { + if (r != -ENXIO) + log_debug_errno(r, "Failed to parse $SYSTEMD_RESOLVED_HOOK, ignoring: %m"); + + return (cache = true); + } + + return (cache = r); +} + +int manager_hook_query( + Manager *m, + DnsQuestion *question_idna, + DnsQuestion *question_utf8, + HookCompleteCallback complete_cb, + void *userdata, + HookQuery **ret) { + + int r; + + assert(m); + assert(ret); + + if (!use_hooks()) { + *ret = NULL; + return 0; /* no relevant hooks, continue immediately */ + } + + /* Let's bring our list of hooks up-to-date */ + (void) manager_hook_discover(m); + + _cleanup_(hook_query_freep) HookQuery *hq = NULL; + + Hook *h; + HASHMAP_FOREACH(h, m->hooks) { + r = hook_test_filter(h, question_idna); + if (r < 0) { + log_warning_errno( + r, "Failed to test if hook '%s' matches IDNA question (%s), assuming not.", + h->socket_path, dns_question_first_name(question_idna)); + continue; + } + if (r == 0) { + r = hook_test_filter(h, question_utf8); + if (r < 0) { + log_warning_errno( + r, "Failed to test if hook '%s' matches UTF-8 question (%s), assuming not.", + h->socket_path, dns_question_first_name(question_utf8)); + continue; + } + if (r == 0) { + log_debug("Hook %s does not match question, skipping.", h->socket_path); + continue; + } + } + + if (!hq) { + hq = new(HookQuery, 1); + if (!hq) + return log_oom(); + + *hq = (HookQuery) { + .question_idna = dns_question_ref(question_idna), + .question_utf8 = dns_question_ref(question_utf8), + .answer_rcode = -1, + .complete = complete_cb, + .userdata = userdata, + }; + } + + r = hook_query_add_candidate(hq, h); + if (r < 0) + return r; + } + + if (!hq || !hq->candidates) { + *ret = NULL; + return 0; /* no relevant hooks, continue immediately */ + } + + *ret = TAKE_PTR(hq); + return 1; /* please wait for the hooks to reply */ +} diff --git a/src/resolve/resolved-hook.h b/src/resolve/resolved-hook.h new file mode 100644 index 0000000000..1365455a23 --- /dev/null +++ b/src/resolve/resolved-hook.h @@ -0,0 +1,10 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "resolved-forward.h" + +typedef void (HookCompleteCallback)(HookQuery *q, int rcode, DnsAnswer *answer, void *userdata); + +int manager_hook_query(Manager *m, DnsQuestion *question_idna, DnsQuestion *question_utf8, HookCompleteCallback complete_cb, void *userdata, HookQuery **ret); + +HookQuery* hook_query_free(HookQuery *hq); diff --git a/src/resolve/resolved-manager.c b/src/resolve/resolved-manager.c index fe4807685a..d256804dbc 100644 --- a/src/resolve/resolved-manager.c +++ b/src/resolve/resolved-manager.c @@ -918,6 +918,8 @@ Manager* manager_free(Manager *m) { dns_service_browser_free(sb); hashmap_free(m->dns_service_browsers); + hashmap_free(m->hooks); + return mfree(m); } diff --git a/src/resolve/resolved-manager.h b/src/resolve/resolved-manager.h index 68e9a6e4ea..7f8a0e0ceb 100644 --- a/src/resolve/resolved-manager.h +++ b/src/resolve/resolved-manager.h @@ -159,6 +159,9 @@ typedef struct Manager { /* Map varlink links to DnsServiceBrowser instances. */ Hashmap *dns_service_browsers; + + Hashmap *hooks; + struct stat hook_stat; } Manager; /* Manager */ diff --git a/src/resolve/resolved-varlink.c b/src/resolve/resolved-varlink.c index 90f4a9fffa..89d84e8eb2 100644 --- a/src/resolve/resolved-varlink.c +++ b/src/resolve/resolved-varlink.c @@ -700,7 +700,7 @@ static void resolve_service_all_complete(DnsQuery *query) { assert(q); - if (q->block_all_complete > 0) { + if (q->hook_query || q->block_all_complete > 0) { TAKE_PTR(q); return; } @@ -710,6 +710,13 @@ static void resolve_service_all_complete(DnsQuery *query) { bool have_success = false; LIST_FOREACH(auxiliary_queries, aux, q->auxiliary_queries) { + + if (aux->hook_query) { + /* If an auxiliary query's hook is still pending, let's wait */ + TAKE_PTR(q); + return; + } + switch (aux->state) { case DNS_TRANSACTION_PENDING: diff --git a/src/resolve/test-dns-query.c b/src/resolve/test-dns-query.c index 64114f5abc..de561aa8b7 100644 --- a/src/resolve/test-dns-query.c +++ b/src/resolve/test-dns-query.c @@ -1,5 +1,7 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include + #include "sd-event.h" #include "dns-answer.h" @@ -890,4 +892,10 @@ TEST(dns_query_go) { exercise_dns_query_go(&cfg, NULL); } -DEFINE_TEST_MAIN(LOG_DEBUG); +static int intro(void) { + /* Disable hooks in order to make test cases hermetic */ + ASSERT_OK_ERRNO(setenv("SYSTEMD_RESOLVED_HOOK", "0", /* overwrite= */ false)); + return EXIT_SUCCESS; +} + +DEFINE_TEST_MAIN_WITH_INTRO(LOG_DEBUG, intro); diff --git a/src/shared/meson.build b/src/shared/meson.build index bc927e4cca..0cf0324f97 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -217,6 +217,7 @@ shared_sources = files( 'varlink-io.systemd.PCRLock.c', 'varlink-io.systemd.Repart.c', 'varlink-io.systemd.Resolve.c', + 'varlink-io.systemd.Resolve.Hook.c', 'varlink-io.systemd.Resolve.Monitor.c', 'varlink-io.systemd.Udev.c', 'varlink-io.systemd.Unit.c', diff --git a/src/shared/resolved-def.h b/src/shared/resolved-def.h index e00bd204d7..68a3d91179 100644 --- a/src/shared/resolved-def.h +++ b/src/shared/resolved-def.h @@ -80,10 +80,13 @@ #define SD_RESOLVED_QUERY_CONTINUOUS \ (UINT64_C(1) << 26) +/* Output: Result was answered by hook */ +#define SD_RESOLVED_FROM_HOOK (UINT64_C(1) << 27) + #define SD_RESOLVED_LLMNR (SD_RESOLVED_LLMNR_IPV4|SD_RESOLVED_LLMNR_IPV6) #define SD_RESOLVED_MDNS (SD_RESOLVED_MDNS_IPV4|SD_RESOLVED_MDNS_IPV6) #define SD_RESOLVED_PROTOCOLS_ALL (SD_RESOLVED_MDNS|SD_RESOLVED_LLMNR|SD_RESOLVED_DNS) -#define SD_RESOLVED_FROM_MASK (SD_RESOLVED_FROM_CACHE|SD_RESOLVED_FROM_ZONE|SD_RESOLVED_FROM_TRUST_ANCHOR|SD_RESOLVED_FROM_NETWORK) +#define SD_RESOLVED_FROM_MASK (SD_RESOLVED_FROM_CACHE|SD_RESOLVED_FROM_ZONE|SD_RESOLVED_FROM_TRUST_ANCHOR|SD_RESOLVED_FROM_NETWORK|SD_RESOLVED_FROM_HOOK) #define SD_RESOLVED_QUERY_TIMEOUT_USEC (120 * USEC_PER_SEC) diff --git a/src/shared/varlink-io.systemd.Resolve.Hook.c b/src/shared/varlink-io.systemd.Resolve.Hook.c new file mode 100644 index 0000000000..a172a95a67 --- /dev/null +++ b/src/shared/varlink-io.systemd.Resolve.Hook.c @@ -0,0 +1,81 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.Resolve.Hook.h" + +/* We want to reuse the ResourceKey structure from the io.systemd.Resolve interface, hence import it here */ +#include "varlink-io.systemd.Resolve.h" + +static SD_VARLINK_DEFINE_STRUCT_TYPE( + Answer, + SD_VARLINK_FIELD_COMMENT("A resource record that shall be looked up. Note that this field is (currently) mostly " + "decoration, useful for debugging, and may be omitted. The data actually used is encoded in the " + "'raw' field."), + SD_VARLINK_DEFINE_FIELD_BY_TYPE(rr, ResourceRecord, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("A resource record encoded in DNS wire format, in turn encoded in Base64. This is the actual data " + "returned to the application, and should carry the same information as the 'rr' field, just in a " + "different encoding."), + SD_VARLINK_DEFINE_FIELD(raw, SD_VARLINK_STRING, 0)); + +static SD_VARLINK_DEFINE_STRUCT_TYPE( + Question, + SD_VARLINK_FIELD_COMMENT("A resource record key that shall be looked up."), + SD_VARLINK_DEFINE_FIELD_BY_TYPE(key, ResourceKey, 0)); + +static SD_VARLINK_DEFINE_METHOD_FULL( + QueryFilter, + SD_VARLINK_SUPPORTS_MORE, + SD_VARLINK_FIELD_COMMENT("A list of domains this hook is interested in. Lookups for domains not listed here will not be " + "passed to the Hook via ResolveRecord(). If this field is not set, requests for all domains " + "will be passed to the hook. Note that this applies recursively, i.e. a domain of a lookup is " + "considered matching the listed domains both if it exactly matches it, and in case only a suffix " + "of it matches it. If this is set to an empty array the hook is disabled."), + SD_VARLINK_DEFINE_OUTPUT(filterDomains, SD_VARLINK_STRING, SD_VARLINK_NULLABLE|SD_VARLINK_ARRAY), + SD_VARLINK_FIELD_COMMENT("Require the specified number of labels or more in a domain for the hook to be considered."), + SD_VARLINK_DEFINE_OUTPUT(filterLabelsMin, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Require the specified number of labels or less in a domain for the hook to be considered."), + SD_VARLINK_DEFINE_OUTPUT(filterLabelsMax, SD_VARLINK_INT, SD_VARLINK_NULLABLE)); + +static SD_VARLINK_DEFINE_METHOD( + ResolveRecord, + SD_VARLINK_FIELD_COMMENT("The question being looked up, i.e. a combination of resource record keys. Note that unlike DNS " + "queries on the wire these lookups can carry multiple key requests, albeit closely related ones. " + "Specifically, lookups for A+AAAA for the the same hostname are submitted as one question, as " + "are lookups for TXT+SRV when doing DNS-SD resolution. Moreover, when looking up resources with " + "non-ASCII characters, they are placed together in a single question, once with labels encoded in " + "UTF-8, and once in IDNA. Hook implementations must be able to deal with these and other similar " + "combinations of resource key requests, and reply with all matching answers at once, or fail them " + "as one. Partial success/failure combinations are not supported."), + SD_VARLINK_DEFINE_INPUT_BY_TYPE(question, Question, SD_VARLINK_ARRAY), + SD_VARLINK_FIELD_COMMENT("A DNS response code. If a hook sets this return parameter further processing of the lookup via " + "regular proocols such as DNS, LLMNR, mDNS is skipped, and the return code returned immediately. " + "In other words, if a hook intends to let the request pass to normal resolution, it should not " + "set this return parameter."), + SD_VARLINK_DEFINE_OUTPUT(rcode, SD_VARLINK_INT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("An answer for a lookup, i.e. a combination of resource records, matching the request. This " + "should only be set when the 'rcode' parameter is returned as 0 (SUCCESS)."), + SD_VARLINK_DEFINE_OUTPUT_BY_TYPE(answer, Answer, SD_VARLINK_ARRAY|SD_VARLINK_NULLABLE)); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_Resolve_Hook, + "io.systemd.Resolve.Hook", + SD_VARLINK_INTERFACE_COMMENT("Generic interface for implementing a domain name resolution hook."), + SD_VARLINK_SYMBOL_COMMENT("Encapsulates a positive lookup answer"), + &vl_type_Answer, + SD_VARLINK_SYMBOL_COMMENT("Encapsulates a lookup question"), + &vl_type_Question, + SD_VARLINK_SYMBOL_COMMENT("Encapsulates the class/type/name part of a DNS resource record."), + &vl_type_ResourceKey, + SD_VARLINK_SYMBOL_COMMENT("Encapsulates a DNS resource record."), + &vl_type_ResourceRecord, + SD_VARLINK_SYMBOL_COMMENT("Returns filter parameters for this hook. A hook service can implement this to reduce lookup " + "requests, by enabling itself only for certain domains or certain numbers of labels in the name. " + "It's recommended to implement this to reduce the number of redundant calls to each hook. Note " + "that this is advisory only, and implementing services must be able to gracefully handle lookup " + "requests that do not match this filter. This call is usually made with the 'more' flag set, in " + "which case the connection is left open after the first reply, and the implementing hook " + "services can send updates to the filter at any time. Whenever a further reply is sent the " + "filter configured therein fully replaces any previously communicated filter."), + &vl_method_QueryFilter, + SD_VARLINK_SYMBOL_COMMENT("Sent whenever a resolution request is made. This typically takes the filter paramaters returned " + "by QueryFilter() into account, but this is not guaranteed."), + &vl_method_ResolveRecord); diff --git a/src/shared/varlink-io.systemd.Resolve.Hook.h b/src/shared/varlink-io.systemd.Resolve.Hook.h new file mode 100644 index 0000000000..0c371572d1 --- /dev/null +++ b/src/shared/varlink-io.systemd.Resolve.Hook.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_Resolve_Hook; diff --git a/src/test/test-varlink-idl.c b/src/test/test-varlink-idl.c index cecc64b3fd..e3fe38f6ca 100644 --- a/src/test/test-varlink-idl.c +++ b/src/test/test-varlink-idl.c @@ -37,6 +37,7 @@ #include "varlink-io.systemd.PCRLock.h" #include "varlink-io.systemd.Repart.h" #include "varlink-io.systemd.Resolve.h" +#include "varlink-io.systemd.Resolve.Hook.h" #include "varlink-io.systemd.Resolve.Monitor.h" #include "varlink-io.systemd.Udev.h" #include "varlink-io.systemd.Unit.h" @@ -192,6 +193,7 @@ TEST(parse_format) { &vl_interface_io_systemd_PCRLock, &vl_interface_io_systemd_Repart, &vl_interface_io_systemd_Resolve, + &vl_interface_io_systemd_Resolve_Hook, &vl_interface_io_systemd_Resolve_Monitor, &vl_interface_io_systemd_Udev, &vl_interface_io_systemd_Unit,