> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# DNS over TLS vs DNS over HTTPS: Test Both with kdig
- URL: https://www.pinglabz.com/dns-dot-doh-kdig-lab/
- Published: 2026-10-13T06:25:29.000Z
- Updated: 2026-10-13T06:25:29.000Z
- Description: Query one signed answer over four transports, test certificate identity and DoH paths, then carry a DNSSEC-bogus answer over verified TLS.
- Author: Jaime
- Tags: DNS, Network Security, Packet Analysis, Linux Labs

The same signed A record arrived over UDP, TCP, DNS over TLS, and DNS over HTTPS in this lab. The visible packets were different, and so were the failure checks: an incorrect certificate hostname stopped DoT, while an incorrect DoH path reached HTTPS and returned HTTP 404.

Then the parent DS was deliberately broken. Both encrypted transports still delivered `203.0.113.42` over verified TLS connections. Local DNSSEC validation rejected the chain. That distinction is easy to lose when a tool prints a TLS session and a DNS answer in the same output.

This walkthrough reuses [the private DNSSEC lab](https://www.pinglabz.com/dnssec-broken-chain-delv-lab/). Complete its namespace, key, zone, and baseline steps first. For basic query output, keep [the dig guide](https://www.pinglabz.com/dig-command/) nearby; for certificate identity and trust-chain details, use [the OpenSSL TLS investigation](https://www.pinglabz.com/openssl-sni-hostname-certificate-chain-lab/).

## Hold the endpoint and answer constant

The client is `192.0.2.10`; one authoritative BIND process on `192.0.2.20` serves the same signed zones through these listeners:

- UDP and TCP `1053`: plain DNS.
- TCP `1853`: DoT with TLS 1.3.
- TCP `18443`: DoH with TLS 1.3, HTTP/2, and `/dns-query`.

The nonstandard ports make the lab explicit. DoT normally uses port 853; DoH uses an HTTPS endpoint URI. The protocol definitions are [RFC 7858](https://datatracker.ietf.org/doc/html/rfc7858?ref=pinglabz.com) and [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484?ref=pinglabz.com).

Tested with: Ubuntu 26.04.1 LTS, Knot DNS `kdig` `3.5.3`, BIND `9.20.24`, OpenSSL `3.5.5`, tcpdump `4.99.6`, TShark `4.6.4`. BIND was built with OpenSSL and libnghttp2 support. Syntax was checked with the installed tools and [Knot's kdig reference](https://www.knot-dns.cz/docs/3.5/html/man%5Fkdig.html?ref=pinglabz.com).

This is an authoritative endpoint comparison. Every query names an explicit IP, so it does not depend on the host resolving the endpoint's hostname first.

## Create a local certificate and TLS listeners

Stop the foreground server from the prerequisite lab before starting its replacement. In the ordinary-user shell, enter the same lab directory and create a disposable CA, an unrelated CA for a negative test, and a server certificate:

```bash
DNS_LAB_ROOT=${DNS_LAB_ROOT:-"$HOME/dns-lab"} DNS_CLIENT=${DNS_CLIENT:-pldns-client} DNS_SERVER=${DNS_SERVER:-pldns-server}
cd "$DNS_LAB_ROOT"
umask 077
for ca in ca wrong-ca; do
  openssl req -x509 -newkey rsa:2048 -noenc -days 14 \
    -subj "/CN=DNS Lab $ca" \
    -addext 'basicConstraints=critical,CA:TRUE' \
    -addext 'keyUsage=critical,keyCertSign,cRLSign' \
    -keyout "pki/$ca.key" -out "pki/$ca.crt"
done
openssl req -new -newkey rsa:2048 -noenc \
  -subj '/CN=dns.lab.test' \
  -keyout pki/server.key -out pki/server.csr
cat > pki/server.ext <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:dns.lab.test
EOF
openssl x509 -req -in pki/server.csr \
  -CA pki/ca.crt -CAkey pki/ca.key -CAcreateserial \
  -days 5 -extfile pki/server.ext -out pki/server.crt
openssl verify -CAfile pki/ca.crt -verify_hostname dns.lab.test pki/server.crt
openssl x509 -in pki/server.crt -noout -dates -ext subjectAltName
```

The certificate has `dns.lab.test` in its DNS subject alternative name. Clients will connect to the IP while checking that identity. `pki/ca.crt` is the public trust certificate, not the CA's private key. The leaf lasts five days; inspect dates and regenerate it for later reruns rather than bypassing an expiration failure.

Create transport versions of the healthy and bogus server configurations. The original zone files remain intact:

```bash
python3 - <<'PYTLS'
from pathlib import Path
root = Path.cwd()
listeners = '''listen-on port 1853 tls lab_tls { 192.0.2.20; };
 listen-on port 18443 tls lab_tls http default { 192.0.2.20; };
 listen-on-v6'''
tls = f'''\ntls lab_tls {{
 key-file "{root}/pki/server.key";
 cert-file "{root}/pki/server.crt";
 protocols {{ TLSv1.3; }};
}};\n'''
for mode in ('good', 'bogus'):
    original = Path(f'named-{mode}.conf').read_text()
    Path(f'named-{mode}-transport.conf').write_text(
        original.replace('listen-on-v6', listeners) + tls)
PYTLS
named-checkconf -z named-good-transport.conf
named-checkconf -z named-bogus-transport.conf
```

The resulting listeners and TLS block use BIND's [listen-on and TLS configuration](https://bind9.readthedocs.io/en/v9.20.24/reference.html?ref=pinglabz.com#tls-block-definition-and-usage). No server-side `ca-file` is set here: the server presents its certificate but does not require a client certificate.

Start the healthy transport server in a separate VM terminal:

```bash
DNS_LAB_ROOT=${DNS_LAB_ROOT:-"$HOME/dns-lab"} DNS_CLIENT=${DNS_CLIENT:-pldns-client} DNS_SERVER=${DNS_SERVER:-pldns-server}
cd "$DNS_LAB_ROOT"
sudo ip netns exec "$DNS_SERVER" \
  "$PWD/vendor/extracted/usr/sbin/named" \
  -g -n 1 -u "$USER" -c "$PWD/named-good-transport.conf"
```

Wait for `running`. In the query terminal, the first block below sets the same lab variables and directory. All four queries ask for the same FQDN and type, disable recursion, and request DNSSEC records.

## Compare four successful exchanges

Plain UDP and TCP:

```bash
DNS_LAB_ROOT=${DNS_LAB_ROOT:-"$HOME/dns-lab"} DNS_CLIENT=${DNS_CLIENT:-pldns-client} DNS_SERVER=${DNS_SERVER:-pldns-server}
cd "$DNS_LAB_ROOT"
sudo ip netns exec "$DNS_CLIENT" kdig \
  @192.0.2.20 -p 1053 www.secure.lab.test. A \
  +norecurse +dnssec +timeout=2 +retry=0
sudo ip netns exec "$DNS_CLIENT" kdig \
  @192.0.2.20 -p 1053 www.secure.lab.test. A \
  +tcp +norecurse +dnssec +timeout=2 +retry=0
```

For DoT, configure the CA and the expected hostname explicitly:

```bash
sudo ip netns exec "$DNS_CLIENT" kdig \
  @192.0.2.20 -p 1853 www.secure.lab.test. A \
  +tls +tls-ca="$PWD/pki/ca.crt" \
  +tls-hostname=dns.lab.test +tls-sni=dns.lab.test \
  +norecurse +dnssec +timeout=2 +retry=0
```

`+tls` alone selects opportunistic TLS. It is not the strict authentication check demonstrated here. `+tls-ca` supplies the trust certificate, `+tls-hostname` supplies the identity to verify, and `+tls-sni` supplies the name sent in ClientHello. Keeping all three explicit makes failures easier to interpret.

For DoH, retain the same identity checks and select the endpoint path:

```bash
sudo ip netns exec "$DNS_CLIENT" kdig \
  @192.0.2.20 -p 18443 www.secure.lab.test. A \
  +https=/dns-query +tls-ca="$PWD/pki/ca.crt" \
  +tls-hostname=dns.lab.test +tls-sni=dns.lab.test \
  +norecurse +dnssec +timeout=2 +retry=0
```

Selected output from the measured DoH baseline:

```text
;; TLS session (TLS1.3)-(ECDHE-SECP256R1)-(RSA-PSS-RSAE-SHA256)-(AES-256-GCM)
;; HTTP session (HTTP/2-POST)-(dns.lab.test/dns-query)-(status: 200)
;; ->>HEADER<<- opcode: QUERY; status: NOERROR; id: 0
;; Flags: qr aa; QUERY: 1; ANSWER: 2; AUTHORITY: 2; ADDITIONAL: 1
www.secure.lab.test.	60	IN	A	203.0.113.42
;; From 192.0.2.20@18443(HTTPS) in 13.9 ms
```

All four baseline commands returned `NOERROR`, the same A record and its RRSIG, and the same signed NS data in the authority section. The output identified the transports as `(UDP)`, `(TCP)`, `(TLS)`, and `(HTTPS)`. DoH used HTTP/2 POST with HTTP status 200.

Each command opened a new connection where applicable. The server was authoritative with a 60-second record TTL, not a recursive cache.

## What the capture can actually show

Capture one exchange at a time from client `eth0`. Use a different filename for each case, start before the query, wait for the listening message, and press Ctrl-C afterward:

```bash
sudo ip netns exec "$DNS_CLIENT" tcpdump \
  --immediate-mode -U -i eth0 -nn -s 0 \
  -w "$PWD/evidence/dot.pcap" "host 192.0.2.20"
```

For example, this offline command reads the DoT capture and decodes the lab port as TLS. The shell opens the file and passes it to TShark on stdin:

```bash
tshark -r - -d tcp.port==1853,tls \
  -Y tls -T fields -e frame.number \
  -e tls.handshake.extensions_server_name \
  -e tls.handshake.extensions_alpn_str \
  -e tls.record.content_type -e tls.record.opaque_type \
  < evidence/dot.pcap
```

For DoH, change the decode port to `18443` and use its capture filename. In the retained tests:

- The UDP and TCP captures exposed the DNS question, response code, A record, and signatures. Both contained two DNS messages.
- DoT ClientHello exposed SNI `dns.lab.test` and offered ALPN `dot`.
- DoH ClientHello exposed the same SNI and offered ALPN `h2`.
- Neither encrypted capture exposed a decoded DNS message without TLS secrets. Encrypted TLS 1.3 records used opaque outer type 23.

A type-23 record is not a count of DNS queries. TLS 1.3 also encrypts handshake details and alerts. The capture alone does not reveal the certificate verification result or the HTTP response status; the endpoint output provides those facts. No key log or session secrets were supplied to the analyzer.

IP addresses, ports, packet sizes, timing, and the measured ClientHello names remained visible. This setup did not use Encrypted ClientHello. If you want to compare readable DNS logs from an unencrypted PCAP, continue with [the Zeek walkthrough](https://www.pinglabz.com/pcap-to-zeek-connection-dns-logs/).

## Fail identity and endpoint checks separately

Repeat the working DoT command with only `+tls-hostname` changed to `wrong.lab.test`. Keep the connection IP, CA file, and SNI unchanged:

```bash
sudo ip netns exec "$DNS_CLIENT" kdig \
  @192.0.2.20 -p 1853 www.secure.lab.test. A \
  +tls +tls-ca="$PWD/pki/ca.crt" \
  +tls-hostname=wrong.lab.test +tls-sni=dns.lab.test \
  +norecurse +dnssec +timeout=2 +retry=0
```

Measured stderr:

```text
;; WARNING: TLS, handshake failed (Error in the certificate.)
;; ERROR: failed to query server 192.0.2.20@1853(TCP)
```

The command returned exit code 1 and no DNS answer. The message is generic; the controlled hostname change, the certificate SAN, and the successful matching-host baseline identify this case.

For the second test, restore `dns.lab.test` and change only `+tls-ca` to `"$PWD/pki/wrong-ca.crt"`. The unrelated CA also produced the certificate error, exit code 1, and no answer. Restore both the proper CA and hostname; the same DoT query succeeds again. Do not make the repair by removing the authentication checks.

For DoH, keep its valid CA, hostname, and SNI but replace `+https=/dns-query` with `+https=/wrong`. Selected measured stdout:

```text
;; HTTP session (HTTP/2-POST)-(dns.lab.test/wrong)-(status: 404)
```

This query returned exit code 1 without a DNS answer, after reaching an HTTP/2 endpoint that returned 404\. TLS identity was accepted; the HTTP path was wrong. Restoring `/dns-query` returned HTTP 200 and the original A record. A wrong certificate name and a wrong URI path fail at different stages even when the tool ultimately reports a failed query for both.

## Carry a DNSSEC-bogus answer over verified TLS

Stop the healthy transport server and restart the foreground command using `named-bogus-transport.conf`. This selects the signed parent with the wrong child DS from the prerequisite lab. The child A record and TLS certificate remain unchanged.

Repeat the authenticated DoT and DoH commands. Both returned the same signed A answer with `NOERROR`; the DoH exchange again reported HTTP status 200\. The response flags were `qr aa`, without AD. This authoritative server is not acting as a validating recursive resolver.

Now run the local validator against the plain listener:

```bash
sudo ip netns exec "$DNS_CLIENT" delv \
  @192.0.2.20 -p 1053 -a "$PWD/anchor.conf" +root=lab.test \
  www.secure.lab.test. A +rtrace +vtrace +trust
```

It rejected the chain with `no DNSKEY matching DS` and `resolution failed: broken trust chain`. This is a separate local DNSSEC check against the same server state.

`kdig +dnssec` requested the DNSSEC records; it did not establish their chain of trust. TLS authenticated the configured endpoint and protected that transport exchange. Those successful checks can coexist with a bogus DNSSEC delegation, as the [DNSSEC fault investigation](https://www.pinglabz.com/dnssec-broken-chain-delv-lab/) shows in detail. A validating recursive endpoint could instead refuse the bogus result; that is a different server configuration from this experiment.

For the final recovery, restart `named-good-transport.conf`. The measured `delv` result returned to `fully validated`, while the strictly authenticated DoT and DoH queries continued to return the expected answer. The recovery asserts both layers rather than treating the appearance of an encrypted DNS answer as proof of DNSSEC health.