fix(network): harden automatic link transitions

This commit is contained in:
agessaman committed 2026-09-03 20:47:11 -07:00
1 parent 2c21406380
commit 50543c9b47
6 files changed
+48 -10

No files matched your search

+2 -1
View File
@@ -455,7 +455,8 @@ class AutomaticNetworkInterface final : public NetworkInterface {
// Hardware availability and stored credentials are configuration. Current
// link/DHCP state is runtime state and must not permanently suppress the
// MQTT task that monitors for a late cable or lease.
return _ethernet_started || (wifi_ssid && wifi_ssid[0] != '\0');
(void)wifi_ssid;
return true;
}
bool isAutomatic() const override { return true; }
+3 -2
View File
@@ -38,7 +38,7 @@ static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition)
return {
transition == NetworkTransition::Down || transition == NetworkTransition::Switched,
transition == NetworkTransition::Up || transition == NetworkTransition::Switched,
transition == NetworkTransition::Up || transition == NetworkTransition::Switched,
transition == NetworkTransition::Switched,
};
}
@@ -160,7 +160,8 @@ static inline NetworkMedium automaticSelection(
if (input.selected == NetworkMedium::WiFi) {
if (input.ethernet_connected &&
input.ethernet_stable_ms >= kEthernetFailbackStableMs) {
(!input.wifi_connected ||
input.ethernet_stable_ms >= kEthernetFailbackStableMs)) {
return NetworkMedium::Ethernet;
}
return NetworkMedium::WiFi;
+4 -1
View File
@@ -1252,7 +1252,10 @@ void MQTTBridge::mqttTaskLoop() {
// Wait a bit for WiFi to start connecting
vTaskDelay(pdMS_TO_TICKS(1000));
bool network_was_connected = _network->isConnected();
// Treat the first post-settle sample as an edge. A fast Wi-Fi association
// may complete during the delay above, before the task has sampled state;
// initializing from the live value would then defer NTP until its retry.
bool network_was_connected = false;
unsigned long last_ntp_attempt = 0;
// Main task loop
+31 -3
View File
@@ -7,6 +7,7 @@
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <bootloader_random.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
@@ -23,6 +24,24 @@
// so an untouched password field never overwrites the stored value.
static const char SECRET_SENTINEL[] = "********";
// esp_random() has a true entropy source only while RF is active. Ethernet
// LAN mode deliberately keeps Wi-Fi and Bluetooth down, so temporarily enable
// the bootloader RNG source while generating authentication secrets there.
class WebConfigEntropyGuard {
bool _enabled;
public:
WebConfigEntropyGuard()
: _enabled(activeNetworkInterface().medium() ==
NetworkMedium::Ethernet) {
if (_enabled) bootloader_random_enable();
}
~WebConfigEntropyGuard() {
if (_enabled) bootloader_random_disable();
}
};
// Key classification (allowlist, secret detection, slot-prefix parsing) lives in
// helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep
// the call sites below readable.
@@ -262,8 +281,11 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, char reply[
}
_initial_setup = initial_setup;
if (_initial_setup) {
for (int i = 0; i < 3; ++i) {
sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff));
{
WebConfigEntropyGuard entropy;
for (int i = 0; i < 3; ++i) {
sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff));
}
}
_setup_code[12] = 0;
} else {
@@ -730,7 +752,13 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
}
_login_fails = 0;
_login_lock_until = 0;
for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random());
{
WebConfigEntropyGuard entropy;
for (int i = 0; i < 4; i++) {
sprintf(&_session_token[i * 8], "%08lx",
(unsigned long)esp_random());
}
}
_session_last_seen = now;
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
-1
View File
@@ -17,7 +17,6 @@ static inline bool beginConfiguredCH390(const char* hostname = nullptr) {
if (hostname && hostname[0] != '\0') {
if (!CH390.setHostname(hostname)) {
Serial.printf("Network: invalid Ethernet hostname %s\n", hostname);
return false;
}
}
if (!CH390.begin(config)) return false;
@@ -9,11 +9,11 @@ TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) {
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, MqttUpRequestsImmediateRetryWithoutDisconnectingSlots) {
TEST(NetworkPolicy, MqttUpRetriesWithoutClearingBrokerCircuitBreaker) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up);
EXPECT_FALSE(actions.stop_started_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
EXPECT_TRUE(actions.reset_reconnect_backoff);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) {
@@ -69,6 +69,12 @@ TEST(NetworkPolicy, FailbackRequiresStableEthernet) {
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, DeadWifiFailsBackToReadyEthernetImmediately) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, false, true, false, 0, 0};
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, true, true, true,