Skip to content

Stop IPv6 DNS servers from evicting the IPv4 ones - #1248

Open
gskjold wants to merge 1 commit into
mainfrom
fix/ipv6-dns-overwrites-ipv4
Open

Stop IPv6 DNS servers from evicting the IPv4 ones#1248
gskjold wants to merge 1 commit into
mainfrom
fix/ipv6-dns-overwrites-ipv4

Conversation

@gskjold

@gskjold gskjold commented Aug 28, 2026

Copy link
Copy Markdown
Member

Fixes #1141

Root cause

lwIP keeps a single global DNS server table, and IPv6 servers are written starting at index 0, evicting the IPv4 servers DHCP handed out.

lwip/src/core/ipv6/nd6.c:

600:  #if LWIP_ND6_RDNSS_MAX_DNS_SERVERS
601:    /* There can be multiple RDNSS options per RA */
602:    u8_t rdnss_server_idx = 0;
...
866:  for (n = 0; (rdnss_server_idx < DNS_MAX_SERVERS) && (n < num); n++, ...) {
879:      dns_setserver(rdnss_server_idx++, &rdnss_address);

rdnss_server_idx is a local, reset on every router advertisement, so this repeats for the lifetime of the connection rather than happening once at boot. The loop bounds on DNS_MAX_SERVERS (3 in our builds) rather than LWIP_ND6_RDNSS_MAX_DNS_SERVERS (2), so a single advertisement can take out the whole table.

It also happens whether or not IPv6 is enabled in our configuration. Advertisements go to ff02::1, and ip6.c:616 accepts that unconditionally:

616:  if (ip6_addr_ismulticast(ip6_current_dest_addr())) {
617:    /* Always joined to multicast if-local and link-local all-nodes group. */
618:    if (ip6_addr_isallnodes_iflocal(...) || ip6_addr_isallnodes_linklocal(...)) {
619:      netif = inp;

Our IPv6 setting only controls whether we request a global address, it does not stop nd6 from processing advertisements. The prebuilt libs we link against enable this:

CONFIG_LWIP_IPV6_RDNSS_MAX_DNS_SERVERS=2
CONFIG_LWIP_DNS_MAX_SERVERS=3
# CONFIG_LWIP_DNS_SETSERVER_WITH_NETIF is not set   <- no per netif isolation, no hook

Why the old watchdog could not work

  • It turned itself off when IPv6 was enabled (dnsState = 2), which is exactly the case in IPv6 and price API #1141.
  • It only guarded slot 0, while an advertisement overwrites up to three.
  • It took its reference from whatever happened to be in slot 0 at GOT_IP. An advertisement arriving before the DHCP ack made the IPv6 address the reference, and it then restored that every 60 seconds for the rest of the connection.
  • It probed with a blocking WiFi.hostByName() from the network event handler, which stalls the event task, and one transient failure disabled it until the next reconnect.
  • 60 second repair latency, and memcmp on ip_addr_t compares union padding.

The fix

src/DnsGuard.{h,cpp} reserves the first slots of the table for IPv4 and the last one for IPv6, so a dual stack network resolves over both instead of us having to pick one.

Interception. -Wl,--wrap=dns_setserver catches every write, with no polling latency and no race against the DHCP ack. Verified against the linked image:

400c9b24 <ETHClass::config>            -> __wrap_dns_setserver
400e4164 <esp_netif_set_dns_info_api>  -> __wrap_dns_setserver   (WiFi.config, static IP)
400f43f8 <dhcp_handle_ack>             -> __wrap_dns_setserver
400f9400 <nd6_input>                   -> __wrap_dns_setserver   (x2, the RDNSS writes)

The only remaining direct calls to the real symbol are dns_init's own intra-TU ones and our wrapper. An IPv6 server is only moved or dropped when there is an IPv4 one to protect, so IPv6 only and closed networks are left completely alone. When IPv6 is off in our config the entry is dropped rather than parked, since without a global address it is unreachable anyway.

Reconciler. dnsGuardEnforce() runs from the main loop and on GOT_IP/GOT_IP6 as a safety net, in case the wrap ever stops matching a future framework. It only ever takes its reference from an IPv4 entry in an untainted slot, so a poisoned table can never become the source of truth. It counts repairs and logs them, so a repair count above zero in the debug log is the signal that the wrap has stopped working.

-D AMS_WRAP_DNS_SETSERVER guards the wrapper so an env without the linker flag still builds and gets the reconciler alone.

Verification

  • All 7 targets build: esp8266, esp32, esp32s2, esp32s2psram, esp32solo, esp32c3, esp32s3. ESP8266 is untouched, everything is behind #if defined(ESP32).
  • pio test -e native — 17/17 pass.
  • Call site redirection confirmed by disassembling firmware.elf as above.

Field verification still wanted: a device on a router that sends RDNSS should now keep its IPv4 DNS on the status page with IPv6 both on and off, and the price service should stay connected.

Follow ups, not in this PR

  • CONFIG_LWIP_DNS_SETSERVER_WITH_NETIF=y plus CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF=y would fix this at the source if the custom_sdkconfig work lands, and the wrap could then go away.
  • The DNS_MAX_SERVERS vs LWIP_ND6_RDNSS_MAX_DNS_SERVERS bound in nd6.c:866 is worth reporting upstream.
  • Worth a look at the price service retry backoff, so a lookup failing during a poisoned window does not keep it down longer than necessary.

🤖 Generated with Claude Code

lwIP keeps a single global DNS server table and nd6 writes the servers
from a router advertisement RDNSS option starting at index 0, so the
IPv4 servers handed out by DHCP are lost. rdnss_server_idx is a local in
nd6_input, so this is repeated for every advertisement, and lwIP is
always joined to the link-local all-nodes group, so it happens whether
or not IPv6 is enabled in our configuration.

The previous watchdog could not deal with this. It only guarded slot 0
while an advertisement overwrites up to DNS_MAX_SERVERS of them, it
turned itself off when IPv6 was enabled, which is the case reported in
1141, and it took its reference from whatever was in slot 0 at GOT_IP,
so an advertisement arriving before the DHCP ack made it restore the
IPv6 address for the rest of the connection. It also probed with a
blocking hostByName from the network event handler, and a single
transient failure disabled it until the next reconnect.

DnsGuard reserves the first slots of the table for IPv4 and the last one
for IPv6, so a dual stack network can resolve over both. Writes are
intercepted with -Wl,--wrap=dns_setserver, which catches nd6_input,
dhcp_handle_ack, esp_netif_set_dns_info_api and ETHClass::config, and an
IPv6 server is only ever moved or dropped when there is an IPv4 one to
protect, leaving IPv6 only and closed networks alone. dnsGuardEnforce()
reconciles the table from the main loop as a safety net in case the wrap
ever stops matching, and reports through the debug log when it has to.

Fixes #1141

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🔧 PR Build Artifacts

Version: 2111222

All environments built successfully. Download the zip files:

Artifacts expire after 7 days. View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

IPv6 and price API

1 participant