Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 41 of 56

DNS and NTP: portable by construction

Two clients written entirely against GNAT.Sockets, so the same source runs on a desktop and on the board — and one shared concurrency wrinkle worth knowing.

One line to resolve a name

function Resolve (...) return ...;    --  Net_Resolver

Net_Resolver turns a host name into an address whatever is carrying the traffic. Resolution is a real DNS query of our own — a UDP A-record request that DNS_Client issues over GNAT.Sockets — so it works identically over Ethernet, Wi-Fi, cellular, or whatever the routing table points at.

Why not use the modem's own resolver? Because one was tried and removed. The BG95's AT+QIDNSGIP silently refused answers whose shape it did not like — a CNAME chain onto several A records failed where a bare A record resolved. Doing DNS ourselves means one code path with predictable behaviour, rather than a per-modem set of quirks.

DNS_Client offers both Resolve (UDP) and Resolve_TCP, the latter for answers too large for a datagram. NTP_Client.Query is the same shape — a UDP query reading the transmit timestamp out of the reply — with To_UTC to convert it.

Portability is the point

Neither client contains anything chip-specific. On the board you call GNAT.Sockets.Initialize (Device) once during bring-up; on a desktop sockets are always usable. The same source then compiles in both places, which is what lets these be tested on a host rather than only on hardware.

The shared wrinkle

Both keep package-global rotors — the transaction id and the default source port for DNS, a source-port counter for NTP. Concurrent calls from several tasks corrupt nothing, but two in-flight queries can land on the same source port, and one then fails its reply check. That surfaces as a failed lookup, not an exception or corruption. If you resolve from more than one task, either serialise the calls or accept the retry.

The source-port rotation is not incidental — a fixed source port is what made an earlier cellular setup fail, because the carrier's NAT poisoned the flow. Rotating ports is a deliberate hardening measure, and the collision above is its small cost.

Encrypted DNS

Plain DNS is readable by anything on the path, and on a shared or hostile network the names a device looks up leak what it is doing. DNS_TLS adds the two encrypted transports over the pure-Ada TLS 1.3 stack:

TransportHow
DoT (RFC 7858) The ordinary DNS message, inside TLS, on port 853.
DoH (RFC 8484) A minimal HTTP/1.1 POST of application/dns-message over HTTPS, port 443 — so it survives networks that block 853.

The message bytes are the same proven ones every transport shares. Only the carriage differs, so the parser and builder under UDP, TCP, DoT and DoH are one implementation with one set of tests (step 52) — not four chances to get a message wrong.

Trust stays with the application, exactly as in the HTTPS examples: the caller establishes the TCP connection and supplies the trust anchors. The resolver does not carry a built-in root store or decide for you which resolver is trustworthy. Note also that a root-pinned DoT or DoH endpoint may need P-384 rather than P-256, depending on whose certificate chain you are anchoring to.

DNS and NTP: portable by construction · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 41 of 56

DNS and NTP: portable by construction

Two clients written entirely against GNAT.Sockets, so the same source runs on a desktop and on the board — and one shared concurrency wrinkle worth knowing.

One line to resolve a name

function Resolve (...) return ...;    --  Net_Resolver

Net_Resolver turns a host name into an address whatever is carrying the traffic. Resolution is a real DNS query of our own — a UDP A-record request that DNS_Client issues over GNAT.Sockets — so it works identically over Ethernet, Wi-Fi, cellular, or whatever the routing table points at.

Why not use the modem's own resolver? Because one was tried and removed. The BG95's AT+QIDNSGIP silently refused answers whose shape it did not like — a CNAME chain onto several A records failed where a bare A record resolved. Doing DNS ourselves means one code path with predictable behaviour, rather than a per-modem set of quirks.

DNS_Client offers both Resolve (UDP) and Resolve_TCP, the latter for answers too large for a datagram. NTP_Client.Query is the same shape — a UDP query reading the transmit timestamp out of the reply — with To_UTC to convert it.

Portability is the point

Neither client contains anything chip-specific. On the board you call GNAT.Sockets.Initialize (Device) once during bring-up; on a desktop sockets are always usable. The same source then compiles in both places, which is what lets these be tested on a host rather than only on hardware.

The shared wrinkle

