Preserve valid DNS leases and cover bounded diagnostics

This commit is contained in:
mikecarper committed 2026-10-06 13:11:15 -07:00
1 parent 0724e2efa3
commit 52c70994d8
4 files changed
+435 -3

No files matched your search

+8 -3
View File
@@ -37,6 +37,7 @@ namespace {
// Trailing empty slots are dropped to keep remote replies short; "-" marks an
// empty slot before a populated one, or a list with nothing in it.
static void formatDnsList(char* out, size_t out_size, const ip_addr_t* servers, int count) {
if (out == nullptr || out_size == 0) return;
int last = count - 1;
while (last >= 0 && ip_addr_isany_val(servers[last])) last--;
out[0] = '\0';
@@ -158,16 +159,20 @@ class NetworkLinkBase : public NetworkLink {
// that ever changes.)
static esp_err_t snapshotDnsOnTcpipThread(void* ctx) {
NetworkLinkBase* self = static_cast<NetworkLinkBase*>(ctx);
ip_addr_t servers[kDnsServers] = {};
bool any = false;
for (int i = 0; i < kDnsServers; i++) {
const ip_addr_t* server = dns_getserver(i);
self->_dns_snapshot[i] = (server != nullptr) ? *server : *IP_ADDR_ANY;
if (!ip_addr_isany_val(self->_dns_snapshot[i])) any = true;
servers[i] = (server != nullptr) ? *server : *IP_ADDR_ANY;
if (!ip_addr_isany_val(servers[i])) any = true;
}
// A lease that carried no resolver at all (static configuration, or a
// server that offered none) must not be remembered as "this medium's DNS is
// nothing" — restoring that would wipe a working resolver for no gain.
if (any) self->_dns_captured = true;
if (any) {
for (int i = 0; i < kDnsServers; i++) self->_dns_snapshot[i] = servers[i];
self->_dns_captured = true;
}
return ESP_OK;
}
+282
View File
@@ -0,0 +1,282 @@
// The marked DNS methods and shared base are copied verbatim from production.
// This fixture replaces only SDK dispatch, address conversion, and link I/O.
#include <helpers/NetworkLink.h>
#include <array>
#include <atomic>
#include <cassert>
#include <climits>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
static void require(bool condition, const char* message) {
if (!condition) throw std::runtime_error(message);
}
constexpr int DNS_MAX_SERVERS = 3;
struct ip_addr_t {
std::array<char, 48> text{};
unsigned type = 0;
bool operator==(const ip_addr_t& other) const {
return type == other.type && text == other.text;
}
};
static ip_addr_t address(const char* text, unsigned type = 4) {
ip_addr_t value;
if (*text) {
require(strlen(text) < value.text.size(), "fixture address too long");
strcpy(value.text.data(), text);
value.type = type;
}
return value;
}
static const ip_addr_t empty_address;
#define IP_ADDR_ANY (&empty_address)
static bool ip_addr_isany_val(const ip_addr_t& value) { return !value.text[0]; }
static char* ipaddr_ntoa_r(const ip_addr_t* value, char* out, int size) {
if (size <= 0 || strlen(value->text.data()) >= size_t(size)) return nullptr;
strcpy(out, value->text.data());
return out;
}
using esp_err_t = int;
constexpr esp_err_t ESP_OK = 0;
static bool on_tcpip_thread = false;
static unsigned tcpip_dispatches = 0;
static std::array<ip_addr_t, DNS_MAX_SERVERS> global_dns{};
static std::array<bool, DNS_MAX_SERVERS> null_dns{};
static std::vector<int> dns_reads, dns_writes;
static esp_err_t esp_netif_tcpip_exec(esp_err_t (*callback)(void*), void* context) {
require(!on_tcpip_thread, "nested TCP/IP dispatch");
++tcpip_dispatches;
on_tcpip_thread = true;
const esp_err_t result = callback(context);
on_tcpip_thread = false;
return result;
}
static const ip_addr_t* dns_getserver(int index) {
require(on_tcpip_thread, "DNS read escaped TCP/IP thread");
dns_reads.push_back(index);
return null_dns.at(index) ? nullptr : &global_dns.at(index);
}
static void dns_setserver(int index, const ip_addr_t* value) {
require(on_tcpip_thread, "DNS write escaped TCP/IP thread");
dns_writes.push_back(index);
global_dns.at(index) = *value;
}
struct LinkEndpoint {
IPAddress gateway;
IPAddress gatewayIP() const { return gateway; }
} WiFi, CH390;
@HELPERS@
@BASE@
// Unchanged interface operations are irrelevant to DNS and have no SDK effects.
struct TestLink : NetworkLinkBase {
const char* mediumName() const override { return "test"; }
NetworkMedium medium() const override { return NetworkMedium::None; }
const char* statusName() const override { return "test"; }
int statusCode() const override { return 0; }
bool configValid(const char*) const override { return true; }
void setHostname(const char*) override {}
bool begin(const char*, const char*) override { return false; }
NetworkTransition maintain(uint32_t, uint8_t) override { return NetworkTransition::None; }
bool isConnected() const override { return false; }
IPAddress localIP() const override { return IPAddress(); }
int rssi() const override { return INT_MIN; }
bool resolveHost(const char*, IPAddress&) const override { return false; }
void formatDiagnostics(char*, size_t) const override {}
};
struct WiFiNetworkLink : TestLink { @WiFiNetworkLink@ };
struct EthernetNetworkLink : TestLink { @EthernetNetworkLink@ };
struct AutomaticNetworkLink : TestLink {
std::atomic<NetworkMedium> _selected{NetworkMedium::None};
WiFiNetworkLink _wifi;
EthernetNetworkLink _ethernet;
@AutomaticNetworkLink@
};
static AutomaticNetworkLink automatic;
static unsigned link_lookups = 0;
NetworkLink& activeNetworkLink() { ++link_lookups; return automatic; }
static bool get(const char* config, char* reply) {
@GETTER@ else { return false; }
return true;
}
static void restore_scenario() {
WiFiNetworkLink one, all;
const std::array<ip_addr_t, 3> complete{{
address("1.1.1.1"), address("8.8.8.8"), address("2001:db8::53", 6)}};
const std::array<ip_addr_t, 3> single{{address("9.9.9.9"), {}, {}}};
global_dns = complete;
all.snapshotDns();
global_dns = single;
one.snapshotDns();
dns_writes.clear();
all.restoreDns();
require(global_dns == complete, "DNS restore lost a populated third slot or typed address");
require(dns_writes == std::vector<int>({0, 1, 2}), "DNS restore did not write every slot");
dns_writes.clear();
one.restoreDns();
require(global_dns == single, "DNS restore retained another medium's empty-slot fallbacks");
require(dns_writes == std::vector<int>({0, 1, 2}), "DNS restore omitted empty slots");
require(tcpip_dispatches == 4, "DNS snapshots/restores bypassed thread dispatch");
}
static void never_leased_scenario() {
WiFiNetworkLink never;
const std::array<ip_addr_t, 3> complete{{address("1.1.1.1"), address("8.8.8.8"), address("9.9.9.9")}};
global_dns = complete;
never.restoreDns();
require(global_dns == complete && dns_writes.empty(), "never-leased medium wiped live DNS");
char lease[64] = {};
never.formatLeaseDns(lease, sizeof(lease));
require(std::string(lease) == "none", "never-leased DNS diagnostic is misleading");
global_dns = {};
never.snapshotDns();
global_dns = complete;
never.restoreDns();
require(global_dns == complete && dns_writes.empty(), "resolver-free lease wiped live DNS");
never.formatLeaseDns(lease, sizeof(lease));
require(std::string(lease) == "none", "resolver-free lease invented captured DNS");
}
static void renewal_scenario() {
WiFiNetworkLink link;
const std::array<ip_addr_t, 3> third_only{{{}, {}, address("9.9.9.9")}};
const std::array<ip_addr_t, 3> complete{{address("1.1.1.1"), address("8.8.8.8"), address("9.9.9.9")}};
const std::array<ip_addr_t, 3> partial{{{}, address("10.10.10.10"), {}}};
global_dns = third_only;
link.snapshotDns();
char lease[64] = {};
link.formatLeaseDns(lease, sizeof(lease));
require(std::string(lease) == "-,-,9.9.9.9", "third-slot-only lease was not captured");
global_dns = complete;
link.restoreDns();
require(global_dns == third_only, "third-slot-only DNS restore lost its resolver");
global_dns = complete;
link.snapshotDns();
global_dns = partial;
link.snapshotDns();
global_dns = complete;
link.restoreDns();
require(global_dns == partial, "valid DNS renewal retained obsolete resolver slots");
global_dns = {};
link.snapshotDns();
global_dns = complete;
link.restoreDns();
require(global_dns == partial, "DNS-free renewal wiped the last valid lease");
link.formatLeaseDns(lease, sizeof(lease));
require(std::string(lease) == "-,10.10.10.10", "DNS-free renewal diagnostic lost the last valid lease");
}
static void prepare_automatic() {
WiFi.gateway = IPAddress("10.0.0.1");
CH390.gateway = IPAddress("192.168.1.1");
global_dns = {{address("1.1.1.1"), {}, {}}};
automatic._wifi.snapshotDns();
global_dns = {{address("8.8.8.8"), {}, address("9.9.9.9")}};
automatic._ethernet.snapshotDns();
global_dns = {{address("4.4.4.4"), {}, {}}};
automatic._selected = NetworkMedium::Ethernet;
}
static void format_scenario() {
char list[64] = {};
std::array<ip_addr_t, 3> servers{};
formatDnsList(list, sizeof(list), servers.data(), 3);
require(std::string(list) == "-", "empty resolver list differs from documented sentinel");
servers = {{address("1.1.1.1"), {}, {}}};
formatDnsList(list, sizeof(list), servers.data(), 3);
require(std::string(list) == "1.1.1.1", "trailing empty resolver slots were retained");
servers[2] = address("9.9.9.9");
formatDnsList(list, sizeof(list), servers.data(), 3);
require(std::string(list) == "1.1.1.1,-,9.9.9.9", "interior empty resolver slot was dropped");
global_dns = servers;
null_dns[0] = true;
formatCurrentDnsServers(list, sizeof(list));
require(std::string(list) == "-,-,9.9.9.9", "null lwIP resolver was not treated as empty");
null_dns = {};
prepare_automatic();
const size_t writes_before = dns_writes.size();
char reply[256] = {};
const NetworkLink& wifi = automatic._wifi;
wifi.formatDns(reply, sizeof(reply));
require(std::string(reply) == "> dns:4.4.4.4 gw:10.0.0.1 lease:1.1.1.1", "WiFi DNS interface lost its own lease/gateway");
const NetworkLink& ethernet = automatic._ethernet;
ethernet.formatDns(reply, sizeof(reply));
require(std::string(reply) == "> dns:4.4.4.4 gw:192.168.1.1 lease:8.8.8.8,-,9.9.9.9", "Ethernet DNS interface lost its own lease/gateway");
const NetworkLink& selected = automatic;
selected.formatDns(reply, sizeof(reply));
require(std::string(reply) == "> dns:4.4.4.4 selected:ethernet gw:192.168.1.1\neth-lease:8.8.8.8,-,9.9.9.9 wifi-lease:1.1.1.1", "automatic DNS omitted global or medium lease data");
automatic._selected = NetworkMedium::WiFi;
selected.formatDns(reply, sizeof(reply));
require(strstr(reply, "selected:wifi gw:10.0.0.1"), "automatic WiFi selection used the Ethernet gateway");
automatic._selected = NetworkMedium::None;
selected.formatDns(reply, sizeof(reply));
require(strstr(reply, "selected:none gw:0.0.0.0"), "unselected diagnostic invented a gateway");
// All three lists can exceed the real CLI's 160-byte buffer. Truncation is
// allowed; writing beyond the declared capacity or losing NUL is not.
global_dns = {{address("255.255.255.255"), address("255.255.255.255"), address("255.255.255.255")}};
automatic._wifi.snapshotDns();
automatic._ethernet.snapshotDns();
automatic._selected = NetworkMedium::Ethernet;
CH390.gateway = IPAddress("255.255.255.255");
selected.formatDns(reply, sizeof(reply));
require(strlen(reply) == 207, "long DNS diagnostic fixture no longer exercises truncation");
const std::string full(reply);
for (size_t size : {size_t(0), size_t(1), size_t(2), size_t(10), size_t(64), size_t(160)}) {
std::array<unsigned char, 192> bounded;
bounded.fill(0xA5);
char* out = reinterpret_cast<char*>(bounded.data() + 8);
selected.formatDns(out, size);
for (size_t i = 0; i < 8; ++i) require(bounded[i] == 0xA5, "DNS formatter wrote before destination");
for (size_t i = 8 + size; i < bounded.size(); ++i) require(bounded[i] == 0xA5, "DNS formatter overran declared capacity");
if (size) require(std::string(out) == full.substr(0, size - 1), "bounded DNS reply lost NUL or changed prefix");
}
require(dns_writes.size() == writes_before, "read-only DNS diagnostics changed resolver state");
}
static void zero_scenario() {
char canary = 'Z';
formatDnsList(&canary, 0, global_dns.data(), 3);
require(canary == 'Z', "zero-size DNS formatter wrote to its destination");
formatDnsList(nullptr, 0, global_dns.data(), 3);
formatDnsList(nullptr, 7, global_dns.data(), 3);
}
static void getter_scenario() {
prepare_automatic();
struct Reply { unsigned char before[8]; char text[160]; unsigned char after[8]; } reply;
memset(&reply, 0xA5, sizeof(reply));
require(get("link.dns", reply.text), "observer DNS getter was not recognized");
require(link_lookups == 1 && strstr(reply.text, "> dns:4.4.4.4 selected:ethernet"), "observer DNS getter did not use the live NetworkLink interface");
for (unsigned char c : reply.before) require(c == 0xA5, "observer DNS getter underflowed reply");
for (unsigned char c : reply.after) require(c == 0xA5, "observer DNS getter overflowed reply");
const std::string original(reply.text);
for (const char* rejected : {"link.dnsX", "LINK.DNS", "link.dns ", "wifi.status"}) {
require(!get(rejected, reply.text), "observer DNS getter accepted another command");
require(std::string(reply.text) == original && link_lookups == 1, "rejected DNS command changed reply or queried the link");
}
}
int main(int argc, char** argv) {
try {
require(argc == 2, "missing DNS scenario");
const std::string scenario(argv[1]);
if (scenario == "restore") restore_scenario();
else if (scenario == "never-leased") never_leased_scenario();
else if (scenario == "renewal") renewal_scenario();
else if (scenario == "format") format_scenario();
else if (scenario == "zero") zero_scenario();
else if (scenario == "getter") getter_scenario();
else throw std::runtime_error("unknown DNS scenario");
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+1
View File
@@ -63,6 +63,7 @@ COMMON_QUERY_EXCEPTIONS = {
'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate',
}
OBSERVER_QUERY_EXCEPTIONS = {
'link.dns': 'Live resolver list, gateway, and retained per-medium lease diagnostics',
'mqtt.config.valid': 'Configuration validation result',
'mqtt.enabled': 'Stored in common NodePrefs; set mqtt.enabled is handled by CommonCLI',
'mqtt.ntp.diag': 'NTP diagnostics',
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Execute production DNS snapshots, formatters, and CLI wiring without a network.
Only lwIP dispatch/address conversion and physical-link endpoints are mocked.
The real NetworkLink interface, complete shared base, and each DNS formatter
are compiled together, including the observer getter copied from production.
"""
from pathlib import Path
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "test/fixtures/network_dns.cpp"
IP_ADDRESS = r'''
#pragma once
#include <string>
class IPAddress {
std::string text;
public:
IPAddress() : text("0.0.0.0") {}
explicit IPAddress(const char* value) : text(value) {}
std::string toString() const { return text; }
};
'''
def production_harness():
network = (ROOT / "src/helpers/NetworkLink.cpp").read_text(encoding="utf-8")
helpers = network[network.index("namespace {") + len("namespace {"):
network.index("class NetworkLinkBase")]
base = extract_braced(network, "class NetworkLinkBase") + ";"
code = FIXTURE.read_text(encoding="ascii").replace("@HELPERS@", helpers)
code = code.replace("@BASE@", base)
for name in ("WiFiNetworkLink", "EthernetNetworkLink", "AutomaticNetworkLink"):
implementation = extract_braced(network, "class " + name)
methods = extract_braced(implementation, "void formatDns(")
if name != "AutomaticNetworkLink":
methods += "\n" + extract_braced(implementation, "IPAddress gatewayIP()")
code = code.replace("@" + name + "@", methods)
cli = (ROOT / "src/helpers/CommonCLI_Observer.cpp").read_text(encoding="utf-8")
getter = extract_braced(cli, 'if (strcmp(config, "link.dns") == 0)')
return code.replace("@GETTER@", getter)
class NetworkDnsTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
if cls.compiler is None:
raise unittest.SkipTest("a host C++17 compiler is required")
cls.temporary = tempfile.TemporaryDirectory(prefix="meshcore-network-dns-")
cls.addClassCleanup(cls.temporary.cleanup)
cls.directory = Path(cls.temporary.name)
(cls.directory / "Arduino.h").write_text("#pragma once\n", encoding="ascii")
(cls.directory / "IPAddress.h").write_text(IP_ADDRESS, encoding="ascii")
cls.source = production_harness()
cls.binary = cls.compile(cls.source, "production")
@classmethod
def compile(cls, source, name):
cpp = cls.directory / (name + ".cpp")
binary = cls.directory / name
cpp.write_text(source, encoding="utf-8")
sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
result = subprocess.run([
cls.compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitizers,
"-DESP_PLATFORM=1", "-I", str(cls.directory), "-I", str(ROOT / "src"),
str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
return binary
def run_scenario(self, scenario, binary=None):
result = subprocess.run([str(binary or self.binary), scenario],
capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_three_slot_snapshot_restore_and_empty_slot_clearing(self):
self.run_scenario("restore")
def test_never_leased_medium_preserves_current_global_resolvers(self):
self.run_scenario("never-leased")
def test_renewals_keep_last_valid_lease_and_replace_obsolete_slots(self):
self.run_scenario("renewal")
def test_dns_lists_and_all_network_diagnostics_are_bounded(self):
self.run_scenario("format")
def test_zero_capacity_and_null_destination_do_not_write(self):
self.run_scenario("zero")
def test_observer_getter_uses_real_interface_and_exact_command(self):
self.run_scenario("getter")
cli = (ROOT / "src/helpers/CommonCLI_Observer.cpp").read_text(encoding="utf-8")
self.assertIn('#ifdef ESP_PLATFORM\n#include "NetworkLink.h"', cli)
self.assertIn('memcmp(config, "wifi.status", 11)', cli)
def test_webconfig_mock_exposes_the_read_only_dns_getter(self):
sys.path.insert(0, str(ROOT / "scripts"))
try:
import webconfig_mock_server as portal
finally:
sys.path.pop(0)
self.assertEqual(portal.cli_get({}, "link.dns"),
(True, "> dns:192.168.1.1 gw:192.168.1.1 lease:192.168.1.1"))
page = (ROOT / "webui/index.html").read_text(encoding="utf-8")
self.assertRegex(page, r'\["link\.dns","[^"\n]+",1\]')
def test_old_snapshot_and_restore_callbacks_lose_resolvers(self):
snapshot = extract_braced(self.source, "static esp_err_t snapshotDnsOnTcpipThread(")
# Reproduce the old write-before-validation callback. The retained
# self-copy on a valid lease has no effect; an empty renewal overwrites
# the snapshot before the captured flag can reject it.
old_snapshot = snapshot.replace("ip_addr_t servers[kDnsServers] = {};", "")
old_snapshot = old_snapshot.replace("servers[i]", "self->_dns_snapshot[i]")
controls = (
(self.source.replace("kDnsServers = DNS_MAX_SERVERS;", "kDnsServers = 2;"), "restore"),
(self.source.replace("dns_setserver(i, &self->_dns_snapshot[i]);",
"if (!ip_addr_isany_val(self->_dns_snapshot[i])) "
"dns_setserver(i, &self->_dns_snapshot[i]);"), "restore"),
(self.source.replace(snapshot, old_snapshot), "renewal"),
)
for index, (broken, scenario) in enumerate(controls):
with self.subTest(control=index):
self.assertNotEqual(broken, self.source)
binary = self.compile(broken, "broken-" + str(index))
result = subprocess.run([str(binary), scenario], capture_output=True,
text=True, timeout=10)
self.assertNotEqual(result.returncode, 0)
self.assertIn("DNS-free renewal" if scenario == "renewal" else "DNS restore",
result.stderr)
if __name__ == "__main__":
unittest.main()