Decide the listener failure policy in setup, enforce the slot limit once, and reject lwip_tcp through the resolved socket implementation only

This commit is contained in:
J. Nick Koston
2026-09-05 11:23:59 +02:00
parent afc93a4dc8
commit a684e7e67d
5 changed files with 35 additions and 45 deletions
+7 -11
View File
@@ -296,15 +296,6 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType:
def _validate_outgoing_connection(config: ConfigType) -> ConfigType:
if CONF_OUTGOING_CONNECTION not in config:
return config
# Platform default check here for a friendly early error; an explicit
# lwip_tcp selection on other platforms is caught against the resolved
# implementation in _validate_outgoing_socket_implementation
if CORE.is_esp8266 or CORE.is_rp2:
raise cv.Invalid(
"outgoing_connection is not supported on this platform because its "
"socket layer cannot make outgoing connections",
path=[CONF_OUTGOING_CONNECTION],
)
if CONF_ENCRYPTION not in config:
raise cv.Invalid(
"outgoing_connection requires 'encryption' so the peer is verified by key",
@@ -474,7 +465,11 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType:
def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType:
"""A raw lwip_tcp socket can be selected explicitly on any platform."""
"""Reject the raw lwip_tcp socket, the only option on ESP8266 and RP2040.
Checked against the resolved implementation so an explicit selection on
another platform is caught the same way as the platform default.
"""
if CONF_OUTGOING_CONNECTION not in config:
return config
from esphome.components import socket
@@ -485,7 +480,8 @@ def _validate_outgoing_socket_implementation(config: ConfigType) -> ConfigType:
) in socket.IMPLEMENTATIONS_WITHOUT_CONNECT:
raise cv.Invalid(
f"outgoing_connection is not supported with the {impl} socket "
"implementation because it cannot make outgoing connections",
"implementation (the only one on ESP8266 and RP2040) because it "
"cannot make outgoing connections",
path=[CONF_OUTGOING_CONNECTION],
)
return config
+1 -1
View File
@@ -18,7 +18,7 @@
namespace esphome::api {
// uncomment to log raw packets
// #define HELPER_LOG_PACKETS
//#define HELPER_LOG_PACKETS
// Maximum message size limits to prevent OOM on constrained devices
// Handshake messages are limited to a small size for security
@@ -178,13 +178,12 @@ void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_
return;
}
this->dialed_conn_ = nullptr;
const uint32_t now = App.get_loop_component_start_time();
if (was_authenticated) {
// A working peer (e.g. a host: target that never sends the flag)
// disconnected normally; state is IDLE, so loop() applies the delay
this->backoff_ = BACKOFF_MIN_MS;
} else {
this->schedule_retry_(now);
this->schedule_retry_(App.get_loop_component_start_time());
}
}
+13 -17
View File
@@ -34,12 +34,6 @@ APIServer::APIServer() { global_api_server = this; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
#ifdef USE_API_OUTGOING_CONNECTION
// Dial-out needs no listener; degrade instead of stopping the component
this->status_set_error(LOG_STR("listen socket failed"));
#else
this->mark_failed();
#endif
}
bool APIServer::create_listen_socket_() {
@@ -98,14 +92,15 @@ void APIServer::setup() {
#endif
#endif
#ifdef USE_API_OUTGOING_CONNECTION
// A dead listener degrades to an error status; dial-out still runs
this->create_listen_socket_();
#else
if (!this->create_listen_socket_()) {
#ifdef USE_API_OUTGOING_CONNECTION
// Dial-out needs no listener; degrade instead of stopping the component
this->status_set_error(LOG_STR("listen socket failed"));
#else
this->mark_failed();
return;
}
#endif
}
#ifdef USE_LOGGER
if (logger::global_logger != nullptr) {
@@ -309,7 +304,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
bool APIServer::add_client_(APIConnection *conn) {
if (this->at_client_limit_()) {
// Callers check first; enforce the array bound where the write happens
// The accept path checks first to skip the allocation; the outgoing
// handoff relies on this check
ESP_LOGW(TAG, "Max connections (%d), dropping client", MAX_API_CONNECTIONS);
delete conn;
return false;
@@ -328,11 +324,11 @@ bool APIServer::add_client_(APIConnection *conn) {
#ifdef USE_API_OUTGOING_CONNECTION
APIConnection *APIServer::add_outgoing_client_(std::unique_ptr<socket::Socket> sock) {
// Re-check at the handoff: inbound clients may have filled the slots and
// the PSK may have been cleared (mark_outgoing() needs the noise helper)
const bool at_limit = this->at_client_limit_();
if (at_limit || !this->noise_ctx_.has_psk()) {
ESP_LOGW(TAG, "Dropping outgoing connection (%s)", at_limit ? "max connections" : "no key");
// Re-check at the handoff: the PSK may have been cleared since the dial
// started (mark_outgoing() needs the noise helper); add_client_ re-checks
// the slot limit
if (!this->noise_ctx_.has_psk()) {
ESP_LOGW(TAG, "Dropping outgoing connection (no key)");
return nullptr;
}
auto *conn = new APIConnection(std::move(sock), this);
@@ -81,25 +81,24 @@ def test_outgoing_connection_requires_encryption(
@pytest.mark.parametrize(
"platform_framework",
[PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO],
("platform_framework", "platform_data"),
[
# The platform default on these two
(PlatformFramework.ESP8266_ARDUINO, None),
(PlatformFramework.RP2040_ARDUINO, None),
# An explicit selection elsewhere
(PlatformFramework.ESP32_IDF, ESP32_PLATFORM_DATA),
],
)
def test_outgoing_connection_rejected_on_raw_lwip_platforms(
def test_outgoing_connection_rejects_lwip_tcp(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
platform_data: ConfigType | None,
) -> None:
set_core_config(platform_framework)
with pytest.raises(cv.Invalid, match="not supported on this platform"):
CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
def test_outgoing_connection_rejects_lwip_tcp_selected_on_esp32(
set_core_config: SetCoreConfigCallable,
) -> None:
"""An explicit lwip_tcp selection is caught at final validate."""
"""The resolved lwip_tcp socket is rejected at final validate."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data=ESP32_PLATFORM_DATA,
platform_framework,
platform_data=platform_data,
full_config={"socket": {"implementation": "lwip_tcp"}},
)
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))