Repository: moby/vpnkit

Issue:
VPNKit DNS server returns NXDOMAIN for SRV records
VPNKit DNS server returns NXDOMAIN for SRV records
```console
$ rootlesskit --net=vpnkit dig -t srv _imaps._tcp.gmail.com
WARN[0000] specifying --disable-host-loopback is highly recommended to prohibit connecting to 127.0.0.1:* on the host namespace (requires slirp4netns or VPNKit) 
WARN[0000] Mounting /etc/resolv.conf without copying-up /etc. Note that /etc/resolv.conf in the namespace will be unmounted when it is recreated on the host. Unless /etc/resolv.conf is statically configured, copying-up /etc is highly recommended. Please refer to RootlessKit documentation for further information. 

; <<>> DiG 9.16.1-Ubuntu <<>> -t srv _imaps._tcp.gmail.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 870
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;_imaps._tcp.gmail.com.         IN      SRV

;; Query time: 0 msec
;; SERVER: 192.168.65.1#53(192.168.65.1)
;; WHEN: Mon Aug 31 20:02:57 JST 2020
;; MSG SIZE  rcvd: 39
```

OTOH slirp4netns DNS works as expected:
```console
$ ./rootlesskit --net=slirp4netns dig -t srv _imaps._tcp.gmail.com
WARN[0000] specifying --disable-host-loopback is highly recommended to prohibit connecting to 127.0.0.1:* on the host namespace (requires slirp4netns or VPNKit) 
WARN[0000] Mounting /etc/resolv.conf without copying-up /etc. Note that /etc/resolv.conf in the namespace will be unmounted when it is recreated on the host. Unless /etc/resolv.conf is statically configured, copying-up /etc is highly recommended. Please refer to RootlessKit documentation for further information. 

; <<>> DiG 9.16.1-Ubuntu <<>> -t srv _imaps._tcp.gmail.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 34903
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;_imaps._tcp.gmail.com.         IN      SRV

;; ANSWER SECTION:
_imaps._tcp.gmail.com.  5       IN      SRV     5 0 993 imap.gmail.com.

;; Query time: 15 msec
;; SERVER: 10.0.2.3#53(10.0.2.3)
;; WHEN: Mon Aug 31 20:04:05 JST 2020
;; MSG SIZE  rcvd: 84
```

VPNKit version: v0.4.0
RootlessKit version: v0.10.0

Originally reported by @hawicz in https://github.com/moby/libnetwork/issues/2574

Hints from issue comments:
@joanbm: I am running into the same problem, but with AAAA records instead. And in particular, it seems to break DNS resolution for IPv4-hosts for Alpine Linux containers started inside a `docker:dind-rootless` container, since it seems musl considers that getting a NXDOMAIN response for an AAAA query means that the entire query should fail even though the A query returns a valid list of IP addresses.

I was going to open a new issue and found this one at the last minute, so here's my writeup/analysis for it:

## Steps to reproduce

The easiest way I have found to reproduce the issue is as follows:

