Stop IPv6 DNS servers from evicting the IPv4 ones - #1248
Open
gskjold wants to merge 1 commit into
Open
Conversation
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>
🔧 PR Build ArtifactsVersion: All environments built successfully. Download the zip files:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:rdnss_server_idxis 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 onDNS_MAX_SERVERS(3 in our builds) rather thanLWIP_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, andip6.c:616accepts that unconditionally: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:
Why the old watchdog could not work
dnsState = 2), which is exactly the case in IPv6 and price API #1141.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.WiFi.hostByName()from the network event handler, which stalls the event task, and one transient failure disabled it until the next reconnect.memcmponip_addr_tcompares 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_setservercatches every write, with no polling latency and no race against the DHCP ack. Verified against the linked image: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 onGOT_IP/GOT_IP6as 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_SETSERVERguards the wrapper so an env without the linker flag still builds and gets the reconciler alone.Verification
esp8266,esp32,esp32s2,esp32s2psram,esp32solo,esp32c3,esp32s3. ESP8266 is untouched, everything is behind#if defined(ESP32).pio test -e native— 17/17 pass.firmware.elfas 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=yplusCONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF=ywould fix this at the source if thecustom_sdkconfigwork lands, and the wrap could then go away.DNS_MAX_SERVERSvsLWIP_ND6_RDNSS_MAX_DNS_SERVERSbound innd6.c:866is worth reporting upstream.🤖 Generated with Claude Code