]> git.ipfire.org Git - thirdparty/systemd.git/commitdiff
test: introduce a dummy DNS test server
authorFrantisek Sumsal <frantisek@sumsal.cz>
Mon, 8 Jan 2024 13:20:30 +0000 (14:20 +0100)
committerYu Watanabe <watanabe.yu+github@gmail.com>
Wed, 10 Jan 2024 17:13:29 +0000 (02:13 +0900)
Introduce a _very_ simple DNS server using our internal DNS-related
code, that responds to queries with specifically crafted packets, to
cover scenarios that are difficult to reproduce with well-behaving DNS
servers.

Also, hide the test DNS server behind Knot using the dnsproxy module, so
we don't have to switch DNS servers during tests.

src/resolve/meson.build
src/resolve/test-resolved-dummy-server.c [new file with mode: 0644]
test/knot-data/knot.conf
test/units/testsuite-75.sh

index e7867e2f85afe63b9021b47d929cf9fe76f38e9a..d855ded91a3db87b21898ba3c7eed01621ea8585 100644 (file)
@@ -196,6 +196,20 @@ executables += [
                 ],
                 'include_directories' : resolve_includes,
         },
+        test_template + {
+                'sources' : [
+                        files('test-resolved-dummy-server.c'),
+                        basic_dns_sources,
+                        systemd_resolved_sources,
+                ],
+                'dependencies' : [
+                        lib_openssl_or_gcrypt,
+                        libm,
+                        systemd_resolved_dependencies,
+                ],
+                'include_directories' : resolve_includes,
+                'type' : 'manual',
+        },
         resolve_fuzz_template + {
                 'sources' : files('fuzz-dns-packet.c'),
         },
