Skip to content

Commit 22bb542

Browse files
gskjoldclaude
andauthored
Stop IPv6 DNS servers from evicting the IPv4 ones (#1248)
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>
1 parent e61d704 commit 22bb542

4 files changed

Lines changed: 204 additions & 41 deletions

File tree

platformio.ini

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ build_flags =
2121
-I src/cloud
2222

2323
[esp32]
24+
# Lets DnsGuard intercept lwIP writes to the DNS server table
25+
build_flags =
26+
-D AMS_WRAP_DNS_SETSERVER
27+
-Wl,--wrap=dns_setserver
2428
lib_deps = WiFi, Ethernet, ESPmDNS, WiFiClientSecure, HTTPClient, FS, WebServer, ESP32 Async UDP, ESP32SSDP, mulmer89/ESPRandom@1.5.0, ${common.lib_deps}
2529

2630
[env:esp8266]
@@ -42,6 +46,7 @@ board_build.f_cpu = 160000000L
4246
board_build.partitions = custom_partition.csv
4347
build_flags =
4448
${common.build_flags}
49+
${esp32.build_flags}
4550
-D AMS_REMOTE_DEBUG=1
4651
-D AMS_CLOUD=1
4752
-D AMS_KMP=1
@@ -67,6 +72,7 @@ board_build.f_flash = 40000000L
6772
board_build.partitions = custom_partition.csv
6873
build_flags =
6974
${common.build_flags}
75+
${esp32.build_flags}
7076
-D AMS_REMOTE_DEBUG=1
7177
-D AMS_CLOUD=1
7278
-D AMS_KMP=1
@@ -89,6 +95,7 @@ board = lolin_s2_mini
8995
board_build.partitions = custom_partition.csv
9096
build_flags =
9197
${common.build_flags}
98+
${esp32.build_flags}
9299
-D AMS_REMOTE_DEBUG=1
93100
-D AMS_CLOUD=1
94101
-D AMS_KMP=1
@@ -107,6 +114,7 @@ board_build.f_cpu = 160000000L
107114
board_build.partitions = custom_partition.csv
108115
build_flags =
109116
${common.build_flags}
117+
${esp32.build_flags}
110118
-DFRAMEWORK_ARDUINO_SOLO1
111119
-D AMS_REMOTE_DEBUG=1
112120
-D AMS_CLOUD=1
@@ -126,6 +134,7 @@ board_build.mcu = esp32c3
126134
board_build.partitions = custom_partition.csv
127135
build_flags =
128136
${common.build_flags}
137+
${esp32.build_flags}
129138
-D AMS_REMOTE_DEBUG=1
130139
-D AMS_CLOUD=1
131140
-D AMS_KMP=1
@@ -143,6 +152,7 @@ board = esp32-s3-devkitc-1
143152
board_build.mcu = esp32s3
144153
build_flags =
145154
${common.build_flags}
155+
${esp32.build_flags}
146156
-D AMS_REMOTE_DEBUG=1
147157
-D AMS_CLOUD=1
148158
-D AMS_KMP=1

src/AmsToMqttBridge.cpp

Lines changed: 19 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ ADC_MODE(ADC_VCC);
2222
#include <ESPmDNS.h>
2323
#include <ESP32SSDP.h>
2424
#include <esp_task_wdt.h>
25-
#include <lwip/dns.h>
25+
#include "DnsGuard.h"
2626
#if defined(BOARD_HAS_PSRAM)
2727
#include <esp_heap_caps.h>
2828
#include <mbedtls/platform.h>
@@ -333,41 +333,15 @@ bool checkVoltageIfNeeded(float range) {
333333
}
334334

335335
#if defined(ESP32)
336-
uint8_t dnsState = 0;
337-
ip_addr_t dns0;
338336
void WiFiEvent(WiFiEvent_t event, WiFiEventInfo_t info) {
339337
if(setupMode) return; // None of this necessary in setup mode
340338
if(ch != NULL) ch->eventHandler(event, info);
341339
switch(event) {
342-
case ARDUINO_EVENT_ETH_CONNECTED:
343-
case ARDUINO_EVENT_WIFI_STA_CONNECTED: {
344-
dnsState = 0;
345-
if(ch != NULL) {
346-
NetworkConfig conf;
347-
ch->getCurrentConfig(conf);
348-
if(conf.ipv6) {
349-
dnsState = 2; // Never reset if IPv6 is enabled
350-
debugI_P(PSTR("IPv6 enabled, not monitoring DNS poisoning"));
351-
}
352-
}
353-
break;
354-
}
355340
case ARDUINO_EVENT_ETH_GOT_IP:
356-
case ARDUINO_EVENT_WIFI_STA_GOT_IP: {
357-
if(dnsState == 0) {
358-
const ip_addr_t* dns = dns_getserver(0);
359-
memcpy(&dns0, dns, sizeof(dns0));
360-
361-
IPAddress res;
362-
int ret = WiFi.hostByName("hub.amsleser.no", res);
363-
if(ret == 0) {
364-
dnsState = 2;
365-
debugI_P(PSTR("No DNS, probably a closed network"));
366-
} else if(dnsState == 0) {
367-
debugI_P(PSTR("DNS is present and working, monitoring DNS poisoning"));
368-
dnsState = 1;
369-
}
370-
}
341+
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
342+
case ARDUINO_EVENT_ETH_GOT_IP6:
343+
case ARDUINO_EVENT_WIFI_STA_GOT_IP6: {
344+
dnsGuardEnforce(); // This is when the DNS table is written
371345
break;
372346
}
373347
case ARDUINO_EVENT_ETH_DISCONNECTED:
@@ -737,6 +711,9 @@ bool longPressActive = false;
737711

738712
unsigned long lastTemperatureRead = 0;
739713
unsigned long lastSysupdate = 0;
714+
#if defined(ESP32)
715+
uint32_t lastDnsRepairs = 0;
716+
#endif
740717
uint64_t lastErrorBlink = 0;
741718
unsigned long lastVoltageCheck = 0;
742719
int lastError = 0;
@@ -787,6 +764,14 @@ void loop() {
787764
postConnect();
788765
}
789766

767+
#if defined(ESP32)
768+
dnsGuardEnforce();
769+
if(dnsGuardRepairs() != lastDnsRepairs) {
770+
lastDnsRepairs = dnsGuardRepairs();
771+
debugI_P(PSTR("Had to restore the IPv4 DNS servers (%d)"), lastDnsRepairs);
772+
}
773+
#endif
774+
790775
// Only do these tasks if we have super-smooth voltage
791776
if(checkVoltageIfNeeded(0.1)) {
792777
handleNtp();
@@ -1382,16 +1367,6 @@ void handleSystem(unsigned long now) {
13821367
if(end - start > SLOW_PROC_TRIGGER_MS) {
13831368
debugW_P(PSTR("Used %dms to send system update to MQTT"), end-start);
13841369
}
1385-
1386-
#if defined(ESP32)
1387-
if(dnsState == 1) {
1388-
const ip_addr_t* dns = dns_getserver(0);
1389-
if(memcmp(&dns0, dns, sizeof(dns0)) != 0) {
1390-
dns_setserver(0, &dns0);
1391-
debugI_P(PSTR("Had to reset DNS server"));
1392-
}
1393-
}
1394-
#endif
13951370
}
13961371
}
13971372

@@ -1587,6 +1562,9 @@ void connectToNetwork() {
15871562
setupMode = false;
15881563
toggleSetupMode();
15891564
}
1565+
#if defined(ESP32)
1566+
dnsGuardSetIpv6Allowed(network.ipv6);
1567+
#endif
15901568
ch->connect(network, sysConfig);
15911569
ws.setConnectionHandler(ch);
15921570
#if defined(_CLOUDCONNECTOR_H)

src/DnsGuard.cpp

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* @copyright Utilitech AS 2023-2026
3+
* License: Fair Source
4+
*
5+
* @brief Keeps the IPv4 DNS servers from being evicted by IPv6 ones
6+
*
7+
* @details lwIP keeps a single global DNS server table, and both nd6 RDNSS and
8+
* stateless DHCPv6 start writing IPv6 servers at index 0, evicting the IPv4
9+
* servers handed out by DHCP. RDNSS is re-applied on every router
10+
* advertisement, and those are processed even when IPv6 is disabled in our
11+
* configuration, since lwIP is always joined to the link-local all-nodes group.
12+
*
13+
* We therefore reserve the first slots of the table for IPv4 and the last one
14+
* for IPv6, which lets both stacks resolve names on a dual stack network.
15+
*
16+
* The table is touched from the lwIP thread, the network event handler and the
17+
* main loop. All the state here is word sized and every disagreement is fixed
18+
* by the next reconcile, so no locking is needed. Taking one on the lwIP thread
19+
* would not be safe anyway.
20+
*/
21+
22+
#include "DnsGuard.h"
23+
24+
#if defined(ESP32)
25+
26+
#include <lwip/dns.h>
27+
28+
#if DNS_MAX_SERVERS < 3
29+
#error "DnsGuard needs room for both an IPv4 and an IPv6 DNS server"
30+
#endif
31+
32+
// Slots reserved for IPv4, the remaining one is where IPv6 is parked
33+
#define DNS_V4_SLOTS (DNS_MAX_SERVERS - 1)
34+
35+
extern "C" void __real_dns_setserver(u8_t numdns, const ip_addr_t *dnsserver);
36+
37+
static ip4_addr_t knownDns[DNS_V4_SLOTS];
38+
static bool ipv6Allowed = false;
39+
static uint32_t repairs = 0;
40+
41+
static bool isUsableV4(const ip_addr_t* server) {
42+
return server != NULL && IP_IS_V4(server) && !ip_addr_isany(server);
43+
}
44+
45+
static bool hasV4Server() {
46+
for(uint8_t i = 0; i < DNS_MAX_SERVERS; i++) {
47+
if(isUsableV4(dns_getserver(i))) return true;
48+
}
49+
return false;
50+
}
51+
52+
#if defined(AMS_WRAP_DNS_SETSERVER)
53+
/**
54+
* Intercepts every write to the DNS table. Runs on the lwIP thread, so it must
55+
* not block or log.
56+
*/
57+
extern "C" void __wrap_dns_setserver(u8_t numdns, const ip_addr_t *dnsserver) {
58+
// Only displace an IPv6 server when there is an IPv4 one worth protecting
59+
if(dnsserver != NULL && IP_IS_V6(dnsserver) && hasV4Server()) {
60+
if(ipv6Allowed) {
61+
__real_dns_setserver(DNS_MAX_SERVERS - 1, dnsserver);
62+
}
63+
return;
64+
}
65+
__real_dns_setserver(numdns, dnsserver);
66+
}
67+
#endif
68+
69+
/**
70+
* Remember the IPv4 servers, but only while the IPv4 slots are untainted, so a
71+
* table that has already been overwritten can never become the source of truth.
72+
* The IPv6 slot is deliberately not read here.
73+
*/
74+
static void remember() {
75+
for(uint8_t i = 0; i < DNS_V4_SLOTS; i++) {
76+
if(IP_IS_V6(dns_getserver(i))) return;
77+
}
78+
79+
ip4_addr_t found[DNS_V4_SLOTS];
80+
uint8_t count = 0;
81+
for(uint8_t i = 0; i < DNS_V4_SLOTS; i++) {
82+
const ip_addr_t* server = dns_getserver(i);
83+
if(isUsableV4(server)) {
84+
found[count++] = *ip_2_ip4(server);
85+
}
86+
}
87+
if(count == 0) return;
88+
89+
for(uint8_t i = 0; i < DNS_V4_SLOTS; i++) {
90+
if(i < count) {
91+
knownDns[i] = found[i];
92+
} else {
93+
ip4_addr_set_zero(&knownDns[i]);
94+
}
95+
}
96+
}
97+
98+
void dnsGuardSetIpv6Allowed(bool allowed) {
99+
ipv6Allowed = allowed;
100+
}
101+
102+
void dnsGuardEnforce() {
103+
remember();
104+
105+
ip_addr_t desired[DNS_MAX_SERVERS];
106+
uint8_t count = 0;
107+
for(uint8_t i = 0; i < DNS_V4_SLOTS; i++) {
108+
if(!ip4_addr_isany_val(knownDns[i])) {
109+
ip_addr_copy_from_ip4(desired[count], knownDns[i]);
110+
count++;
111+
}
112+
}
113+
if(count == 0) return; // No IPv4 DNS known, leave an IPv6 only or closed network alone
114+
115+
if(ipv6Allowed) {
116+
for(uint8_t i = 0; i < DNS_MAX_SERVERS; i++) {
117+
const ip_addr_t* server = dns_getserver(i);
118+
if(server != NULL && IP_IS_V6(server) && !ip_addr_isany(server)) {
119+
ip_addr_copy(desired[count], *server);
120+
count++;
121+
break;
122+
}
123+
}
124+
}
125+
while(count < DNS_MAX_SERVERS) {
126+
ip_addr_set_zero(&desired[count]);
127+
count++;
128+
}
129+
130+
bool repaired = false;
131+
for(uint8_t i = 0; i < DNS_MAX_SERVERS; i++) {
132+
if(!ip_addr_cmp(dns_getserver(i), &desired[i])) {
133+
dns_setserver(i, &desired[i]);
134+
repaired = true;
135+
}
136+
}
137+
if(repaired) repairs++;
138+
}
139+
140+
uint32_t dnsGuardRepairs() {
141+
return repairs;
142+
}
143+
144+
#endif

src/DnsGuard.h

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @copyright Utilitech AS 2023-2026
3+
* License: Fair Source
4+
*
5+
* @brief Keeps the IPv4 DNS servers from being evicted by IPv6 ones
6+
*/
7+
8+
#ifndef _DNSGUARD_H
9+
#define _DNSGUARD_H
10+
11+
#if defined(ESP32)
12+
13+
#include <stdint.h>
14+
15+
/**
16+
* Whether an IPv6 DNS server is allowed to stay in the table at all. When IPv6
17+
* is disabled we have no global IPv6 address, so such a server is unreachable.
18+
*/
19+
void dnsGuardSetIpv6Allowed(bool allowed);
20+
21+
/**
22+
* Reconcile the lwIP DNS table with the last known good IPv4 servers. Cheap
23+
* enough to call from the main loop, safe to call from an event handler.
24+
*/
25+
void dnsGuardEnforce();
26+
27+
/** Number of times the table had to be repaired since boot */
28+
uint32_t dnsGuardRepairs();
29+
30+
#endif
31+
#endif

0 commit comments

Comments
 (0)