diff --git a/src/resolve/meson.build b/src/resolve/meson.build index 4ea7b3a4ca..b246c0c402 100644 --- a/src/resolve/meson.build +++ b/src/resolve/meson.build @@ -120,6 +120,9 @@ executables += [ resolve_test_template + { 'sources' : files('test-dns-question.c'), }, + resolve_test_template + { + 'sources' : files('test-mdns-announce.c'), + }, resolve_test_template + { 'sources' : files('test-resolved-etc-hosts.c'), }, diff --git a/src/resolve/resolved-dns-scope.c b/src/resolve/resolved-dns-scope.c index 6a20fa20da..7e19f12469 100644 --- a/src/resolve/resolved-dns-scope.c +++ b/src/resolve/resolved-dns-scope.c @@ -1533,10 +1533,57 @@ static int on_announcement_timeout(sd_event_source *s, usec_t usec, void *userda return 0; } -int dns_scope_send_goodbye(DnsScope *scope, DnsAnswer *answer) { - _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL; - int r; +static int dns_scope_emit_announcement(DnsScope *scope, DnsAnswer *answer) { + size_t max_size, fragmented_max; + unsigned n_sent = 0, n_records = 0; + int r, ret = 0; + assert(scope); + assert(scope->protocol == DNS_PROTOCOL_MDNS); + + /* Once the loop is finished the mDNS sockets are gone, and opening one would register I/O + * on it. Guarded here, in the one emitter every announcement and withdrawal goes through. */ + r = sd_event_get_state(scope->manager->event); + if (r < 0) + return log_debug_errno(r, "Failed to get event loop state: %m"); + if (r == SD_EVENT_FINISHED) + return 0; + + /* An mDNS scope always belongs to a link; its MTU may still be unknown (0) this early. */ + assert(scope->link); + mdns_announcement_max_sizes(scope->family, scope->link->mtu, &max_size, &fragmented_max); + + DnsPacket **packets = NULL; + size_t n_packets = 0; + CLEANUP_ARRAY(packets, n_packets, dns_packet_unref_array); + + r = mdns_announcement_packetize(answer, max_size, fragmented_max, &packets, &n_packets); + if (r < 0) + return r; + if (r > 0) + /* Warn, not debug: a withdrawal's caller drops these from the zone regardless, so a + * record skipped here is never withdrawn and peers keep it for its full TTL. */ + log_warning("Cannot fit %i mDNS record(s) into any packet on scope %s, not sending them.", + r, dns_scope_ifname(scope) ?: "*"); + + /* Best effort per packet, so one failed send does not withhold the rest. Each packet also + * takes its own token from the scope's multicast rate limit, which bounds datagrams. */ + FOREACH_ARRAY(p, packets, n_packets) { + r = dns_scope_emit_udp(scope, /* fd= */ -EBADF, AF_UNSPEC, *p); + RET_GATHER(ret, r); + if (r >= 0) { + n_sent++; + n_records += DNS_PACKET_ANCOUNT(*p); + } + } + + log_debug("Emitted %u mDNS announcement packet(s) carrying %u record(s) on scope %s.", + n_sent, n_records, dns_scope_ifname(scope) ?: "*"); + + return ret; +} + +int dns_scope_send_goodbye(DnsScope *scope, DnsAnswer *answer) { assert(scope); assert(answer); @@ -1546,32 +1593,14 @@ int dns_scope_send_goodbye(DnsScope *scope, DnsAnswer *answer) { if (scope->protocol != DNS_PROTOCOL_MDNS) return 0; - r = sd_event_get_state(scope->manager->event); - if (r < 0) - return log_debug_errno(r, "Failed to get event loop state: %m"); - - /* If this is called on exit, through manager_free() -> link_free(), then we cannot announce. */ - if (r == SD_EVENT_FINISHED) - return 0; - if (dns_answer_isempty(answer)) return 0; - r = dns_scope_make_reply_packet(scope, /* id= */ 0, DNS_RCODE_SUCCESS, /* q= */ NULL, answer, - /* soa= */ NULL, /* tentative= */ false, &p); - if (r < 0) - return log_debug_errno(r, "Failed to build reply packet: %m"); - - r = dns_scope_emit_udp(scope, -EBADF, AF_UNSPEC, p); - if (r < 0) - return log_debug_errno(r, "Failed to send reply packet: %m"); - - return 0; + return dns_scope_emit_announcement(scope, answer); } int dns_scope_announce(DnsScope *scope, bool goodbye) { _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; - _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL; _cleanup_set_free_ Set *types = NULL; DnsZoneItem *z; unsigned size = 0; @@ -1679,13 +1708,14 @@ int dns_scope_announce(DnsScope *scope, bool goodbye) { if (dns_answer_isempty(answer)) return 0; - r = dns_scope_make_reply_packet(scope, 0, DNS_RCODE_SUCCESS, NULL, answer, NULL, false, &p); + /* Emission is best effort per packet, so a single failed send (a rate limit, a transient + * -ENOBUFS) does not cost the announcement its second transmission below. That repetition + * is scheduled by a pass that finds the scope unannounced -- its first, and any after the + * scope's records were re-added -- and is the only retry a lost packet gets: a send that + * fails on a pass that schedules nothing is logged and not repeated. */ + r = dns_scope_emit_announcement(scope, answer); if (r < 0) - return log_debug_errno(r, "Failed to build reply packet: %m"); - - r = dns_scope_emit_udp(scope, -1, AF_UNSPEC, p); - if (r < 0) - return log_debug_errno(r, "Failed to send reply packet: %m"); + log_debug_errno(r, "Failed to emit some announcement packets, ignoring: %m"); /* In section 8.3 of RFC6762: "The Multicast DNS responder MUST send at least two unsolicited * responses, one second apart." */ diff --git a/src/resolve/resolved-mdns.c b/src/resolve/resolved-mdns.c index fb20ba9cd0..74c81fd736 100644 --- a/src/resolve/resolved-mdns.c +++ b/src/resolve/resolved-mdns.c @@ -1,6 +1,8 @@ /* SPDX-License-Identifier: LGPL-2.1-or-later */ +#include #include +#include #include "sd-event.h" @@ -699,3 +701,206 @@ int manager_mdns_ipv6_fd(Manager *m) { return m->mdns_ipv6_fd = TAKE_FD(s); } + +static int mdns_announcement_packet_new(size_t max_size, DnsPacket **ret) { + _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL; + int r; + + assert(ret); + + r = dns_packet_new(&p, DNS_PROTOCOL_MDNS, /* min_alloc_dsize= */ 0, max_size); + if (r < 0) + return r; + + DNS_PACKET_HEADER(p)->flags = htobe16(DNS_PACKET_MAKE_FLAGS( + 1 /* qr */, + 0 /* opcode */, + 1 /* aa, see RFC 6762, section 18.4 */, + 0 /* tc */, + 0 /* (tentative) */, + 0 /* (ra) */, + 0 /* (ad) */, + 0 /* (cd) */, + DNS_RCODE_SUCCESS)); + + *ret = TAKE_PTR(p); + return 0; +} + +/* Append one record to the packet under construction, creating it first if there is none, and + * count it in the packet's own header. Returns -EMSGSIZE when the record does not fit, which the + * caller answers by sealing what it has (or by widening the packet), exactly as it would for a bare + * dns_packet_append_rr(). */ +static int mdns_announcement_packet_append(DnsPacket **p, size_t max_size, DnsAnswerItem *item) { + int r; + + assert(p); + assert(item); + + if (!*p) { + r = mdns_announcement_packet_new(max_size, p); + if (r < 0) + return r; + } + + r = dns_packet_append_rr(*p, item->rr, item->flags, + /* start= */ NULL, /* rdata_start= */ NULL); + if (r < 0) + return r; + + DNS_PACKET_HEADER(*p)->ancount = htobe16(DNS_PACKET_ANCOUNT(*p) + 1); + return 0; +} + +/* Push the announcement packet under construction onto the result array; its ancount has been kept + * current by every append. A no-op without a packet or without any RRs appended yet. */ +static int mdns_announcement_packet_seal( + DnsPacket ***packets, + size_t *n_packets, + DnsPacket **p) { + + assert(packets); + assert(n_packets); + assert(p); + + if (!*p || DNS_PACKET_ANCOUNT(*p) == 0) + return 0; + + if (!GREEDY_REALLOC(*packets, *n_packets + 1)) + return -ENOMEM; + + (*packets)[(*n_packets)++] = TAKE_PTR(*p); + + return 0; +} + +/* The datagram size every host of the family must be able to accept: RFC 791 section 3.1 for IPv4, + * RFC 8200 section 5's minimum link MTU for IPv6, which no link fragments. */ +static size_t fallback_mtu(int family) { + assert(IN_SET(family, AF_INET, AF_INET6)); + + return family == AF_INET6 ? IPV6_MIN_MTU : MDNS_PACKET_UNKNOWN_MTU_IPV4; +} + +/* Derive the two size bounds an announcement is packed to, from the address family and the link MTU + * (0 while the link's MTU is not known yet, or too small to carry a packet). + * + * RFC 6762 section 17's hard ceiling -- "even when fragmentation is used, a Multicast DNS packet, + * including IP and UDP headers, MUST NOT exceed 9000 bytes" -- binds every packet we emit, so it caps + * the MTU-derived size on jumbo-frame links just as much as it caps the lone oversized-RR fallback. + * A fragmented IPv6 datagram additionally carries an 8-byte Fragment extension header (RFC 8200 + * section 4.5) that counts against the ceiling. */ +void mdns_announcement_max_sizes( + int family, + size_t link_mtu, + size_t *ret_max_size, + size_t *ret_fragmented_max) { + + size_t header_size, fragmented_max, max_size; + + assert(IN_SET(family, AF_INET, AF_INET6)); + assert(ret_max_size); + assert(ret_fragmented_max); + + header_size = udp_header_size(family); + assert(MDNS_PACKET_FRAGMENTED_SIZE_MAX > + header_size + sizeof(struct ip6_frag) + DNS_PACKET_HEADER_SIZE); + fragmented_max = MDNS_PACKET_FRAGMENTED_SIZE_MAX - header_size - + (family == AF_INET6 ? sizeof(struct ip6_frag) : 0); + + /* An unsolicited DNS-SD announcement (or goodbye) covers the whole zone, which easily outgrows + * both the compression pointer range that dns_packet_append_name() can address and the interface + * MTU that RFC 6762 section 17 expects multicast DNS messages to fit into. Instead of emitting + * one oversized datagram, split the RRset across as many MTU-sized packets as needed. */ + if (link_mtu > header_size + DNS_PACKET_HEADER_SIZE) + max_size = link_mtu - header_size; + else + /* Nothing to derive from: the MTU is not read yet (0), or subtracting the headers + * would underflow or leave less than the DNS header dns_packet_new() asserts room + * for. Assume what every host of the family must accept instead -- not the section + * 17 ceiling, which is the bound for a datagram deliberately fragmented to carry one + * record. A derivable bound is kept even where no record fits it: the records then + * go out one per fragmented packet, which is the only shape section 17 allows. */ + max_size = fallback_mtu(family) - header_size; + + *ret_max_size = MIN(max_size, fragmented_max); + *ret_fragmented_max = fragmented_max; +} + +/* Pack the answer's records into as many announcement packets of at most max_size bytes as needed, + * in answer order, each with its ancount finalized. A single record that does not fit any packet + * on its own is retried alone within fragmented_max -- RFC 6762 section 17 allows a lone resource + * record to rely on IP fragmentation, up to a hard 9000-byte ceiling including headers -- and + * dropped if even that bound cannot accommodate it, as is a record that cannot be encoded at any + * size. Returns how many were dropped that way, so a caller can report it: nothing else + * distinguishes an announcement that carried everything from one that did not. */ +int mdns_announcement_packetize( + DnsAnswer *answer, + size_t max_size, + size_t fragmented_max, + DnsPacket ***ret_packets, + size_t *ret_n_packets) { + + _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0; + DnsAnswerItem *item; + unsigned n_skipped = 0; + int r; + + assert(max_size <= fragmented_max); + assert(ret_packets); + assert(ret_n_packets); + + CLEANUP_ARRAY(packets, n_packets, dns_packet_unref_array); + + DNS_ANSWER_FOREACH_ITEM(item, answer) { + r = mdns_announcement_packet_append(&p, max_size, item); + if (r == -EMSGSIZE && p && DNS_PACKET_ANCOUNT(p) > 0) { + /* Packet full -- seal it and retry this RR in a fresh one. */ + r = mdns_announcement_packet_seal(&packets, &n_packets, &p); + if (r < 0) + return r; + + r = mdns_announcement_packet_append(&p, max_size, item); + } + if (r == -EMSGSIZE && max_size < fragmented_max) { + /* A single RR larger than the MTU. Emit it alone in an oversized packet + * within the section 17 fragmented-packet ceiling. */ + p = dns_packet_unref(p); + + r = mdns_announcement_packet_append(&p, fragmented_max, item); + if (r >= 0) { + /* Sealed alone: nothing that follows may be packed to this bound. */ + r = mdns_announcement_packet_seal(&packets, &n_packets, &p); + if (r < 0) + return r; + + continue; + } + } + /* No packet we can build fits this record: too large even for the section 17 + * ceiling, or not encodable at all (-E2BIG; -ENOSPC is defensive, no bound we pack + * to lets a packet grow that far). Skip it rather than fail the announcement -- + * dns_packet_append_rr() rolled the packet back, so its neighbours are intact. An + * empty packet is dropped: the fragmented retry may have widened it, and the + * records that follow must not be packed to that bound. */ + if (IN_SET(r, -EMSGSIZE, -ENOSPC, -E2BIG)) { + log_debug_errno(r, "Skipping resource record that does not fit an mDNS packet: %m"); + if (p && DNS_PACKET_ANCOUNT(p) == 0) + p = dns_packet_unref(p); + n_skipped++; + continue; + } + if (r < 0) + return r; + } + + r = mdns_announcement_packet_seal(&packets, &n_packets, &p); + if (r < 0) + return r; + + *ret_n_packets = n_packets; + *ret_packets = TAKE_PTR(packets); + return (int) n_skipped; +} diff --git a/src/resolve/resolved-mdns.h b/src/resolve/resolved-mdns.h index fd9ecc944f..d68a6af46e 100644 --- a/src/resolve/resolved-mdns.h +++ b/src/resolve/resolved-mdns.h @@ -6,9 +6,31 @@ #define MDNS_PORT 5353 #define MDNS_ANNOUNCE_DELAY (1 * USEC_PER_SEC) +/* RFC 6762 section 17: "Even when fragmentation is used, a Multicast DNS packet, including IP and UDP + * headers, MUST NOT exceed 9000 bytes." */ +#define MDNS_PACKET_FRAGMENTED_SIZE_MAX 9000U + +/* What to assume while the interface MTU is not known yet: the datagram size every host of the + * family must be able to accept, RFC 791 section 3.1 -- whole or reassembled from fragments. (For + * IPv6 the corresponding bound is IPV6_MIN_MTU, RFC 8200 section 5's minimum link MTU, which is a + * stronger guarantee: no link fragments a datagram that size.) */ +#define MDNS_PACKET_UNKNOWN_MTU_IPV4 576U + int manager_mdns_ipv4_fd(Manager *m); int manager_mdns_ipv6_fd(Manager *m); void manager_mdns_stop(Manager *m); void manager_mdns_maybe_stop(Manager *m); int manager_mdns_start(Manager *m); + +void mdns_announcement_max_sizes( + int family, + size_t link_mtu, + size_t *ret_max_size, + size_t *ret_fragmented_max); +int mdns_announcement_packetize( + DnsAnswer *answer, + size_t max_size, + size_t fragmented_max, + DnsPacket ***ret_packets, + size_t *ret_n_packets); diff --git a/src/resolve/test-mdns-announce.c b/src/resolve/test-mdns-announce.c new file mode 100644 index 0000000000..e95e215b6e --- /dev/null +++ b/src/resolve/test-mdns-announce.c @@ -0,0 +1,578 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include + +#include "alloc-util.h" +#include "dns-answer.h" +#include "dns-packet.h" +#include "dns-rr.h" +#include "macro.h" +#include "resolved-mdns.h" +#include "string-util.h" +#include "strv.h" +#include "tests.h" + +static DnsResourceRecord* a_rr(const char *name) { + DnsResourceRecord *rr; + + rr = dns_resource_record_new_full(DNS_CLASS_IN, DNS_TYPE_A, name); + ASSERT_NOT_NULL(rr); + rr->ttl = 120; + rr->a.in_addr.s_addr = htobe32(0xC0A80101); + + return rr; +} + +/* A TXT record with a single character string of the given size. */ +static DnsResourceRecord* txt_rr(const char *name, size_t item_size) { + DnsResourceRecord *rr; + DnsTxtItem *item; + + rr = dns_resource_record_new_full(DNS_CLASS_IN, DNS_TYPE_TXT, name); + ASSERT_NOT_NULL(rr); + rr->ttl = 120; + + item = malloc0(offsetof(DnsTxtItem, data) + item_size + 1); + ASSERT_NOT_NULL(item); + item->length = item_size; + memset(item->data, 'x', item_size); + rr->txt.items = item; + + return rr; +} + +/* The wire size one record takes at the start of a fresh packet. The test names below share no + * suffix (except where a testcase says so), so compression never shrinks later occurrences and + * every record costs this much. */ +static size_t rr_wire_size(DnsResourceRecord *rr) { + _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL; + size_t before; + + ASSERT_OK(dns_packet_new(&p, DNS_PROTOCOL_MDNS, /* min_alloc_dsize= */ 0, DNS_PACKET_SIZE_MAX)); + before = p->size; + ASSERT_OK(dns_packet_append_rr(p, rr, /* flags= */ 0, /* start= */ NULL, /* rdata_start= */ NULL)); + + return p->size - before; +} + +/* Adds a record for each name and returns their common wire size, which the packing bounds in the + * cases below are expressed in multiples of -- so it also asserts that they really are equal. */ +static size_t answer_add_equal_sized(DnsAnswer *answer, char * const *names) { + size_t sz = 0; + + assert(answer); + + STRV_FOREACH(name, names) { + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *rr = a_rr(*name); + + if (sz == 0) + sz = rr_wire_size(rr); + else + ASSERT_EQ(rr_wire_size(rr), sz); + + ASSERT_OK(dns_answer_add(answer, rr, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + } + + return sz; +} + +/* Parses every packet back and returns the owner names in packet order, so a caller can pin that + * each record went out exactly once and in order -- a count would miss one duplicated and one lost. + * Releases the packets and the array. */ +static char** check_and_free_packets(DnsPacket **packets, size_t n_packets) { + _cleanup_strv_free_ char **names = NULL; + DnsResourceRecord *rr; + + FOREACH_ARRAY(p, packets, n_packets) { + /* Positive, not just non-negative: dns_packet_validate_reply() returns 0 rather than + * an error for a packet whose header says "query", so ASSERT_OK would accept one. + * QR and AA are what make this an authoritative response (RFC 6762 section 18.4). */ + ASSERT_OK_POSITIVE(dns_packet_validate_reply(*p)); + ASSERT_EQ(DNS_PACKET_QR(*p), 1); + ASSERT_EQ(DNS_PACKET_AA(*p), 1); + ASSERT_OK(dns_packet_extract(*p)); + + DNS_ANSWER_FOREACH(rr, (*p)->answer) + ASSERT_OK(strv_extend(&names, dns_resource_key_name(rr->key))); + } + + dns_packet_unref_array(packets, n_packets); + return TAKE_PTR(names); +} + +/* The RFC 6762 section 17 ceiling minus the headers each family puts on the wire. Spelled out rather + * than re-derived from udp_header_size(): a test that recomputes the formula under test only pins + * that it was applied twice the same way. IPv6 additionally loses the 8-byte Fragment header. */ +#define EXPECTED_FRAGMENTED_MAX_IPV4 (9000U - 20U - 8U) +#define EXPECTED_FRAGMENTED_MAX_IPV6 (9000U - 40U - 8U - 8U) + +TEST(mdns_announcement_max_sizes_without_a_known_mtu) { + size_t max_size, fragmented_max; + + /* No MTU known yet: cut to what a host of the family must accept, not to section 17's + * ceiling, which is the bound for a datagram deliberately fragmented to carry one record. + * The two must stay apart or the lone-oversized-record path is unreachable. */ + mdns_announcement_max_sizes(AF_INET, /* link_mtu= */ 0, &max_size, &fragmented_max); + ASSERT_EQ(fragmented_max, (size_t) EXPECTED_FRAGMENTED_MAX_IPV4); + ASSERT_EQ(max_size, (size_t) (MDNS_PACKET_UNKNOWN_MTU_IPV4 - 20U - 8U)); + ASSERT_LT(max_size, fragmented_max); + + mdns_announcement_max_sizes(AF_INET6, /* link_mtu= */ 0, &max_size, &fragmented_max); + ASSERT_EQ(fragmented_max, (size_t) EXPECTED_FRAGMENTED_MAX_IPV6); + ASSERT_EQ(max_size, (size_t) (IPV6_MIN_MTU - 40U - 8U)); + ASSERT_LT(max_size, fragmented_max); +} + +TEST(mdns_announcement_max_sizes_from_the_link_mtu) { + size_t max_size, fragmented_max; + + /* An Ethernet link: packets are cut to the MTU, while a lone oversized record may still rely + * on fragmentation up to the ceiling. */ + mdns_announcement_max_sizes(AF_INET, /* link_mtu= */ 1500, &max_size, &fragmented_max); + ASSERT_EQ(max_size, 1500U - 20U - 8U); + ASSERT_EQ(fragmented_max, (size_t) EXPECTED_FRAGMENTED_MAX_IPV4); + + mdns_announcement_max_sizes(AF_INET6, /* link_mtu= */ 1500, &max_size, &fragmented_max); + ASSERT_EQ(max_size, 1500U - 40U - 8U); + ASSERT_EQ(fragmented_max, (size_t) EXPECTED_FRAGMENTED_MAX_IPV6); +} + +TEST(mdns_announcement_max_sizes_caps_a_jumbo_mtu) { + size_t max_size, fragmented_max; + + /* A jumbo-frame link would let an unfragmented packet exceed the section 17 ceiling, so the + * ceiling has to bind the MTU-derived size too -- this is the case the cap exists for. + * DNS_PACKET_SIZE_MAX is larger still, so nothing may derive a size from it either. */ + ASSERT_GT((size_t) DNS_PACKET_SIZE_MAX, (size_t) MDNS_PACKET_FRAGMENTED_SIZE_MAX); + + mdns_announcement_max_sizes(AF_INET, /* link_mtu= */ 9216, &max_size, &fragmented_max); + ASSERT_EQ(max_size, fragmented_max); + ASSERT_EQ(max_size, (size_t) EXPECTED_FRAGMENTED_MAX_IPV4); + + mdns_announcement_max_sizes(AF_INET6, /* link_mtu= */ 9216, &max_size, &fragmented_max); + ASSERT_EQ(max_size, fragmented_max); + ASSERT_EQ(max_size, (size_t) EXPECTED_FRAGMENTED_MAX_IPV6); +} + +TEST(mdns_announcement_max_sizes_ignores_an_mtu_below_a_packet_header) { + size_t max_size, fragmented_max; + int family; + + /* An MTU leaving no room for a DNS header past the IP/UDP ones yields no bound at all: + * below them the subtraction underflows, at them dns_packet_new() gets less than the header + * it asserts room for. So the derivation falls back. A derivable bound too small for any + * record is a different case, kept deliberately -- see the packetization test. */ + FOREACH_ARGUMENT(family, AF_INET, AF_INET6) { + size_t unknown_mtu_max, unknown_mtu_fragmented; + + mdns_announcement_max_sizes(family, /* link_mtu= */ 0, + &unknown_mtu_max, &unknown_mtu_fragmented); + + mdns_announcement_max_sizes(family, udp_header_size(family) + DNS_PACKET_HEADER_SIZE, + &max_size, &fragmented_max); + ASSERT_EQ(max_size, unknown_mtu_max); + ASSERT_EQ(fragmented_max, unknown_mtu_fragmented); + + mdns_announcement_max_sizes(family, udp_header_size(family) - 1, + &max_size, &fragmented_max); + ASSERT_EQ(max_size, unknown_mtu_max); + ASSERT_EQ(fragmented_max, unknown_mtu_fragmented); + + /* One byte of headroom does derive from the MTU, so the assertions above are not + * just restating the default. No record fits that bound; see the packetization + * test for where such a bound leads. */ + mdns_announcement_max_sizes(family, udp_header_size(family) + DNS_PACKET_HEADER_SIZE + 1, + &max_size, &fragmented_max); + ASSERT_EQ(max_size, DNS_PACKET_HEADER_SIZE + 1); + } +} + +TEST(mdns_announcement_packetize_empty) { + DnsPacket **packets = NULL; + size_t n_packets = SIZE_MAX; + + ASSERT_OK(mdns_announcement_packetize(/* answer= */ NULL, 512, 512, + &packets, &n_packets)); + ASSERT_EQ(n_packets, 0u); + ASSERT_NULL(packets); +} + +TEST(mdns_announcement_packetize_splits_at_max_size) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0; + + answer = dns_answer_new(5); + ASSERT_NOT_NULL(answer); + + sz = answer_add_equal_sized(answer, STRV_MAKE("small0", "small1", "small2", "small3", "small4")); + + /* Exactly two records fit a packet: five records must make three packets, filled in + * order. */ + ASSERT_OK(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 2 * sz, + DNS_PACKET_HEADER_SIZE + 2 * sz, + &packets, &n_packets)); + ASSERT_EQ(n_packets, 3u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[1]), 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[2]), 1u); + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, STRV_MAKE("small0", "small1", "small2", "small3", "small4"))); +} + +TEST(mdns_announcement_packetize_oversized_rr_within_fragmented_ceiling) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *big = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0, big_sz; + + answer = dns_answer_new(4); + ASSERT_NOT_NULL(answer); + + /* small, small, big, small -- the big record does not fit a packet even alone. */ + sz = answer_add_equal_sized(answer, STRV_MAKE("small0", "small1")); + + big = a_rr("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + big_sz = rr_wire_size(big); + ASSERT_GT(big_sz, 2 * sz); + ASSERT_OK(dns_answer_add(answer, big, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *rr = a_rr("small2"); + ASSERT_EQ(rr_wire_size(rr), sz); + ASSERT_OK(dns_answer_add(answer, rr, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + /* The big record must ride alone in a packet sized up to the fragmented ceiling, between + * the regular packets of its neighbors. */ + ASSERT_OK(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 2 * sz, + DNS_PACKET_HEADER_SIZE + big_sz, + &packets, &n_packets)); + ASSERT_EQ(n_packets, 3u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[1]), 1u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[2]), 1u); + ASSERT_EQ(packets[1]->size, DNS_PACKET_HEADER_SIZE + big_sz); + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, + STRV_MAKE("small0", + "small1", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "small2"))); +} + +TEST(mdns_announcement_packetize_skips_rr_beyond_fragmented_ceiling) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *giant = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0; + + answer = dns_answer_new(3); + ASSERT_NOT_NULL(answer); + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *first = a_rr("small0"); + sz = rr_wire_size(first); + ASSERT_OK(dns_answer_add(answer, first, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + giant = a_rr("ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"); + ASSERT_GT(rr_wire_size(giant), 2 * sz); + ASSERT_OK(dns_answer_add(answer, giant, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *last = a_rr("small1"); + ASSERT_EQ(rr_wire_size(last), sz); + ASSERT_OK(dns_answer_add(answer, last, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + /* With no fragmented headroom the record that fits nowhere is dropped, and its neighbors + * still go out. */ + ASSERT_EQ(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 2 * sz, + DNS_PACKET_HEADER_SIZE + 2 * sz, + &packets, &n_packets), 1); + ASSERT_EQ(n_packets, 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 1u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[1]), 1u); + + /* The giant is gone; its neighbours went out intact and in order. */ + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, STRV_MAKE("small0", "small1"))); +} + +/* The packet a failed fragmented retry leaves behind must not be reused for the records that follow: + * it was built to fragmented_max, and packing them to that bound is the oversized multi-record + * announcement this whole split exists to prevent. Reaching it takes max_size < fragmented_max, so + * that the retry runs at all, together with a record too large for either bound, so that it fails. */ +TEST(mdns_announcement_packetize_drops_the_widened_packet_of_a_failed_retry) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *giant = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0; + + answer = dns_answer_new(4); + ASSERT_NOT_NULL(answer); + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *first = a_rr("small0"); + sz = rr_wire_size(first); + + /* Too large for max_size and for fragmented_max alike, so its retry fails as well. */ + giant = a_rr("ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"); + ASSERT_GT(rr_wire_size(giant), 3 * sz); + ASSERT_OK(dns_answer_add(answer, giant, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + ASSERT_OK(dns_answer_add(answer, first, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + FOREACH_STRING(name, "small1", "small2") { + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *rr = a_rr(name); + + ASSERT_EQ(rr_wire_size(rr), sz); + ASSERT_OK(dns_answer_add(answer, rr, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + } + + ASSERT_EQ(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 2 * sz, + DNS_PACKET_HEADER_SIZE + 3 * sz, + &packets, &n_packets), 1); + + /* Two packets of max_size, not one of fragmented_max -- which is exactly three records + * wide, so a reused widened packet would hold all three and show up as a single ancount. */ + ASSERT_EQ(n_packets, 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[1]), 1u); + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, STRV_MAKE("small0", "small1", "small2"))); +} + +/* A bound derived from a real but tiny MTU, too small for any record to fit: each record then goes + * out alone in a packet larger than the MTU, which is the one shape RFC 6762 section 17 allows to be + * fragmented: several records in one datagram would be too, and section 17 allows that for a lone + * record only. */ +TEST(mdns_announcement_packetize_bound_too_small_for_any_record) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0; + + answer = dns_answer_new(3); + ASSERT_NOT_NULL(answer); + + sz = answer_add_equal_sized(answer, STRV_MAKE("small0", "small1", "small2")); + + ASSERT_OK(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 1, + DNS_PACKET_HEADER_SIZE + 4 * sz, + &packets, &n_packets)); + ASSERT_EQ(n_packets, 3u); + FOREACH_ARRAY(p, packets, n_packets) { + ASSERT_EQ(DNS_PACKET_ANCOUNT(*p), 1u); + ASSERT_EQ((*p)->size, DNS_PACKET_HEADER_SIZE + sz); + } + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, STRV_MAKE("small0", "small1", "small2"))); +} + +/* The fragmented retry reached without a preceding seal: when the oversized record is the *first* + * item there is nothing accumulated to flush, so the retry is entered with nothing counted yet and a + * packet that had nothing appended -- a different path into the same branch than the mid-answer case + * above, and the one that pins the lone record's ancount at exactly 1. */ +TEST(mdns_announcement_packetize_oversized_rr_first) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *giant = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0; + + answer = dns_answer_new(3); + ASSERT_NOT_NULL(answer); + + giant = a_rr("ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"); + ASSERT_OK(dns_answer_add(answer, giant, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *first = a_rr("small0"); + sz = rr_wire_size(first); + ASSERT_GT(rr_wire_size(giant), 2 * sz); + ASSERT_OK(dns_answer_add(answer, first, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *last = a_rr("small1"); + ASSERT_EQ(rr_wire_size(last), sz); + ASSERT_OK(dns_answer_add(answer, last, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + ASSERT_OK(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 2 * sz, + DNS_PACKET_HEADER_SIZE + 4 * sz, + &packets, &n_packets)); + + /* The giant goes out alone in the first packet -- at its own size, so the case stays the + * one it is meant to be: too large for max_size, small enough for the fragmented bound -- + * with an ancount of exactly 1, not the count of a packet that was never filled. Its + * followers share the second. */ + ASSERT_EQ(n_packets, 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 1u); + ASSERT_EQ(packets[0]->size, DNS_PACKET_HEADER_SIZE + rr_wire_size(giant)); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[1]), 2u); + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, STRV_MAKE(dns_resource_key_name(giant->key), "small0", "small1"))); +} + +/* Nothing emittable at all. The skip must not leave a half-built packet to be sealed with an ancount + * of zero, and an announcement that has nothing to say is not an error -- the caller sends what it + * gets back and would otherwise abandon the rest of a withdrawal over one unsendable record. */ +TEST(mdns_announcement_packetize_emits_nothing_when_every_rr_is_oversized) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *giant = NULL, *giant2 = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 17; + + answer = dns_answer_new(2); + ASSERT_NOT_NULL(answer); + + giant = a_rr("ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg"); + ASSERT_OK(dns_answer_add(answer, giant, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + giant2 = a_rr("hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh"); + ASSERT_OK(dns_answer_add(answer, giant2, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + ASSERT_EQ(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE, + DNS_PACKET_HEADER_SIZE, + &packets, &n_packets), 2); + ASSERT_EQ(n_packets, 0u); + ASSERT_NULL(packets); +} + +/* What a withdrawal *is* on the wire: TTL 0, and the cache-flush bit on the class of a unique + * record. Both come from the answer item's flags, which packetization has to carry through to + * dns_packet_append_rr() -- passing 0 there instead would emit positive-TTL "goodbyes" that withdraw + * nothing, and every other testcase here would still pass. */ +TEST(mdns_announcement_packetize_preserves_item_flags) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *bye = NULL, *plain = NULL; + DnsPacket **packets = NULL; + DnsAnswerItem *item; + size_t n_packets = 0, seen = 0; + + answer = dns_answer_new(2); + ASSERT_NOT_NULL(answer); + + bye = a_rr("bye"); + ASSERT_OK(dns_answer_add(answer, bye, /* ifindex= */ 0, + DNS_ANSWER_GOODBYE|DNS_ANSWER_CACHE_FLUSH, /* rrsig= */ NULL)); + + /* A negative control in the same packet: without the flags the record keeps its TTL and + * stays a shared-owner one, so the assertions below discriminate rather than hold for + * anything that parses. */ + plain = a_rr("stays"); + ASSERT_OK(dns_answer_add(answer, plain, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + ASSERT_OK(mdns_announcement_packetize( + answer, + DNS_PACKET_SIZE_MAX, + DNS_PACKET_SIZE_MAX, + &packets, &n_packets)); + ASSERT_EQ(n_packets, 1u); + ASSERT_OK_POSITIVE(dns_packet_validate_reply(packets[0])); + ASSERT_EQ(DNS_PACKET_QR(packets[0]), 1); + ASSERT_EQ(DNS_PACKET_AA(packets[0]), 1); + ASSERT_OK(dns_packet_extract(packets[0])); + + /* Reading a packet turns the wire's cache-flush bit into the *absence* of + * DNS_ANSWER_SHARED_OWNER, so that is what pins it here. */ + DNS_ANSWER_FOREACH_ITEM(item, packets[0]->answer) { + if (streq(dns_resource_key_name(item->rr->key), "bye")) { + ASSERT_EQ(item->rr->ttl, 0u); + ASSERT_FALSE(FLAGS_SET(item->flags, DNS_ANSWER_SHARED_OWNER)); + } else { + /* Named explicitly, so a record with neither name cannot satisfy the + * negative control by falling into this arm. */ + ASSERT_STREQ(dns_resource_key_name(item->rr->key), "stays"); + ASSERT_EQ(item->rr->ttl, 120u); + ASSERT_TRUE(FLAGS_SET(item->flags, DNS_ANSWER_SHARED_OWNER)); + } + + seen++; + } + ASSERT_EQ(seen, 2u); + + dns_packet_unref_array(packets, n_packets); +} + +/* A 256-byte character string cannot be encoded by any packet: its length prefix is one byte. That + * is an encoding error (-E2BIG; an rdata past 64KiB is the -ENOSPC twin), not a size one, and it + * must cost this record alone -- the encoder rolls the packet back, so the neighbours go out intact + * and in order rather than the whole announcement being abandoned over one bad record. */ +TEST(mdns_announcement_packetize_skips_rr_that_cannot_be_encoded) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + _cleanup_(dns_resource_record_unrefp) DnsResourceRecord *first = NULL, *bad = NULL, *last = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0; + + answer = dns_answer_new(3); + ASSERT_NOT_NULL(answer); + + first = a_rr("small0"); + ASSERT_OK(dns_answer_add(answer, first, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + bad = txt_rr("bad", 256); + ASSERT_OK(dns_answer_add(answer, bad, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + last = a_rr("small1"); + ASSERT_OK(dns_answer_add(answer, last, /* ifindex= */ 0, /* flags= */ 0, /* rrsig= */ NULL)); + + ASSERT_EQ(mdns_announcement_packetize( + answer, + DNS_PACKET_SIZE_MAX, + DNS_PACKET_SIZE_MAX, + &packets, &n_packets), 1); + ASSERT_EQ(n_packets, 1u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 2u); + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, STRV_MAKE("small0", "small1"))); +} + +/* DNS-SD names all share a suffix, and the compression map is per packet: one carried across a + * boundary would emit a pointer into offsets that only exist in the previous packet. The cases + * above use names sharing none, so this is where that is pinned -- every packet parses back on its + * own, and the second record of a packet did compress against the first. */ +TEST(mdns_announcement_packetize_compresses_within_a_packet_only) { + _cleanup_(dns_answer_unrefp) DnsAnswer *answer = NULL; + DnsPacket **packets = NULL; + size_t n_packets = 0, sz = 0; + + answer = dns_answer_new(5); + ASSERT_NOT_NULL(answer); + + sz = answer_add_equal_sized(answer, STRV_MAKE("svc0._x._udp.local", + "svc1._x._udp.local", + "svc2._x._udp.local", + "svc3._x._udp.local", + "svc4._x._udp.local")); + + ASSERT_OK(mdns_announcement_packetize( + answer, + DNS_PACKET_HEADER_SIZE + 2 * sz, + DNS_PACKET_HEADER_SIZE + 2 * sz, + &packets, &n_packets)); + ASSERT_EQ(n_packets, 3u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[0]), 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[1]), 2u); + ASSERT_EQ(DNS_PACKET_ANCOUNT(packets[2]), 1u); + /* A full packet of two uncompressed records would be exactly the bound. */ + ASSERT_LT(packets[0]->size, DNS_PACKET_HEADER_SIZE + 2 * sz); + ASSERT_LT(packets[1]->size, DNS_PACKET_HEADER_SIZE + 2 * sz); + + _cleanup_strv_free_ char **names = check_and_free_packets(packets, n_packets); + ASSERT_TRUE(strv_equal(names, + STRV_MAKE("svc0._x._udp.local", + "svc1._x._udp.local", + "svc2._x._udp.local", + "svc3._x._udp.local", + "svc4._x._udp.local"))); +} + +DEFINE_TEST_MAIN(LOG_DEBUG) diff --git a/src/shared/dns-packet.c b/src/shared/dns-packet.c index a0508b1c68..4f7353d963 100644 --- a/src/shared/dns-packet.c +++ b/src/shared/dns-packet.c @@ -297,6 +297,8 @@ DnsPacket *dns_packet_unref(DnsPacket *p) { return NULL; } +DEFINE_POINTER_ARRAY_FREE_FUNC(DnsPacket*, dns_packet_unref); + int dns_packet_validate(DnsPacket *p) { assert(p); diff --git a/src/shared/dns-packet.h b/src/shared/dns-packet.h index 1bf577a7ce..90c5187091 100644 --- a/src/shared/dns-packet.h +++ b/src/shared/dns-packet.h @@ -160,6 +160,7 @@ void dns_packet_set_flags(DnsPacket *p, bool dnssec_checking_disabled, bool trun DnsPacket *dns_packet_ref(DnsPacket *p); DnsPacket *dns_packet_unref(DnsPacket *p); +void dns_packet_unref_array(DnsPacket **array, size_t n); DEFINE_TRIVIAL_CLEANUP_FUNC(DnsPacket*, dns_packet_unref);