1. Install rootful Docker using the [official distribution and instructions](https://docs.docker.com/engine/install/ubuntu/), for example over Ubuntu Server 22.04

2. Run the `docker:dind-rootless` image:

   ```sh
   $ sudo docker run -d --name dind --privileged --env DOCKER_TLS_CERTDIR="" docker:24.0.2-dind-rootless --tls=false
   ```

3. Launch an Alpine Linux container inside it, and try to resolve an IPv4-only domain:

   ```sh
   $ sudo docker exec -it dind env DOCKER_HOST=tcp://localhost:2375 docker run --rm -it alpine:3.18 wget http://ipv4.tlund.se -O /dev/null
   ```

* **Actual result:** The command outputs `wget: bad address 'ipv4.tlund.se'`, which indicates a DNS resolution failure. This also reproduces when using any other tools, such as e.g. `curl`.
* **Expected result:** The command successfully resolves the address and downloads the website.

## Further tests

Some further tests tell us more about the nature of the problem, and why I believe it's related to VPNKit:

* **Alpine Linux is necessary**: The problem does not happen if you run the command inside a non-Alpine userspace such as Debian:

   ```sh
   $ sudo docker exec -it dind env DOCKER_HOST=tcp://localhost:2375 docker run --rm -it debian:bullseye-slim sh -c 'apt-get update && apt-get install -y wget && wget http://ipv4.tlund.se -O /dev/null'
   [...]
   Connecting to ipv4.tlund.se (ipv4.tlund.se)|193.15.228.195|:80... connected.
   [...]
   ```

* **Docker-in-Docker not necessary**: It is not necessary to get into a full Docker-in-Docker scenario to run into the issue. Attempting to resolve the hostname with just rootlesskit as follows, also fails:

   ```sh
   $ sudo docker exec -it dind rootlesskit --net=vpnkit wget https://ipv4.tlund.se -O /dev/null
   [...]
   wget: bad address 'ipv4.tlund.se'
   [...]
   ```

   Similarly, the cause of the problem is not that the test is running inside a `docker:dind-rootless` container. I have set up an Alpine Linux VM, installed `rootlesskit` and `vpnkit` on it from [this package](https://download.docker.com/linux/static/stable/x86_64/docker-rootless-extras-24.0.2.tgz)), and the problem still reproduces.

* **VPNKit** necessary:

   Removing the `--net=vpnkit`  switch from the previous command makes it work:
   ```sh
   $ sudo docker exec -it dind rootlesskit wget https://ipv4.tlund.se -O /dev/null
   Connecting to ipv4.tlund.se (193.15.228.195:443)
   [...]
   ```

   Similarly, using slirp4netns makes it work:
   ```sh
   $ sudo docker exec -it -u 0 dind apk add slirp4netns
   [...]
   $ sudo docker exec -it dind rootlesskit --net=slirp4netns wget https://ipv4.tlund.se -O /dev/null
   Connecting to ipv4.tlund.se (193.15.228.195:443)
   [...]
   ```

* **Fixed by using the --dns=/etc/resolv.conf**: 

   Adding the `--dns=/etc/resolv.conf` parameter to VPNKit to force "Upstream" instead of "Host" resolution fixes the problem:

   ```sh
   $ cat >vpnkit_forward.sh <<EOF
   #!/bin/sh
   exec vpnkit --dns=/etc/resolv.conf "\$@"
   EOF
   $ sudo docker cp vpnkit_forward.sh dind:/vpnkit_forward.sh
   $ sudo docker exec -it -u 0 dind chmod +x /vpnkit_forward.sh
   $ sudo docker exec -it dind rootlesskit --net=vpnkit --vpnkit-binary=/vpnkit_forward.sh wget https://ipv4.tlund.se -O /dev/null
   Connecting to ipv4.tlund.se (193.15.228.195:443)
   [...]
   ```

* **Related to IPv4 hosts**

   The problem appears to be related to the fact that the host we are trying to resolve (in the example: ipv4.tlund.se) only has A (IPv4) records, but no AAAA records. Trying to resolve a host with both kinds of records *does* work:

   ```sh
   $ sudo docker exec -it dind rootlesskit --net=vpnkit wget https://dual.tlund.se -O /dev/null
   Connecting to dual.tlund.se (193.15.228.195:443)
   [...]
   ```

## Potential cause: NXDOMAIN for AAAA records

I believe that the problem is that when you run an AAAA query for a domain without any AAAA records inside rootlesskit+vpnkit, you get an invalid NXDOMAIN response:

```sh
$ sudo docker exec -it -u 0 dind apk add bind-tools
$ sudo docker exec -it dind rootlesskit --net=vpnkit dig ipv4.tlund.se AAAA | grep status
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 60429
```

While if you run it without vpnkit or with slirp4netns, you get a NOERROR response:
```
$ sudo docker exec -it dind dig ipv4.tlund.se AAAA | grep status
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 54463
$ sudo docker exec -it dind rootlesskit --net=slirp4netns dig ipv4.tlund.se AAAA | grep status
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41316
```

It appears that the musl DNS resolver will fail the resolution once it sees that NXDOMAIN response for the AAAA records, failing the entire resolution.

I have not yet had time to figure out *why* we're getting a NXDOMAIN response after we add VPNKit (or what the specs say about those weird cases), but at first glance it seems like it should return NOERROR instead.@joanbm: At least for AAAA queries, the NXDOMAIN appears to come from those two lines: https://github.com/moby/vpnkit/blob/dc331cb22850be0cdd97c84a9cfecaf44a1afb6e/src/hostnet/hostnet_dns.ml#L449-L450, which seem to be turning a response with no answers into a NXDOMAIN.@dan0dbfe: Hello from the future!

I've arrived [at the same conclusion](https://github.com/moby/moby/issues/47628#issuecomment-2484678431) after running into the same issue https://github.com/moby/moby/issues/47628.

But I don't know OCaml to help fix it. The code should distinguish between an empty NOERROR vs a NXDOMAIN returned from upstream.
@joanbm: FWIW I tried reproducing https://github.com/moby/vpnkit/issues/509#issuecomment-1616797197 with the latest docker:dind-rootless image and VPNKit built from master, and it still seems to reproduce.@joanbm: At least part of the issue is that `getaddrinfo` returns `EAI_NODATA` when the domain exists but has no records, which ends up propagated as an error. At first glance, this seems to fix the issue:

```diff
diff --git a/src/hostnet/host.ml b/src/hostnet/host.ml
index 9e8cd69a..b0ac7669 100644
--- a/src/hostnet/host.ml
+++ b/src/hostnet/host.ml
@@ -1267,6 +1267,7 @@ module Dns = struct
   let getaddrinfo node family =
     Luv_lwt.in_luv (fun return ->
         Luv.DNS.getaddrinfo ~family ~node () (function
+          | Error `EAI_NODATA -> return (Ok [])
           | Error err -> return (Error (`Msg (Luv.Error.strerror err)))
           | Ok x ->
               let ips =
```

[Needs proper testing]
@Vigrond: Hello, this is the most recent / active issue thread for this problem, so I will post my experience here.

This bug revealed itself to me with my `gitlab-runner` running `dind-rootless` and trying to run `npm install` in a `node:alpine` image and failing with `getaddrinfo ENOTFOUND github.com`

Curiously, running `npm install` in a non-alpine `node` image works and properly resolves due to the different implementations of `getaddrinfo` (glibc vs musl) and how they handle `NXDOMAIN` responses by default.

Confirming this is a VPNKit issue, as other commenters have also done, but I will reiterate with some simple code:

A regular docker dind image (which doesn't use rootlesskit + VPNkit) works:
```
docker run --rm -d --name nstest-dind --privileged docker:dind
sleep 5
docker exec -it nstest-dind docker run --rm alpine /bin/sh -c "apk add bind-tools && dig github.com A github.com AAAA && wget github.com"
```

This gives us the following successful response:

```
; <<>> DiG 9.18.37 <<>> github.com A github.com AAAA
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 37070
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;github.com.			IN	A

;; ANSWER SECTION:
github.com.		60	IN	A	140.82.116.3

;; Query time: 17 msec
;; SERVER: 192.168.0.100#53(192.168.0.100) (UDP)
;; WHEN: Sat May 24 23:58:46 UTC 2025
;; MSG SIZE  rcvd: 55

;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 34564
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;github.com.			IN	AAAA

;; AUTHORITY SECTION:
github.com.		2121	IN	SOA	dns1.p08.nsone.net. hostmaster.nsone.net. 1656468023 43200 7200 1209600 3600

;; Query time: 0 msec
;; SERVER: 192.168.0.100#53(192.168.0.100) (UDP)
;; WHEN: Sat May 24 23:58:46 UTC 2025
;; MSG SIZE  rcvd: 123

Connecting to github.com (140.82.116.3:80)
Connecting to github.com (140.82.116.3:443)
saving to 'index.html'
index.html           100% |********************************|  281k  0:00:00 ETA
'index.html' saved

```

We can see that an `A` record was pulled, `AAAA` did not exist but status was still `NOERROR` and an Authority `SOA` was returned, properly.  We take note of my PiHole DNS server `192.168.0.100:53`

`wget`ing the index of github.com also works, as expected.

Let's try dind-rootless, which integrates rootlesskit and VPNkit:

```
docker run --rm -d --name nstest-dindrootless --privileged docker:dind-rootless
sleep 5
docker exec -it nstest-dindrootless docker -H unix://run/user/1000/docker.sock run --rm alpine /bin/sh -c "apk add bind-tools && dig github.com A github.com AAAA && wget github.com"
```

And we get a slightly different, but more disappointing response:

```
; <<>> DiG 9.18.37 <<>> github.com A github.com AAAA
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 57182
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;github.com.			IN	A

;; ANSWER SECTION:
github.com.		0	IN	A	140.82.116.4
github.com.		0	IN	A	140.82.116.4

;; Query time: 21 msec
;; SERVER: 192.168.65.1#53(192.168.65.1) (UDP)
;; WHEN: Sun May 25 00:02:46 UTC 2025
;; MSG SIZE  rcvd: 60

;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 41651
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0

;; QUESTION SECTION:
;github.com.			IN	AAAA

;; Query time: 0 msec
;; SERVER: 192.168.65.1#53(192.168.65.1) (UDP)
;; WHEN: Sun May 25 00:02:46 UTC 2025
;; MSG SIZE  rcvd: 28

wget: bad address 'github.com'
```
`A` records are returned properly.  However, we get an `NXDOMAIN` status for our `AAAA` request, no `SOA` Authority, and `wget` fails with "bad address".  We take note of the VPNKit DNS server `192.168.65.1:53`

How do we know this is really caused by a picky `getaddrinfo` implementation?  Well let's try a `glibc` implementation of `getaddrinfo` on our same exact dind-rootless container using an ubuntu image instead of alpine:

```
docker exec -it nstest-dindrootless docker -H unix://run/user/1000/docker.sock run --rm ubuntu /bin/sh -c "apt update && apt install -y dnsutils wget && dig github.com A github.com AAAA && wget github.com"
```

In the effort to keep this post shorter, I'll spare the copy/paste and say while `NXDOMAIN` is returned still, `wget` succeeds.

For those looking for a quickfix but do not want to switch to a non-alpine  image or an image implementing `glibc`, simply specifying an alternate DNS also works:

```
docker exec -it nstest-dindrootless docker -H unix://run/user/1000/docker.sock run --rm --dns 8.8.8.8 alpine /bin/sh -c "apk add bind-tools && dig github.com A github.com AAAA && wget github.com"
```

So who is wrong?  [musl's implementation of getaddrinfo](https://github.com/libuv/libuv/issues/2225) or VPNKits DNS NXDOMAIN responses?  I am sure both have caused issues industry-wide.  But for this case, I am going to lean towards the latter.

Digging around the VPNKit source code and being unfamiliar with OCaml, my best guess on this faulty logic is here: https://github.com/moby/vpnkit/blob/master/src/hostnet/hostnet_dns.ml#L175 and then (in the same file) here: https://github.com/moby/vpnkit/blob/master/src/hostnet/hostnet_dns.ml#L415 .  But I have no idea what I am doing here.

Though it seems work has already been done https://github.com/moby/vpnkit/pull/645 .  I don't know if it is confirmed that this fixes the issue, and I don't know if that work has propagated up towards the latest dind-rootless images.  The above tests imply that either the latest dind-rootless images do not have the work from #645, or they do and it didn't fix it.

finito@keigoi: I and my colleague discovered the same fix as @joanbm independently.

This fix seems perfectly sound given the meaning of EAI_NODATA below, and that the list returned here will be used directly as the DNS server's reply.

> EAI_NODATA 
> The specified network host exists, but does not have any network addresses defined.

@joanbm, Could you prepare this patch as a PR soon? 

If not, I'd like to prepare a PR for this in the near future, making sure to include your attribution using `Co-authored-by:` in the commit.@dan0dbfe: Hello, thanks for investigating and fixing the issue for real.

It looks like the empty AAAA test added in #645 only catches this if executed in musl distros like Alpine - which I did not do.

Solution patch:
diff --git a/src/hostnet/host.ml b/src/hostnet/host.ml
index 3f29b451..9e8cd69a 100644
--- a/src/hostnet/host.ml
+++ b/src/hostnet/host.ml
@@ -1294,10 +1294,11 @@ module Dns = struct
               in
               return (Ok ips)))
     >>= function
-    | Error (`Msg _) ->
-        (* FIXME: error handling completely missing *)
-        Lwt.return []
-    | Ok ips -> Lwt.return ips
+    | Error e ->
+      (* FIXME: error handling completely missing *)
+      Lwt.return (Error e)
+    | Ok ips -> 
+      Lwt.return (Ok ips)
 
   let localhost_local = Dns.Name.of_string "localhost.local"
 
@@ -1306,17 +1307,19 @@ module Dns = struct
     (match question with
     | { q_class = Q_IN; q_name; _ } when q_name = localhost_local ->
         Log.debug (fun f -> f "DNS lookup of localhost.local: return NXDomain");
-        Lwt.return (q_name, [])
+        Lwt.return (Error q_name)
     | { q_class = Q_IN; q_type = Q_A; q_name; _ } ->
-        getaddrinfo (Dns.Name.to_string q_name) `INET >>= fun ips ->
-        Lwt.return (q_name, ips)
+        (getaddrinfo (Dns.Name.to_string q_name) `INET >>= function
+          | Error _ -> Lwt.return (Error q_name)
+          | Ok ips -> Lwt.return (Ok (q_name, ips)))
     | { q_class = Q_IN; q_type = Q_AAAA; q_name; _ } ->
-        getaddrinfo (Dns.Name.to_string q_name) `INET6 >>= fun ips ->
-        Lwt.return (q_name, ips)
-    | _ -> Lwt.return (Dns.Name.of_string "", []))
+        (getaddrinfo (Dns.Name.to_string q_name) `INET6 >>= function
+          | Error _ -> Lwt.return (Error q_name)
+          | Ok ips -> Lwt.return (Ok (q_name, ips)))
+    | _ -> 
+        Lwt.return (Error (Dns.Name.of_string "")))
     >>= function
-    | _, [] -> Lwt.return []
-    | q_name, ips ->
+    | Ok (q_name, ips) ->
         let answers =
           List.map
             (function
@@ -1338,7 +1341,9 @@ module Dns = struct
                   })
             ips
         in
-        Lwt.return answers
+        Lwt.return (Ok answers)
+    | Error _ ->
+        Lwt.return (Error ())
 
   let resolve = resolve_getaddrinfo
 end
diff --git a/src/hostnet/hostnet_dns.ml b/src/hostnet/hostnet_dns.ml
index 80d55f9d..3bd0c889 100644
--- a/src/hostnet/hostnet_dns.ml
+++ b/src/hostnet/hostnet_dns.ml
@@ -446,10 +446,11 @@ struct
             | _, Host ->
               D.resolve question
               >>= function
-              | [] ->
-                Lwt.return (Ok (Some (marshal nxdomain)))
-              | answers ->
+              | Ok answers ->
                 Lwt.return (Ok (marshal_reply answers))
+              (* Currently return all errors as NXDOMAIN *)
+              | Error _ ->
+                Lwt.return (Ok (Some (marshal nxdomain)))
       end
     | _ ->
       Lwt.return (Error (`Msg "DNS packet had multiple questions"))
diff --git a/src/hostnet/hostnet_http.ml b/src/hostnet/hostnet_http.ml
index 910bc5f8..c09ff39f 100644
--- a/src/hostnet/hostnet_http.ml
+++ b/src/hostnet/hostnet_http.ml
@@ -151,16 +151,19 @@ module Make
       let question =
         make_question ~q_class:Q_IN Q_A (Dns.Name.of_string name_or_ip)
       in
-      Dns_resolver.resolve question
-      >>= fun rrs ->
-      (* Any IN record will do (NB it might be a CNAME) *)
-      let rec find_ip = function
-        | { cls = RR_IN; rdata = A ipv4; _ } :: _ ->
-          Lwt.return (Ok (Ipaddr.V4 ipv4))
-        | _ :: rest -> find_ip rest
-        | [] -> errorf "Failed to lookup host: %s" name_or_ip in
-      find_ip rrs
-    | Ok x -> Lwt.return (Ok x)
+      (Dns_resolver.resolve question >>= function
+      | Ok rrs ->
+        (* Any IN record will do (NB it might be a CNAME) *)
+        let rec find_ip = function
+          | { cls = RR_IN; rdata = A ipv4; _ } :: _ ->
+            Lwt.return (Ok (Ipaddr.V4 ipv4))
+          | _ :: rest -> find_ip rest
+          | [] -> errorf "Failed to lookup host: %s" name_or_ip
+        in
+        find_ip rrs
+      | Error _ ->
+        Lwt.return (Error (`Msg (Printf.sprintf "Failed to lookup host: %s" name_or_ip))))
+    | Ok ip -> Lwt.return (Ok ip)
 
   let to_json t =
     let open Ezjsonm in
diff --git a/src/hostnet/sig.ml b/src/hostnet/sig.ml
index d3002ce0..8c5b9373 100644
--- a/src/hostnet/sig.ml
+++ b/src/hostnet/sig.ml
@@ -130,7 +130,7 @@ module type FILES = sig
 end
 
 module type DNS = sig
-  val resolve: Dns.Packet.question -> Dns.Packet.rr list Lwt.t
+  val resolve: Dns.Packet.question -> (Dns.Packet.rr list, unit) result Lwt.t
   (** Given a question, find associated resource records *)
 end
 



Test patch:
diff --git a/src/hostnet_test/test_dns.ml b/src/hostnet_test/test_dns.ml
index e2aff321..10ab4730 100644
--- a/src/hostnet_test/test_dns.ml
+++ b/src/hostnet_test/test_dns.ml
@@ -110,6 +110,37 @@ let test_etc_hosts_priority server config () =
   in
   run ~pcap:"test_etc_hosts_priority.pcap" t
 
+let test_noerror_empty_response server config () =
+  let name = "ipv4.tlund.se" in (* FIXME: Use a better address? *)
+  let t _ stack =
+    set_dns_policy config;
+    let resolver = DNS.create stack.Client.t in
+    (* Query for an AAAA record on a domain that might only have A records *)
+    DNS.gethostbyname ~server ~q_type:Dns.Packet.Q_AAAA resolver name >>= function
+    | [] -> (* Expected: NOERROR with an empty answer list *)
+      Log.info (fun f -> f "%s returned NOERROR with no AAAA records" name);
+      Lwt.return ()
+    | _ ->
+      Log.err (fun f -> f "Expected no AAAA records for %s, but got some" name);
+      failwith ("Expected NOERROR with no AAAA records for " ^ name)
+  in
+  run ~pcap:"test_noerror_empty_response.pcap" t
+
+let test_nxdomain_response server config () =
+  let name = "nonexistent.example.com" in (* Use a domain that doesn’t exist *)
+  let t _ stack =
+    set_dns_policy config;
+    let resolver = DNS.create stack.Client.t in
+    DNS.gethostbyname ~server resolver name >>= function
+    | [] -> (* Expected: NXDOMAIN (empty answer list) *)
+      Log.info (fun f -> f "%s returned NXDOMAIN as expected" name);
+      Lwt.return ()
+    | _ ->
+      Log.err (fun f -> f "Expected NXDOMAIN for %s, but got answers" name);
+      failwith ("Expected NXDOMAIN for " ^ name)
+  in
+  run ~pcap:"test_nxdomain_response.pcap" t
+
 let test_dns config =
   let prefix = Dns_policy.(Config.to_string @@ config ()) in [
     prefix ^ ": lookup ",
@@ -123,6 +154,12 @@ let test_dns config =
 
     prefix ^ ": _etc_hosts_priority",
     [ "", `Quick, test_etc_hosts_priority primary_dns_ip config ];
+
+    prefix ^ ": noerror_empty_response",
+    [ "", `Quick, test_noerror_empty_response primary_dns_ip config ];
+
+    prefix ^ ": nxdomain_response",
+    [ "", `Quick, test_nxdomain_response primary_dns_ip config ];
   ]
 
 (* A real UDP server listening on a physical port *)