The tunnel is up, the firewall permits port 53, AdGuard Home is running, and names still do not resolve for the WireGuard client. The query normally arrives and an answer normally leaves, so packet counters on the resolver look healthy and the service appears innocent. What goes wrong is the source address on that reply: it leaves from a local address the client never asked, and the client's stub resolver throws it away. One list in AdGuard Home's configuration decides this, and its usual default hands the decision to the kernel.

The query arrives, the answer leaves from somewhere else

A client configured with 10.6.0.1 as its resolver sends a UDP query to 10.6.0.1 port 53. That packet crosses the tunnel and lands on the host, and AdGuard Home answers it. On the wire, though, the reply is a new packet whose source address is chosen by the host's stack, not by the client's expectation.

When the client's resolver is a connected socket, which is what systemd-resolved, dnsmasq and most stub resolvers use, it accepts only replies from the exact address it sent to. A reply sourced from 192.168.1.10 to a query sent to 10.6.0.1 is dropped before any parsing happens. The resolver logs a successful answer, the client logs a timeout, and both are telling the truth.

So the counters are not lying and neither is the client. The evidence you need is in three places, in this order: the address the listener is bound to, the route the kernel would use back to the client, and the source address on the actual reply packet.

How the kernel chooses the reply source

A UDP socket bound to 0.0.0.0 has no source address of its own, so each outgoing reply triggers a route lookup for the destination. The kernel then takes the source from that route: the address pinned by the route, or the primary address of the outgoing interface. Nothing about the query's destination address is remembered.

On a single-homed host this is invisible, because there is only one plausible answer. On a machine holding a LAN address, a WireGuard address and a management address, several answers are plausible, and the winner depends on the routing table at that moment. Add an extra route, a second address on the same interface, a NAT redirect in front of the resolver, or run AdGuard in a container with its own namespace, and the chosen source can differ from the one the client queried.

ss -lunp | grep :53 shows which game you are in. A listener on 0.0.0.0:53 is the wildcard case; a listener on 10.6.0.1:53 has already pinned the answer. Reading the socket is faster than reading the YAML, and it reflects what is actually running rather than what was last saved.

What bind_hosts actually controls

AdGuard Home's dns.bind_hosts is the list of local addresses the DNS listener uses. The shipped default is the wildcard, which means accept on every local address and let the kernel pick the source per reply. Replacing the wildcard with the address clients query changes the socket, and with it the source address on every answer.

The list takes more than one entry, which matters on a resolver serving two networks. Listing 10.6.0.1 and 192.168.1.10 gives two bound sockets, and a query arriving on either one is answered from the address it arrived on. That is the behaviour most people assume the wildcard already provides.

The YAML is plain, and the address in it is the one your tunnel clients were told to use, not the host's primary address:

dns:
  bind_hosts:
    - 10.6.0.1
  port: 53

Why two installations of one resolver disagree

Plugin-managed, in this context, means the resolver was installed and is supervised by another package: a router firmware package, a NAS package, or a home-automation add-on. That package usually ships its own copy of AdGuard Home's YAML and restarts the process on its own schedule, while a standalone instance is the vendor binary or container you configure yourself. Either way you are looking at two independent processes with separate configuration files and separate bind choices, even when both read the same upstream list and the same filter lists. One can bind the specific tunnel address while the other inherits the wildcard, and the web interface looks identical from either side.

That is why "the same resolver worked before the migration" proves nothing about the current binding. Check the socket, then check the configuration file the running process actually loaded. A stale copy in another directory is a common reason the edit appears to have no effect.

Pin it, then test from the tunnel side

Change bind_hosts to the tunnel address, restart the service, and confirm the socket moved: ss -lunp | grep :53 should now show 10.6.0.1:53 and no wildcard entry. If it still shows 0.0.0.0, the running process is not reading the file you edited.

Then test from the client, because that is the only side that matters. A query with dig @10.6.0.1 example.com exercises the path the user exercises, and a capture on the host with tcpdump -ni wg0 udp port 53 shows the reply's source address on the wire. Compare it with the address in the client's resolver setting; when they are equal, the timeout stops being a mystery.

ip route get 10.6.0.2 explains any remaining oddity, because it prints the interface and source the host would use back to the client. If that output names the LAN interface, the bind change is not the whole fix and the routing table is the next thing to read.

What the bind setting cannot fix

Pinning the listener does not repair an access list that omits the tunnel subnet, a firewall rule that permits the query but not the reply, or a missing route back to the WireGuard pool. Those fail earlier or later than the source address, and each has a different owner.

It also does not change which upstream the resolver forwards to, whether rebinding protection rejects a private answer for a private name, or how the client applies its own search domains. Those are separate controls. Change the bind address when the reply source is wrong, and change something else when it is not.

Nuŋlɔɖi siwo ku ɖe eŋu