Both keep package-global rotors — the transaction id and the default source port for DNS, a source-port counter for NTP. Concurrent calls from several tasks corrupt nothing, but two in-flight queries can land on the same source port, and one then fails its reply check. That surfaces as a failed lookup, not an exception or corruption. If you resolve from more than one task, either serialise the calls or accept the retry.

The source-port rotation is not incidental — a fixed source port is what made an earlier cellular setup fail, because the carrier's NAT poisoned the flow. Rotating ports is a deliberate hardening measure, and the collision above is its small cost.

Encrypted DNS

Plain DNS is readable by anything on the path, and on a shared or hostile network the names a device looks up leak what it is doing. DNS_TLS adds the two encrypted transports over the pure-Ada TLS 1.3 stack:

TransportHow
DoT (RFC 7858) The ordinary DNS message, inside TLS, on port 853.
DoH (RFC 8484) A minimal HTTP/1.1 POST of application/dns-message over HTTPS, port 443 — so it survives networks that block 853.

The message bytes are the same proven ones every transport shares. Only the carriage differs, so the parser and builder under UDP, TCP, DoT and DoH are one implementation with one set of tests (step 52) — not four chances to get a message wrong.

Trust stays with the application, exactly as in the HTTPS examples: the caller establishes the TCP connection and supplies the trust anchors. The resolver does not carry a built-in root store or decide for you which resolver is trustworthy. Note also that a root-pinned DoT or DoH endpoint may need P-384 rather than P-256, depending on whose certificate chain you are anchoring to.

DNS and NTP: portable by construction · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 41 of 56

DNS and NTP: portable by construction

Two clients written entirely against GNAT.Sockets, so the same source runs on a desktop and on the board — and one shared concurrency wrinkle worth knowing.

One line to resolve a name

function Resolve (...) return ...;    --  Net_Resolver

Net_Resolver turns a host name into an address whatever is carrying the traffic. Resolution is a real DNS query of our own — a UDP A-record request that DNS_Client issues over GNAT.Sockets — so it works identically over Ethernet, Wi-Fi, cellular, or whatever the routing table points at.

Why not use the modem's own resolver? Because one was tried and removed. The BG95's AT+QIDNSGIP silently refused answers whose shape it did not like — a CNAME chain onto several A records failed where a bare A record resolved. Doing DNS ourselves means one code path with predictable behaviour, rather than a per-modem set of quirks.

DNS_Client offers both Resolve (UDP) and Resolve_TCP, the latter for answers too large for a datagram. NTP_Client.Query is the same shape — a UDP query reading the transmit timestamp out of the reply — with To_UTC to convert it.

Portability is the point

Neither client contains anything chip-specific. On the board you call GNAT.Sockets.Initialize (Device) once during bring-up; on a desktop sockets are always usable. The same source then compiles in both places, which is what lets these be tested on a host rather than only on hardware.

The shared wrinkle

Both keep package-global rotors — the transaction id and the default source port for DNS, a source-port counter for NTP. Concurrent calls from several tasks corrupt nothing, but two in-flight queries can land on the same source port, and one then fails its reply check. That surfaces as a failed lookup, not an exception or corruption. If you resolve from more than one task, either serialise the calls or accept the retry.

The source-port rotation is not incidental — a fixed source port is what made an earlier cellular setup fail, because the carrier's NAT poisoned the flow. Rotating ports is a deliberate hardening measure, and the collision above is its small cost.

Encrypted DNS

Plain DNS is readable by anything on the path, and on a shared or hostile network the names a device looks up leak what it is doing. DNS_TLS adds the two encrypted transports over the pure-Ada TLS 1.3 stack:

TransportHow
DoT (RFC 7858) The ordinary DNS message, inside TLS, on port 853.
DoH (RFC 8484) A minimal HTTP/1.1 POST of application/dns-message over HTTPS, port 443 — so it survives networks that block 853.

The message bytes are the same proven ones every transport shares. Only the carriage differs, so the parser and builder under UDP, TCP, DoT and DoH are one implementation with one set of tests (step 52) — not four chances to get a message wrong.

Trust stays with the application, exactly as in the HTTPS examples: the caller establishes the TCP connection and supplies the trust anchors. The resolver does not carry a built-in root store or decide for you which resolver is trustworthy. Note also that a root-pinned DoT or DoH endpoint may need P-384 rather than P-256, depending on whose certificate chain you are anchoring to.