diff --git a/src/resolve/test-resolved-dummy-server.c b/src/resolve/test-resolved-dummy-server.c
new file mode 100644 (file)
index 0000000..4fc4734
--- /dev/null
@@ -0,0 +1,428 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later */
+
+#include "sd-daemon.h"
+
+#include "fd-util.h"
+#include "iovec-util.h"
+#include "log.h"
+#include "resolved-dns-packet.h"
+#include "resolved-manager.h"
+#include "socket-netlink.h"
+#include "socket-util.h"
+
+/* Taken from resolved-dns-stub.c */
+#define ADVERTISE_DATAGRAM_SIZE_MAX (65536U-14U-20U-8U)
+
+/* This is more or less verbatim manager_recv() from resolved-manager.c, sans the manager stuff */
+static int server_recv(int fd, DnsPacket **ret) {
+        _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL;
+        CMSG_BUFFER_TYPE(CMSG_SPACE(MAXSIZE(struct in_pktinfo, struct in6_pktinfo))
+                         + CMSG_SPACE(int) /* ttl/hoplimit */
+                         + EXTRA_CMSG_SPACE /* kernel appears to require extra buffer space */) control;
+        union sockaddr_union sa;
+        struct iovec iov;
+        struct msghdr mh = {
+                .msg_name = &sa.sa,
+                .msg_namelen = sizeof(sa),
+                .msg_iov = &iov,
+                .msg_iovlen = 1,
+                .msg_control = &control,
+                .msg_controllen = sizeof(control),
+        };
+        struct cmsghdr *cmsg;
+        ssize_t ms, l;
+        int r;
+
+        assert(fd >= 0);
+        assert(ret);
+
+        ms = next_datagram_size_fd(fd);
+        if (ms < 0)
+                return ms;
+
+        r = dns_packet_new(&p, DNS_PROTOCOL_DNS, ms, DNS_PACKET_SIZE_MAX);
+        if (r < 0)
+                return r;
+
+        iov = IOVEC_MAKE(DNS_PACKET_DATA(p), p->allocated);
+
+        l = recvmsg_safe(fd, &mh, 0);
+        if (ERRNO_IS_NEG_TRANSIENT(l))
+                return 0;
+        if (l <= 0)
+                return l;
+
+        assert(!(mh.msg_flags & MSG_TRUNC));
+
+        p->size = (size_t) l;
+
+        p->family = sa.sa.sa_family;
+        p->ipproto = IPPROTO_UDP;
+        if (p->family == AF_INET) {
+                p->sender.in = sa.in.sin_addr;
+                p->sender_port = be16toh(sa.in.sin_port);
+        } else if (p->family == AF_INET6) {
+                p->sender.in6 = sa.in6.sin6_addr;
+                p->sender_port = be16toh(sa.in6.sin6_port);
+                p->ifindex = sa.in6.sin6_scope_id;
+        } else
+                return -EAFNOSUPPORT;
+
+        p->timestamp = now(CLOCK_BOOTTIME);
+
+        CMSG_FOREACH(cmsg, &mh) {
+
+                if (cmsg->cmsg_level == IPPROTO_IPV6) {
+                        assert(p->family == AF_INET6);
+
+                        switch (cmsg->cmsg_type) {
+
+                        case IPV6_PKTINFO: {
+                                struct in6_pktinfo *i = CMSG_TYPED_DATA(cmsg, struct in6_pktinfo);
+
+                                if (p->ifindex <= 0)
+                                        p->ifindex = i->ipi6_ifindex;
+
+                                p->destination.in6 = i->ipi6_addr;
+                                break;
+                        }
+
+                        case IPV6_HOPLIMIT:
+                                p->ttl = *CMSG_TYPED_DATA(cmsg, int);
+                                break;
+
+                        case IPV6_RECVFRAGSIZE:
+                                p->fragsize = *CMSG_TYPED_DATA(cmsg, int);
+                                break;
+                        }
+                } else if (cmsg->cmsg_level == IPPROTO_IP) {
+                        assert(p->family == AF_INET);
+
+                        switch (cmsg->cmsg_type) {
+
+                        case IP_PKTINFO: {
+                                struct in_pktinfo *i = CMSG_TYPED_DATA(cmsg, struct in_pktinfo);
+
+                                if (p->ifindex <= 0)
+                                        p->ifindex = i->ipi_ifindex;
+
+                                p->destination.in = i->ipi_addr;
+                                break;
+                        }
+
+                        case IP_TTL:
+                                p->ttl = *CMSG_TYPED_DATA(cmsg, int);
+                                break;
+
+                        case IP_RECVFRAGSIZE:
+                                p->fragsize = *CMSG_TYPED_DATA(cmsg, int);
+                                break;
+                        }
+                }
+        }
+
+        /* The Linux kernel sets the interface index to the loopback
+         * device if the packet came from the local host since it
+         * avoids the routing table in such a case. Let's unset the
+         * interface index in such a case. */
+        if (p->ifindex == LOOPBACK_IFINDEX)
+                p->ifindex = 0;
+
+        log_debug("Received DNS UDP packet of size %zu, ifindex=%i, ttl=%u, fragsize=%zu, sender=%s, destination=%s",
+                  p->size, p->ifindex, p->ttl, p->fragsize,
+                  IN_ADDR_TO_STRING(p->family, &p->sender),
+                  IN_ADDR_TO_STRING(p->family, &p->destination));
+
+        *ret = TAKE_PTR(p);
+        return 1;
+}
+
+/* Same as above, see manager_ipv4_send() in resolved-manager.c */
+static int server_ipv4_send(
+                int fd,
+                const struct in_addr *destination,
+                uint16_t port,
+                const struct in_addr *source,
+                DnsPacket *packet) {
+
+        union sockaddr_union sa;
+        struct iovec iov;
+        struct msghdr mh = {
+                .msg_iov = &iov,
+                .msg_iovlen = 1,
+                .msg_name = &sa.sa,
+                .msg_namelen = sizeof(sa.in),
+        };
+
+        assert(fd >= 0);
+        assert(destination);
+        assert(port > 0);
+        assert(packet);
+
+        iov = IOVEC_MAKE(DNS_PACKET_DATA(packet), packet->size);
+
+        sa = (union sockaddr_union) {
+                .in.sin_family = AF_INET,
+                .in.sin_addr = *destination,
+                .in.sin_port = htobe16(port),
+        };
+
+        return sendmsg_loop(fd, &mh, 0);
+}
+
+static int make_reply_packet(DnsPacket *packet, DnsPacket **ret) {
+        _cleanup_(dns_packet_unrefp) DnsPacket *p = NULL;
+        int r;
+
+        assert(packet);
+        assert(ret);
+
+        r = dns_packet_new(&p, DNS_PROTOCOL_DNS, 0, DNS_PACKET_PAYLOAD_SIZE_MAX(packet));
+        if (r < 0)
+                return r;
+
+        r = dns_packet_append_question(p, packet->question);
+        if (r < 0)
+                return r;
+
+        DNS_PACKET_HEADER(p)->id = DNS_PACKET_ID(packet);
+        DNS_PACKET_HEADER(p)->qdcount = htobe16(dns_question_size(packet->question));
+
+        *ret = TAKE_PTR(p);
+        return 0;
+}
+
+static int reply_append_edns(DnsPacket *packet, DnsPacket *reply, const char *extra_text, size_t rcode, uint16_t ede_code) {
+        size_t saved_size;
+        int r;
+
+        assert(packet);
+        assert(reply);
+
+        /* Append EDNS0 stuff (inspired by dns_packet_append_opt() from resolved-dns-packet.c).
+         *
+         * Relevant headers from RFC 6891:
+         *
+         * +------------+--------------+------------------------------+
+         * | Field Name | Field Type   | Description                  |
+         * +------------+--------------+------------------------------+
+         * | NAME       | domain name  | MUST be 0 (root domain)      |
+         * | TYPE       | u_int16_t    | OPT (41)                     |
+         * | CLASS      | u_int16_t    | requestor's UDP payload size |
+         * | TTL        | u_int32_t    | extended RCODE and flags     |
+         * | RDLEN      | u_int16_t    | length of all RDATA          |
+         * | RDATA      | octet stream | {attribute,value} pairs      |
+         * +------------+--------------+------------------------------+
+         *
+         *               +0 (MSB)                            +1 (LSB)
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 0: |                          OPTION-CODE                          |
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 2: |                         OPTION-LENGTH                         |
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 4: |                                                               |
+         *    /                          OPTION-DATA                          /
+         *    /                                                               /
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         *
+         * And from RFC 8914:
+         *
+         *                                              1   1   1   1   1   1
+         *      0   1   2   3   4   5   6   7   8   9   0   1   2   3   4   5
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 0: |                            OPTION-CODE                        |
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 2: |                           OPTION-LENGTH                       |
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 4: | INFO-CODE                                                     |
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         * 6: / EXTRA-TEXT ...                                                /
+         *    +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+         */
+
+        saved_size = reply->size;
+
+        /* empty name */
+        r = dns_packet_append_uint8(reply, 0, NULL);
+        if (r < 0)
+                return r;
+
+        /* type */
+        r = dns_packet_append_uint16(reply, DNS_TYPE_OPT, NULL);
+        if (r < 0)
+                return r;
+
+        /* class: maximum udp packet that can be received */
+        r = dns_packet_append_uint16(reply, ADVERTISE_DATAGRAM_SIZE_MAX, NULL);
+        if (r < 0)
+                return r;
+
+        /* extended RCODE and VERSION */
+        r = dns_packet_append_uint16(reply, ((uint16_t) rcode & 0x0FF0) << 4, NULL);
+        if (r < 0)
+                return r;
+
+        /* flags: DNSSEC OK (DO), see RFC3225 */
+        r = dns_packet_append_uint16(reply, 0, NULL);
+        if (r < 0)
+                return r;
+
+        /* RDATA */
+
+        size_t extra_text_len = isempty(extra_text) ? 0 : strlen(extra_text);
+        /* RDLENGTH (OPTION CODE + OPTION LENGTH + INFO-CODE + EXTRA-TEXT) */
+        r = dns_packet_append_uint16(reply, 2 + 2 + 2 + extra_text_len, NULL);
+        if (r < 0)
+                return 0;
+
+        /* OPTION-CODE: 15 for EDE */
+        r = dns_packet_append_uint16(reply, 15, NULL);
+        if (r < 0)
+                return r;
+
+        /* OPTION-LENGTH: INFO-CODE + EXTRA-TEXT */
+        r = dns_packet_append_uint16(reply, 2 + extra_text_len, NULL);
+        if (r < 0)
+                return r;
+
+        /* INFO-CODE: EDE code */
+        r = dns_packet_append_uint16(reply, ede_code, NULL);
+        if (r < 0)
+                return r;
+
+        /* EXTRA-TEXT */
+        if (extra_text_len > 0) {
+                /* From RFC 8914:
+                 *  EDE text may be null terminated but MUST NOT be assumed to be; the length MUST be derived
+                 *  from the OPTION-LENGTH field
+                 *
+                 *  Let's exercise our code on the receiving side and not NUL-terminate the EXTRA-TEXT field
+                 */
+                r = dns_packet_append_blob(reply, extra_text, extra_text_len, NULL);
+                if (r < 0)
+                        return r;
+        }
+
+        DNS_PACKET_HEADER(reply)->arcount = htobe16(DNS_PACKET_ARCOUNT(reply) + 1);
+        reply->opt_start = saved_size;
+        reply->opt_size = reply->size - saved_size;
+
+        /* Order: qr, opcode, aa, tc, rd, ra, ad, cd, rcode */
+        DNS_PACKET_HEADER(reply)->flags = htobe16(DNS_PACKET_MAKE_FLAGS(
+                                                1, 0, 0, 0, DNS_PACKET_RD(packet), 1, 0, 1, rcode));
+        return 0;
+}
+
+static void server_fail(DnsPacket *packet, DnsPacket *reply, int rcode) {
+        assert(reply);
+
+        /* Order: qr, opcode, aa, tc, rd, ra, ad, cd, rcode */
+        DNS_PACKET_HEADER(reply)->flags = htobe16(DNS_PACKET_MAKE_FLAGS(
+                                                1, 0, 0, 0, DNS_PACKET_RD(packet), 1, 0, 1, rcode));
+}
+
+static int server_handle_edns_bogus_dnssec(DnsPacket *packet, DnsPacket *reply) {
+        assert(packet);
+        assert(reply);
+
+        return reply_append_edns(packet, reply, NULL, DNS_RCODE_SERVFAIL, DNS_EDE_RCODE_DNSSEC_BOGUS);
+}
+
+static int server_handle_edns_extra_text(DnsPacket *packet, DnsPacket *reply) {
+        assert(packet);
+        assert(reply);
+
+        return reply_append_edns(packet, reply, "Nothing to see here!", DNS_RCODE_SERVFAIL, DNS_EDE_RCODE_CENSORED);
+}
+
+static int server_handle_edns_invalid_code(DnsPacket *packet, DnsPacket *reply, const char *extra_text) {
+        assert(packet);
+        assert(reply);
+        assert_cc(_DNS_EDE_RCODE_MAX_DEFINED < UINT16_MAX);
+
+        return reply_append_edns(packet, reply, extra_text, DNS_RCODE_SERVFAIL, _DNS_EDE_RCODE_MAX_DEFINED + 1);
+}
+
+static int server_handle_edns_code_zero(DnsPacket *packet, DnsPacket *reply) {
+        assert(packet);
+        assert(reply);
+        assert_cc(DNS_EDE_RCODE_OTHER == 0);
+
+        return reply_append_edns(packet, reply, "\xF0\x9F\x90\xB1", DNS_RCODE_SERVFAIL, DNS_EDE_RCODE_OTHER);
+}
+
+int main(int argc, char *argv[]) {
+        _cleanup_close_ int fd = -EBADF;
+        int r;
+
+        log_parse_environment();
+        log_open();
+
+        if (argc != 2)
+                return log_error_errno(SYNTHETIC_ERRNO(EINVAL),
+                                       "This program takes one argument in format ip_address:port");
+
+        fd = make_socket_fd(LOG_DEBUG, argv[1], SOCK_DGRAM, SOCK_CLOEXEC);
+        if (fd < 0)
+                return log_error_errno(fd, "Failed to listen on address '%s': %m", argv[1]);
+
+        (void) sd_notify(/* unset_environment=false */ false, "READY=1");
+
+        for (;;) {
+                _cleanup_(dns_packet_unrefp) DnsPacket *packet = NULL;
+                _cleanup_(dns_packet_unrefp) DnsPacket *reply = NULL;
+                const char *name;
+
+                r = server_recv(fd, &packet);
+                if (r < 0) {
+                        log_debug_errno(r, "Failed to receive packet, ignoring: %m");
+                        continue;
+                }
+
+                r = dns_packet_validate_query(packet);
+                if (r < 0) {
+                        log_debug_errno(r, "Invalid DNS UDP packet, ignoring.");
+                        continue;
+                }
+
+                r = dns_packet_extract(packet);
+                if (r < 0) {
+                        log_debug_errno(r, "Failed to extract DNS packet, ignoring: %m");
+                        continue;
+                }
+
+                name = dns_question_first_name(packet->question);
+                log_info("Processing question for name '%s'", name);
+
+                (void) dns_question_dump(packet->question, stdout);
+
+                r = make_reply_packet(packet, &reply);
+                if (r < 0) {
+                        log_debug_errno(r, "Failed to make reply packet: %m");
+                        break;
+                }
+
+                if (streq_ptr(name, "edns-bogus-dnssec.forwarded.test"))
+                        r = server_handle_edns_bogus_dnssec(packet, reply);
+                else if (streq_ptr(name, "edns-extra-text.forwarded.test"))
+                        r = server_handle_edns_extra_text(packet, reply);
+                else if (streq_ptr(name, "edns-invalid-code.forwarded.test"))
+                        r = server_handle_edns_invalid_code(packet, reply, NULL);
+                else if (streq_ptr(name, "edns-invalid-code-with-extra-text.forwarded.test"))
+                        r = server_handle_edns_invalid_code(packet, reply, "Hello [#]$%~ World");
+                else if (streq_ptr(name, "edns-code-zero.forwarded.test"))
+                        r = server_handle_edns_code_zero(packet, reply);
+                else
+                        r = log_debug_errno(SYNTHETIC_ERRNO(EFAULT), "Unhandled name '%s', ignoring.", name);
+
+                if (r < 0)
+                        server_fail(packet, reply, DNS_RCODE_NXDOMAIN);
+
+                r = server_ipv4_send(fd, &packet->sender.in, packet->sender_port, &packet->destination.in, reply);
+                if (r < 0)
+                        log_debug_errno(r, "Failed to send reply: %m");
+
+        }
+
+        return 0;
+}
index 245fa75cf7c2f59981c40d1cc6acd034b25d7e2c..69c2082e48cd42471a820728f76bb82e37349384 100644 (file)
@@ -29,6 +29,9 @@ remote:
       address: 10.0.0.1@53
       address: fd00:dead:beef:cafe::1@53
 
