diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 2ce8dd21f7..15b61e9015 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_DATA, CONF_DATA_TEMPLATE, CONF_DELAY, + CONF_ENABLE_IPV6, CONF_ENCRYPTION, CONF_EVENT, CONF_ID, @@ -48,6 +49,7 @@ from esphome.const import ( ) from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority from esphome.cpp_generator import MockObj, TemplateArgsType +import esphome.final_validate as fv from esphome.helpers import fnv1_hash from esphome.types import ConfigFragmentType, ConfigType @@ -462,7 +464,27 @@ def _validate_esp8266_action_strings(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = _validate_esp8266_action_strings +def _validate_outgoing_host_ipv6(config: ConfigType) -> ConfigType: + """An IPv6 host silently dials 255.255.255.255 on a build without IPv6.""" + if ( + (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is None + or (host := outgoing.get(CONF_HOST)) is None + or host.version != 6 + ): + return config + network_conf = fv.full_config.get().get("network") or {} + if not network_conf.get(CONF_ENABLE_IPV6): + raise cv.Invalid( + "outgoing_connection host is an IPv6 address but IPv6 is not " + "enabled; set 'network: enable_ipv6: true'", + path=[CONF_OUTGOING_CONNECTION, CONF_HOST], + ) + return config + + +FINAL_VALIDATE_SCHEMA = cv.All( + _validate_esp8266_action_strings, _validate_outgoing_host_ipv6 +) def _add_action_strings( diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 4aee8dcf59..966a148221 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -45,8 +45,9 @@ void OutgoingConnectionManager::loop(APIServer *server) { const uint32_t now = App.get_loop_component_start_time(); switch (this->state_) { case DialState::DIAL_STATE_IDLE: - // The target client just went away; give it the configured delay to - // reconnect on its own before dialing. + // The connected target went away; give it the configured delay to + // reconnect on its own before dialing. (Boot skips this state so a + // device whose client never reached it dials right away.) this->state_ = DialState::DIAL_STATE_WAITING; this->state_ts_ = now; this->wait_ = API_OUTGOING_CONNECTION_DELAY; @@ -117,6 +118,7 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { this->last_poll_ = now; int fd = this->dial_socket_->get_fd(); if (fd < 0 || fd >= FD_SETSIZE) { + ESP_LOGW(TAG, "Bad fd for connect poll: %d", fd); this->schedule_retry_(now); return; } @@ -205,6 +207,8 @@ void OutgoingConnectionManager::dump_config() const { #else if (this->saved_.host[0] != '\0') { ESP_LOGCONFIG(TAG, " Outgoing connection host: %s (remembered)", this->saved_.host); + } else { + ESP_LOGCONFIG(TAG, " Outgoing connection host: none remembered yet"); } #endif } diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index ece7a0882d..5581c3e135 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -6,6 +6,9 @@ #ifdef USE_SOCKET_IMPL_LWIP_TCP #error "api outgoing_connection needs a socket implementation that can make outgoing connections" #endif +#ifndef USE_API_NOISE +#error "api outgoing_connection needs noise encryption so the peer is verified by key" +#endif #include "esphome/components/socket/socket.h" #include "esphome/core/preferences.h" @@ -41,7 +44,10 @@ class OutgoingConnectionManager { /// re-dials. A dialed connection dying without ever sending the flagged /// hello is the unproven-peer case, so the backoff escalates here. void on_client_removed(APIConnection *conn); - void on_shutdown() { this->dial_socket_.reset(); } + void on_shutdown() { + this->dial_socket_.reset(); + this->state_ = DialState::DIAL_STATE_IDLE; + } void dump_config() const; protected: @@ -88,7 +94,10 @@ class OutgoingConnectionManager { uint32_t wait_{0}; uint32_t state_ts_{0}; uint32_t last_poll_{0}; - DialState state_{DialState::DIAL_STATE_IDLE}; + // Boot starts in WAITING with wait_ 0: when no dial-back client has ever + // connected (a device its client could never reach), dial immediately. + // The configured delay only applies after a connected target goes away. + DialState state_{DialState::DIAL_STATE_WAITING}; }; } // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 8effa29489..1063203c8e 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -288,9 +288,11 @@ void APIServer::add_client_(APIConnection *conn) { #ifdef USE_API_OUTGOING_CONNECTION APIConnection *APIServer::add_outgoing_client_(std::unique_ptr sock) { // Inbound clients may have taken the remaining slots while the dial was in - // flight; re-check at the handoff so add_client_ cannot write past clients_ - if (this->at_client_limit_()) { - ESP_LOGW(TAG, "Max connections (%d), dropping outgoing connection", MAX_API_CONNECTIONS); + // flight; re-check at the handoff so add_client_ cannot write past clients_. + // The PSK can also have been cleared mid-dial, and mark_outgoing() requires + // the noise helper the constructor only picks while a PSK is set. + 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"); return nullptr; } auto *conn = new APIConnection(std::move(sock), this); diff --git a/tests/components/api/test-outgoing-connection.esp32-idf.yaml b/tests/components/api/test-outgoing-connection.esp32-idf.yaml index bf806229ed..0796faf6ab 100644 --- a/tests/components/api/test-outgoing-connection.esp32-idf.yaml +++ b/tests/components/api/test-outgoing-connection.esp32-idf.yaml @@ -5,9 +5,8 @@ wifi: ssid: MySSID password: password1 -# Outgoing connection: the device dials Home Assistant when no client with a -# state subscription is connected. Requires encryption so the peer is -# verified by key. +# Outgoing connection: the device dials out when no dial-back client is +# connected. Requires encryption so the peer is verified by key. api: encryption: key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU= diff --git a/tests/integration/test_api_outgoing_connection.py b/tests/integration/test_api_outgoing_connection.py index fc60835b42..a03a2b42ab 100644 --- a/tests/integration/test_api_outgoing_connection.py +++ b/tests/integration/test_api_outgoing_connection.py @@ -153,8 +153,9 @@ async def test_api_outgoing_connection_remembered( """No host configured: the device remembers the client whose hello carried the dial-back flag and dials that address after a restart.""" listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - # Bound but not yet listening: dial attempts in the first phase are - # refused, exercising the retry path without queueing stale connections. + # Bound but not yet listening so first-phase dials cannot queue stale + # connections; whether the device attempts any dial before the restart + # is timing dependent and not asserted here. listener.bind(("127.0.0.1", 0)) port = listener.getsockname()[1]