DNS and NTP: portable by construction · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 41 of 56

DNS and NTP: portable by construction

Two clients written entirely against GNAT.Sockets, so the same source runs on a desktop and on the board — and one shared concurrency wrinkle worth knowing.

One line to resolve a name

function Resolve (...) return ...;    --  Net_Resolver

Net_Resolver turns a host name into an address whatever is carrying the traffic. Resolution is a real DNS query of our own — a UDP A-record request that DNS_Client issues over GNAT.Sockets — so it works identically over Ethernet, Wi-Fi, cellular, or whatever the routing table points at.

Why not use the modem's own resolver? Because one was tried and removed. The BG95's AT+QIDNSGIP silently refused answers whose shape it did not like — a CNAME chain onto several A records failed where a bare A record resolved. Doing DNS ourselves means one code path with predictable behaviour, rather than a per-modem set of quirks.

DNS_Client offers both Resolve (UDP) and Resolve_TCP, the latter for answers too large for a datagram. NTP_Client.Query is the same shape — a UDP query reading the transmit timestamp out of the reply — with To_UTC to convert it.

Portability is the point

Neither client contains anything chip-specific. On the board you call GNAT.Sockets.Initialize (Device) once during bring-up; on a desktop sockets are always usable. The same source then compiles in both places, which is what lets these be tested on a host rather than only on hardware.

The shared wrinkle

Both keep package-global rotors — the transaction id and the default source port for DNS, a source-port counter for NTP. Concurrent calls from several tasks corrupt nothing, but two in-flight queries can land on the same source port, and one then fails its reply check. That surfaces as a failed lookup, not an exception or corruption. If you resolve from more than one task, either serialise the calls or accept the retry.

The source-port rotation is not incidental — a fixed source port is what made an earlier cellular setup fail, because the carrier's NAT poisoned the flow. Rotating ports is a deliberate hardening measure, and the collision above is its small cost.

Encrypted DNS

Plain DNS is readable by anything on the path, and on a shared or hostile network the names a device looks up leak what it is doing. DNS_TLS adds the two encrypted transports over the pure-Ada TLS 1.3 stack:

TransportHow
DoT (RFC 7858) The ordinary DNS message, inside TLS, on port 853.
DoH (RFC 8484) A minimal HTTP/1.1 POST of application/dns-message over HTTPS, port 443 — so it survives networks that block 853.

The message bytes are the same proven ones every transport shares. Only the carriage differs, so the parser and builder under UDP, TCP, DoT and DoH are one implementation with one set of tests (step 52) — not four chances to get a message wrong.

Trust stays with the application, exactly as in the HTTPS examples: the caller establishes the TCP connection and supplies the trust anchors. The resolver does not carry a built-in root store or decide for you which resolver is trustworthy. Note also that a root-pinned DoT or DoH endpoint may need P-384 rather than P-256, depending on whose certificate chain you are anchoring to.

DNS and NTP: portable by construction · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 41 of 56

DNS and NTP: portable by construction

Two clients written entirely against GNAT.Sockets, so the same source runs on a desktop and on the board — and one shared concurrency wrinkle worth knowing.

One line to resolve a name

function Resolve (...) return ...;    --  Net_Resolver

Net_Resolver turns a host name into an address whatever is carrying the traffic. Resolution is a real DNS query of our own — a UDP A-record request that DNS_Client issues over GNAT.Sockets — so it works identically over Ethernet, Wi-Fi, cellular, or whatever the routing table points at.

Why not use the modem's own resolver? Because one was tried and removed. The BG95's AT+QIDNSGIP silently refused answers whose shape it did not like — a CNAME chain onto several A records failed where a bare A record resolved. Doing DNS ourselves means one code path with predictable behaviour, rather than a per-modem set of quirks.

DNS_Client offers both Resolve (UDP) and Resolve_TCP, the latter for answers too large for a datagram. NTP_Client.Query is the same shape — a UDP query reading the transmit timestamp out of the reply — with To_UTC to convert it.

Portability is the point

Neither client contains anything chip-specific. On the board you call GNAT.Sockets.Initialize (Device) once during bring-up; on a desktop sockets are always usable. The same source then compiles in both places, which is what lets these be tested on a host rather than only on hardware.

The shared wrinkle