+    - id: forwarded
+      address: 10.99.0.1@53
+
 submission:
     - id: parent_zone_sbm
       check-interval: 2s
@@ -69,6 +72,11 @@ policy:
     - id: manual
       manual: on
 
+mod-dnsproxy:
+  - id: forwarded
+    remote: forwarded
+    fallback: off
+
 template:
     # Sign everything by default and propagate the respective DS records to the parent
     - id: default
@@ -86,6 +94,11 @@ template:
       semantic-checks: on
       storage: "/var/lib/knot/zones"
 
+    - id: forwarded
+      dnssec-signing: off
+      module: mod-dnsproxy/forwarded
+      zonefile-load: none
+
 zone:
     # Create our own DNSSEC-aware root zone, so we can test the whole chain of
     # trust. This needs a ZSK/KSK keypair to be generated before running knot +
@@ -119,3 +132,7 @@ zone:
     # An unsigned zone
     - domain: unsigned.test
       template: unsigned
+
+    # Forward all queries for this zone to our dummy test server
+    - domain: forwarded.test
+      template: forwarded
index a4e2e0547bbd8d5815f5e3ca52f595680396509d..f1fb5d943a5ff5b18ab62cb1511dd069b535ced6 100755 (executable)
@@ -197,6 +197,25 @@ DNSSEC=allow-downgrade
 DNS=10.0.0.1
 DNS=fd00:dead:beef:cafe::1
 EOF
