From 274997e9ae84bd38ad3e1a1f207bd0df046fa0dc Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 4 Oct 2026 19:55:54 -0700 Subject: [PATCH] feat(cli): add get link.dns for resolver, gateway and lease DNS Shows lwIP's live global DNS server list next to the DNS each medium's DHCP lease installed, so a node that is linked but failing to resolve can be diagnosed remotely. --- MQTT_IMPLEMENTATION.md | 1 + scripts/webconfig_mock_server.py | 1 + src/helpers/CommonCLI_Observer.cpp | 2 + src/helpers/NetworkLink.cpp | 86 ++++++++++++++++++++++++++++++ src/helpers/NetworkLink.h | 2 + webui/index.html | 1 + 6 files changed, 93 insertions(+) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 72fdbfc02..ffb60eb6f 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -558,6 +558,7 @@ These settings apply across all MQTT slots: - `get wifi.pwd` - Get WiFi password - `get link.status` - Get the selected network medium, connection status, IP, signal when available, and uptime - `get link.diag` - Explain automatic Ethernet selection using controller initialization, link event, IP, WiFi fallback, route-lock, and reason state +- `get link.dns` - Show the live DNS resolver list, the selected medium's gateway, and the DNS each medium's DHCP lease installed (`-` = empty slot, `none` = never leased) - `get wifi.status` - WiFi-only compatibility alias; reports n/a when another medium is selected - `get wifi.powersave` - Get WiFi power save mode (none/min/max) diff --git a/scripts/webconfig_mock_server.py b/scripts/webconfig_mock_server.py index 1c37955c4..ff2ec98a0 100644 --- a/scripts/webconfig_mock_server.py +++ b/scripts/webconfig_mock_server.py @@ -574,6 +574,7 @@ GETTERS = { "link.diag": lambda c: ( "why:ethernet-not-enabled selected:wifi\n" "wifi:state:connected ip:192.168.1.42"), + "link.dns": lambda c: "dns:192.168.1.1,-,- gw:192.168.1.1 lease:192.168.1.1,-", "mqtt.status": lambda c: cli_mqtt_status(c), "mqtt.presets": lambda c: "\n".join( "%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd) diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 4af86b3b1..0cee10c81 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1029,6 +1029,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } } else if (strcmp(config, "link.diag") == 0) { activeNetworkLink().formatDiagnostics(reply, 160); + } else if (strcmp(config, "link.dns") == 0) { + activeNetworkLink().formatDns(reply, 160); } else if (memcmp(config, "link.status", 11) == 0 || memcmp(config, "wifi.status", 11) == 0) { NetworkLink& network = activeNetworkLink(); diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index ba87b35a5..393866236 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -33,6 +33,38 @@ extern void tcpipInit(); namespace { +static void appendDnsAddr(char* out, size_t out_size, const ip_addr_t& addr) { + const size_t len = strlen(out); + if (len >= out_size) return; + if (len > 0 && len + 1 < out_size) { + out[len] = ','; + out[len + 1] = '\0'; + } + const size_t at = strlen(out); + if (ip_addr_isany_val(addr)) { + snprintf(out + at, out_size - at, "-"); + } else { + ipaddr_ntoa_r(&addr, out + at, (int)(out_size - at)); + } +} + +static esp_err_t readDnsServersOnTcpipThread(void* ctx) { + ip_addr_t* servers = static_cast(ctx); + for (int i = 0; i < DNS_MAX_SERVERS; i++) { + const ip_addr_t* server = dns_getserver(i); + servers[i] = (server != nullptr) ? *server : *IP_ADDR_ANY; + } + return ESP_OK; +} + +// lwIP's server list is global, so this is what every socket resolves through. +static void formatCurrentDnsServers(char* out, size_t out_size) { + ip_addr_t servers[DNS_MAX_SERVERS] = {}; + esp_netif_tcpip_exec(readDnsServersOnTcpipThread, servers); + out[0] = '\0'; + for (int i = 0; i < DNS_MAX_SERVERS; i++) appendDnsAddr(out, out_size, servers[i]); +} + class NetworkLinkBase : public NetworkLink { protected: std::atomic _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})}; @@ -144,6 +176,36 @@ class NetworkLinkBase : public NetworkLink { public: void snapshotDns() { esp_netif_tcpip_exec(snapshotDnsOnTcpipThread, this); } + // "none" until this medium has held a lease that carried a resolver. + void formatLeaseDns(char* out, size_t out_size) const { + struct Copy { + const NetworkLinkBase* self; + ip_addr_t servers[kDnsServers]; + bool captured; + } copy = {this, {}, false}; + esp_netif_tcpip_exec([](void* ctx) -> esp_err_t { + Copy* c = static_cast(ctx); + c->captured = c->self->_dns_captured; + for (int i = 0; i < kDnsServers; i++) c->servers[i] = c->self->_dns_snapshot[i]; + return ESP_OK; + }, ©); + out[0] = '\0'; + if (!copy.captured) { + snprintf(out, out_size, "none"); + return; + } + for (int i = 0; i < kDnsServers; i++) appendDnsAddr(out, out_size, copy.servers[i]); + } + + void formatDnsFor(char* reply, size_t reply_size, IPAddress gateway) const { + char current[64]; + char lease[40]; + formatCurrentDnsServers(current, sizeof(current)); + formatLeaseDns(lease, sizeof(lease)); + snprintf(reply, reply_size, "> dns:%s gw:%s lease:%s", + current, gateway.toString().c_str(), lease); + } + // A medium that has never held a lease with a resolver leaves the current one // alone rather than blanking it. void restoreDns() { esp_netif_tcpip_exec(restoreDnsOnTcpipThread, this); } @@ -324,6 +386,10 @@ class WiFiNetworkLink final : public NetworkLinkBase { statusName(), localIP().toString().c_str()); } + void formatDns(char* reply, size_t reply_size) const override { + formatDnsFor(reply, reply_size, WiFi.gatewayIP()); + } + IPAddress gatewayIP() const { return WiFi.gatewayIP(); } }; #if defined(NETWORK_PREFER_ETHERNET) @@ -471,6 +537,10 @@ class EthernetNetworkLink final : public NetworkLinkBase { snprintf(reply, reply_size, "> ethernet:%s ip=%s", statusName(), localIP().toString().c_str()); } + void formatDns(char* reply, size_t reply_size) const override { + formatDnsFor(reply, reply_size, CH390.gatewayIP()); + } + IPAddress gatewayIP() const { return CH390.gatewayIP(); } EventState eventState() const { return static_cast( @@ -936,6 +1006,22 @@ class AutomaticNetworkLink final : public NetworkLink { wifiConfigured() ? "yes" : "no", wifi_started ? "yes" : "no", (wifi_started && _wifi.isConnected()) ? "up" : "down"); } + void formatDns(char* reply, size_t reply_size) const override { + const NetworkMedium selected = _selected.load(std::memory_order_acquire); + char current[64]; + char eth_lease[40]; + char wifi_lease[40]; + formatCurrentDnsServers(current, sizeof(current)); + _ethernet.formatLeaseDns(eth_lease, sizeof(eth_lease)); + _wifi.formatLeaseDns(wifi_lease, sizeof(wifi_lease)); + const IPAddress gateway = selected == NetworkMedium::Ethernet ? _ethernet.gatewayIP() + : selected == NetworkMedium::WiFi ? _wifi.gatewayIP() + : IPAddress(); + snprintf(reply, reply_size, + "> dns:%s selected:%s gw:%s\neth-lease:%s wifi-lease:%s", + current, NetworkPolicy::mediumName(selected), + gateway.toString().c_str(), eth_lease, wifi_lease); + } unsigned long connectedAtMillis() const override { const NetworkMedium selected = _selected.load(std::memory_order_acquire); return selected == NetworkMedium::None diff --git a/src/helpers/NetworkLink.h b/src/helpers/NetworkLink.h index afc125a96..914ef1e3f 100644 --- a/src/helpers/NetworkLink.h +++ b/src/helpers/NetworkLink.h @@ -59,6 +59,8 @@ class NetworkLink { virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI. virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0; virtual void formatDiagnostics(char* reply, size_t reply_size) const = 0; + // Live lwIP resolver list, gateway, and the DNS each medium's lease installed. + virtual void formatDns(char* reply, size_t reply_size) const = 0; virtual unsigned long connectedAtMillis() const = 0; virtual uint8_t lastDisconnectReason() const = 0; diff --git a/webui/index.html b/webui/index.html index 85d392439..d8dda96a4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1562,6 +1562,7 @@ var CLI_KEYS=[ ["wifi.powersave","WiFi power-save mode",0,"none|min|max"], ["link.status","Selected network, IP, signal and uptime",1], ["link.diag","Ethernet selection and fallback diagnostics",1], + ["link.dns","DNS servers, gateway and per-network lease DNS",1], ["wifi.status","WiFi connection, IP, RSSI and uptime",1], ["mqtt.origin","Observer name in published messages",0], ["mqtt.iata","IATA region code used in topic paths",0],