Both keep package-global rotors — the transaction id and the default source port for DNS, a source-port counter for NTP. Concurrent calls from several tasks corrupt nothing, but two in-flight queries can land on the same source port, and one then fails its reply check. That surfaces as a failed lookup, not an exception or corruption. If you resolve from more than one task, either serialise the calls or accept the retry.

The source-port rotation is not incidental — a fixed source port is what made an earlier cellular setup fail, because the carrier's NAT poisoned the flow. Rotating ports is a deliberate hardening measure, and the collision above is its small cost.

Encrypted DNS

Plain DNS is readable by anything on the path, and on a shared or hostile network the names a device looks up leak what it is doing. DNS_TLS adds the two encrypted transports over the pure-Ada TLS 1.3 stack:

TransportHow
DoT (RFC 7858) The ordinary DNS message, inside TLS, on port 853.
DoH (RFC 8484) A minimal HTTP/1.1 POST of application/dns-message over HTTPS, port 443 — so it survives networks that block 853.

The message bytes are the same proven ones every transport shares. Only the carriage differs, so the parser and builder under UDP, TCP, DoT and DoH are one implementation with one set of tests (step 52) — not four chances to get a message wrong.

Trust stays with the application, exactly as in the HTTPS examples: the caller establishes the TCP connection and supplies the trust anchors. The resolver does not carry a built-in root store or decide for you which resolver is trustworthy. Note also that a root-pinned DoT or DoH endpoint may need P-384 rather than P-256, depending on whose certificate chain you are anchoring to.

DNS and NTP: portable by construction · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 41 of 56

DNS and NTP: portable by construction

Two clients written entirely against GNAT.Sockets, so the same source runs on a desktop and on the board — and one shared concurrency wrinkle worth knowing.

One line to resolve a name

function Resolve (...) return ...;    --  Net_Resolver

Net_Resolver turns a host name into an address whatever is carrying the traffic. Resolution is a real DNS query of our own — a UDP A-record request that DNS_Client issues over GNAT.Sockets — so it works identically over Ethernet, Wi-Fi, cellular, or whatever the routing table points at.

Why not use the modem's own resolver? Because one was tried and removed. The BG95's AT+QIDNSGIP silently refused answers whose shape it did not like — a CNAME chain onto several A records failed where a bare A record resolved. Doing DNS ourselves means one code path with predictable behaviour, rather than a per-modem set of quirks.

DNS_Client offers both Resolve (UDP) and Resolve_TCP, the latter for answers too large for a datagram. NTP_Client.Query is the same shape — a UDP query reading the transmit timestamp out of the reply — with To_UTC to convert it.

Portability is the point

Neither client contains anything chip-specific. On the board you call GNAT.Sockets.Initialize (Device) once during bring-up; on a desktop sockets are always usable. The same source then compiles in both places, which is what lets these be tested on a host rather than only on hardware.

The shared wrinkle

Both keep package-global rotors — the transaction id and the default source port for DNS, a source-port counter for NTP. Concurrent calls from several tasks corrupt nothing, but two in-flight queries can land on the same source port, and one then fails its reply check. That surfaces as a failed lookup, not an exception or corruption. If you resolve from more than one task, either serialise the calls or accept the retry.

The source-port rotation is not incidental — a fixed source port is what made an earlier cellular setup fail, because the carrier's NAT poisoned the flow. Rotating ports is a deliberate hardening measure, and the collision above is its small cost.

Encrypted DNS

Plain DNS is readable by anything on the path, and on a shared or hostile network the names a device looks up leak what it is doing. DNS_TLS adds the two encrypted transports over the pure-Ada TLS 1.3 stack:

TransportHow
DoT (RFC 7858) The ordinary DNS message, inside TLS, on port 853.
DoH (RFC 8484) A minimal HTTP/1.1 POST of application/dns-message over HTTPS, port 443 — so it survives networks that block 853.

The message bytes are the same proven ones every transport shares. Only the carriage differs, so the parser and builder under UDP, TCP, DoT and DoH are one implementation with one set of tests (step 52) — not four chances to get a message wrong.

Trust stays with the application, exactly as in the HTTPS examples: the caller establishes the TCP connection and supplies the trust anchors. The resolver does not carry a built-in root store or decide for you which resolver is trustworthy. Note also that a root-pinned DoT or DoH endpoint may need P-384 rather than P-256, depending on whose certificate chain you are anchoring to.