+cat >/etc/systemd/network/10-dns1.netdev <<EOF
+[NetDev]
+Name=dns1
+Kind=dummy
+EOF
+cat >/etc/systemd/network/10-dns1.network <<EOF
+[Match]
+Name=dns1
+
+[Network]
+Address=10.99.0.1/24
+DNSSEC=no
+EOF
+systemctl edit --stdin --full --runtime --force "resolved-dummy-server.service" <<EOF
+[Service]
+Type=notify
+Environment=SYSTEMD_LOG_LEVEL=debug
+ExecStart=/usr/lib/systemd/tests/unit-tests/manual/test-resolved-dummy-server 10.99.0.1:53
+EOF
 
 DNS_ADDRESSES=(
     "10.0.0.1"
@@ -236,6 +255,7 @@ ln -svf /etc/bind.keys /etc/bind/bind.keys
 systemctl unmask systemd-networkd
 systemctl start systemd-networkd
 restart_resolved
+systemctl start resolved-dummy-server
 # Create knot's runtime dir, since from certain version it's provided only by
 # the package and not created by tmpfiles/systemd
 if [[ ! -d /run/knot ]]; then
@@ -246,6 +266,7 @@ systemctl start knot
 # Wait a bit for the keys to propagate
 sleep 4
 
+systemctl status resolved-dummy-server
 networkctl status
 resolvectl status
 resolvectl log-level debug
@@ -254,7 +275,14 @@ resolvectl log-level debug
 systemd-run -u resolvectl-monitor.service -p Type=notify resolvectl monitor
 systemd-run -u resolvectl-monitor-json.service -p Type=notify resolvectl monitor --json=short
 
-knotc --force zone-check
+# FIXME: knot, unfortunately, incorrectly complains about missing zone files for zones
+#        that are forwarded using the `dnsproxy` module. Until the issue is resolved,
+#        let's fall back to pre-processing the `zone-check` output a bit before checking it
+#
+# See: https://gitlab.nic.cz/knot/knot-dns/-/issues/913
+run knotc zone-check || :
+sed -i '/forwarded.test./d' "$RUN_OUT"
+[[ ! -s "$RUN_OUT" ]]
 # We need to manually propagate the DS records of onlinesign.test. to the parent
 # zone, since they're generated online
 knotc zone-begin test.
@@ -552,6 +580,61 @@ grep -qF "fd00:dead:beef:cafe::123" "$RUN_OUT"
 #run dig +dnssec this.does.not.exist.untrusted.test
 #grep -qF "status: NXDOMAIN" "$RUN_OUT"
 
+: "--- ZONE: forwarded.test (queries forwarded to our dummy test server) ---"
+JOURNAL_CURSOR="$(mktemp)"
+journalctl -n0 -q --cursor-file="$JOURNAL_CURSOR"
+
+# See "test-resolved-dummy-server.c" for the server part
+(! run resolvectl query nope.forwarded.test)
+grep -qF "nope.forwarded.test" "$RUN_OUT"
+grep -qF "not found" "$RUN_OUT"
+
+# SERVFAIL + EDE code 6: DNSSEC Bogus
+(! run resolvectl query edns-bogus-dnssec.forwarded.test)
+grep -qE "^edns-bogus-dnssec.forwarded.test:.+: upstream-failure \(DNSSEC Bogus\)" "$RUN_OUT"
+# Same thing, but over Varlink
+(! run varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name" : "edns-bogus-dnssec.forwarded.test"}')
+grep -qF "io.systemd.Resolve.DNSSECValidationFailed" "$RUN_OUT"
+grep -qF '{"result":"upstream-failure","extendedDNSErrorCode":6}' "$RUN_OUT"
+journalctl --sync
+journalctl -u systemd-resolved.service --cursor-file="$JOURNAL_CURSOR" --grep "Server returned error: SERVFAIL \(DNSSEC Bogus\). Lookup failed."
+
+# SERVFAIL + EDE code 16: Censored + extra text
+(! run resolvectl query edns-extra-text.forwarded.test)
+grep -qE "^edns-extra-text.forwarded.test.+: SERVFAIL \(Censored: Nothing to see here!\)" "$RUN_OUT"
+(! run varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name" : "edns-extra-text.forwarded.test"}')
+grep -qF "io.systemd.Resolve.DNSError" "$RUN_OUT"
+grep -qF '{"rcode":2,"extendedDNSErrorCode":16,"extendedDNSErrorMessage":"Nothing to see here!"}' "$RUN_OUT"
+journalctl --sync
+journalctl -u systemd-resolved.service --cursor-file="$JOURNAL_CURSOR" --grep "Server returned error: SERVFAIL \(Censored: Nothing to see here!\)"
+
+# SERVFAIL + EDE code 0: Other + extra text
+(! run resolvectl query edns-code-zero.forwarded.test)
+grep -qE "^edns-code-zero.forwarded.test:.+: SERVFAIL \(Other: 🐱\)" "$RUN_OUT"
+(! run varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name" : "edns-code-zero.forwarded.test"}')
+grep -qF "io.systemd.Resolve.DNSError" "$RUN_OUT"
+grep -qF '{"rcode":2,"extendedDNSErrorCode":0,"extendedDNSErrorMessage":"🐱"}' "$RUN_OUT"
+journalctl --sync
+journalctl -u systemd-resolved.service --cursor-file="$JOURNAL_CURSOR" --grep "Server returned error: SERVFAIL \(Other: 🐱\)"
+
+# SERVFAIL + invalid EDE code
+(! run resolvectl query edns-invalid-code.forwarded.test)
+grep -qE "^edns-invalid-code.forwarded.test:.+: SERVFAIL \([0-9]+\)" "$RUN_OUT"
+(! run varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name" : "edns-invalid-code.forwarded.test"}')
+grep -qF "io.systemd.Resolve.DNSError" "$RUN_OUT"
+grep -qE '{"rcode":2,"extendedDNSErrorCode":[0-9]+}' "$RUN_OUT"
+journalctl --sync
+journalctl -u systemd-resolved.service --cursor-file="$JOURNAL_CURSOR" --grep "Server returned error: SERVFAIL \(\d+\)"
+
+# SERVFAIL + invalid EDE code + extra text
+(! run resolvectl query edns-invalid-code-with-extra-text.forwarded.test)
+grep -qE '^edns-invalid-code-with-extra-text.forwarded.test:.+: SERVFAIL \([0-9]+: Hello \[#\]\$%~ World\)' "$RUN_OUT"
+(! run varlinkctl call /run/systemd/resolve/io.systemd.Resolve io.systemd.Resolve.ResolveHostname '{"name" : "edns-invalid-code-with-extra-text.forwarded.test"}')
+grep -qF "io.systemd.Resolve.DNSError" "$RUN_OUT"
+grep -qE '{"rcode":2,"extendedDNSErrorCode":[0-9]+,"extendedDNSErrorMessage":"Hello \[#\]\$%~ World"}' "$RUN_OUT"
+journalctl --sync
+journalctl -u systemd-resolved.service --cursor-file="$JOURNAL_CURSOR" --grep "Server returned error: SERVFAIL \(\d+: Hello \[\#\]\\$%~ World\)"
+
 ### Test resolvectl show-cache
 run resolvectl show-cache
 run resolvectl show-cache --json=short