From ae549b25bbf0232a4c84aa61dc25fe66ee1d50cb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Sep 2026 11:36:57 +0200 Subject: [PATCH] Report address parse failures from set_sockaddr and drop duplicated policy --- esphome/components/api/__init__.py | 9 +++------ .../components/api/api_outgoing_connection.cpp | 17 ++++++----------- esphome/components/api/api_server.cpp | 5 +++-- esphome/components/socket/socket.cpp | 12 ++++++++++-- .../api/test_outgoing_connection.py | 11 ++++++++--- 5 files changed, 30 insertions(+), 24 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 95873ba7d8..1ea06d980e 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -295,14 +295,11 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: def _validate_outgoing_connection(config: ConfigType) -> ConfigType: + # The socket-layer constraint (raw lwip_tcp cannot dial out, the default + # on esp8266/rp2040) is checked against the resolved implementation in + # _validate_outgoing_socket_implementation at final validate if CONF_OUTGOING_CONNECTION not in config: return config - 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", diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 6c826d0d19..5727e57389 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -81,10 +81,7 @@ void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { struct sockaddr_storage addr; socklen_t addr_len = socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, API_OUTGOING_CONNECTION_PORT); - // inet_addr() cannot signal failure: unparsable IPv4 text yields the - // broadcast address, which is never a valid target either - if (addr_len == 0 || (((struct sockaddr *) &addr)->sa_family == AF_INET && - ((struct sockaddr_in *) &addr)->sin_addr.s_addr == ESPHOME_INADDR_NONE)) { + if (addr_len == 0) { ESP_LOGW(TAG, "Invalid target %s", host); #ifndef API_OUTGOING_CONNECTION_HOST // A corrupt remembered value can never become dialable; forget it @@ -204,9 +201,8 @@ void OutgoingConnectionManager::on_client_removed(APIConnection *conn, bool was_ 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 + // disconnected normally; state is IDLE, so loop() applies the delay this->backoff_ = BACKOFF_MIN_MS; - this->schedule_wait_(now, API_OUTGOING_CONNECTION_DELAY); } else { this->schedule_retry_(now); } @@ -245,11 +241,10 @@ void OutgoingConnectionManager::on_target_client(APIConnection *conn) { } void OutgoingConnectionManager::dump_config() const { -#ifdef API_OUTGOING_CONNECTION_HOST - const char *host = API_OUTGOING_CONNECTION_HOST; -#else - const char *host = this->saved_.host[0] != '\0' ? this->saved_.host : "none remembered yet"; -#endif + const char *host = this->target_host_(); + if (host == nullptr) { + host = "none remembered yet"; + } ESP_LOGCONFIG(TAG, " Outgoing connection port: %u\n" " Outgoing connection host: %s", diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 99772ee7dc..de5b050e32 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -298,8 +298,9 @@ void APIServer::add_client_(APIConnection *conn) { APIConnection *APIServer::add_outgoing_client_(std::unique_ptr 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) - if (this->at_client_limit_() || !this->noise_ctx_.has_psk()) { - ESP_LOGW(TAG, "Dropping outgoing connection (%s)", this->at_client_limit_() ? "max connections" : "no key active"); + 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"); return nullptr; } auto *conn = new APIConnection(std::move(sock), this); diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 212da80312..687160a252 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -165,7 +165,10 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ #else // Use LWIP-specific functions ip6_addr_t ip6; - inet6_aton(ip_address, &ip6); + if (inet6_aton(ip_address, &ip6) == 0) { + errno = EINVAL; + return 0; + } memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); #endif return sizeof(sockaddr_in6); @@ -185,7 +188,12 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_ return 0; } #else - server->sin_addr.s_addr = inet_addr(ip_address); + // Unlike inet_addr(), inet_aton() can signal failure while still + // accepting the broadcast address 255.255.255.255 + if (inet_aton(ip_address, &server->sin_addr) == 0) { + errno = EINVAL; + return 0; + } #endif server->sin_port = htons(port); return sizeof(sockaddr_in); diff --git a/tests/component_tests/api/test_outgoing_connection.py b/tests/component_tests/api/test_outgoing_connection.py index cd0cb46a10..51d3efc5dc 100644 --- a/tests/component_tests/api/test_outgoing_connection.py +++ b/tests/component_tests/api/test_outgoing_connection.py @@ -67,9 +67,14 @@ def test_outgoing_connection_rejected_on_raw_lwip_platforms( set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework, ) -> 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"})) + """esp8266 and rp2040 default to lwip_tcp and are rejected at final validate.""" + set_core_config( + platform_framework, + full_config={"socket": {"implementation": "lwip_tcp"}}, + ) + config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"})) + with pytest.raises(cv.Invalid, match="lwip_tcp"): + _validate_outgoing_socket_implementation(config) def test_outgoing_connection_rejects_lwip_tcp_socket(