From 4e0cda0287adfa66190a8814b9cf064cc6940b60 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 31 Aug 2026 15:40:15 -0400 Subject: [PATCH] Select the dial-back target with an explicit hello flag instead of guessing from client info --- esphome/components/api/__init__.py | 20 +- esphome/components/api/api.proto | 5 + esphome/components/api/api_connection.cpp | 15 +- esphome/components/api/api_connection.h | 11 +- esphome/components/api/api_frame_helper.h | 5 +- .../components/api/api_frame_helper_noise.cpp | 16 +- .../components/api/api_frame_helper_noise.h | 12 +- .../api/api_outgoing_connection.cpp | 115 +++++------- .../components/api/api_outgoing_connection.h | 45 +++-- esphome/components/api/api_pb2.cpp | 5 + esphome/components/api/api_pb2.h | 5 +- esphome/components/api/api_pb2_dump.cpp | 3 + esphome/components/api/api_server.cpp | 17 +- esphome/components/api/api_server.h | 13 +- esphome/core/defines.h | 2 + .../api/test_outgoing_connection.py | 17 +- .../test_api_outgoing_connection.py | 174 +++++++++--------- 17 files changed, 248 insertions(+), 232 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 3b2f87e2bb..2ce8dd21f7 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -1,4 +1,3 @@ -import ipaddress import logging import re from typing import Any @@ -293,17 +292,6 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: return config -def _validate_ip_literal(value: Any) -> str: - value = cv.string_strict(value) - try: - ipaddress.ip_address(value) - except ValueError as err: - raise cv.Invalid( - f"outgoing_connection host must be an IP address, got {value!r}" - ) from err - return value - - def _validate_outgoing_connection_platform(value: ConfigType) -> ConfigType: if CORE.is_esp8266 or CORE.is_rp2: raise cv.Invalid( @@ -325,7 +313,7 @@ def _validate_outgoing_connection(config: ConfigType) -> ConfigType: OUTGOING_CONNECTION_SCHEMA = cv.All( cv.Schema( { - cv.Optional(CONF_HOST): _validate_ip_literal, + cv.Optional(CONF_HOST): cv.ipaddress, cv.Optional(CONF_PORT, default=6054): cv.port, cv.Optional( CONF_DELAY, default="60s" @@ -660,9 +648,9 @@ async def to_code(config: ConfigType) -> None: if (outgoing := config.get(CONF_OUTGOING_CONNECTION)) is not None: cg.add_define("USE_API_OUTGOING_CONNECTION") if (host := outgoing.get(CONF_HOST)) is not None: - cg.add(var.set_outgoing_connection_host(host)) - cg.add(var.set_outgoing_connection_port(outgoing[CONF_PORT])) - cg.add(var.set_outgoing_connection_delay(outgoing[CONF_DELAY])) + cg.add_define("API_OUTGOING_CONNECTION_HOST", str(host)) + cg.add_define("API_OUTGOING_CONNECTION_PORT", outgoing[CONF_PORT]) + cg.add_define("API_OUTGOING_CONNECTION_DELAY", outgoing[CONF_DELAY]) cg.add_define("USE_API") cg.add_global(api_ns.using) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 3a0e0abea9..0f77ced142 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -112,6 +112,11 @@ message HelloRequest { string client_info = 1; uint32 api_version_major = 2; uint32 api_version_minor = 3; + + // Set by clients that can accept connections the device opens to them + // (see api: outgoing_connection:). The device remembers this client's + // address as the target to dial when no such client is connected. + bool outgoing_connection_target = 4 [(field_ifdef) = "USE_API_OUTGOING_CONNECTION"]; } // Confirmation of successful connection request. diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index b587ecde51..10559db850 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1786,10 +1786,6 @@ void APIConnection::complete_authentication_() { #endif } -#ifdef USE_API_OUTGOING_CONNECTION -void APIConnection::notify_state_subscription_() { this->parent_->on_client_state_subscription(this); } -#endif - bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Copy client name with truncation if needed (set_client_name handles truncation) this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size()); @@ -1826,6 +1822,17 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Auto-authenticate - password auth was removed in ESPHome 2026.1.0 this->complete_authentication_(); +#ifdef USE_API_OUTGOING_CONNECTION + // Only honor the flag once a real key is active: with a PSK set, plaintext + // and the all-zeros provisioning PSK are rejected at the transport, so a + // client reaching this point has proven possession of the key. + if (msg.outgoing_connection_target && !this->flags_.outgoing_connection_target && + this->parent_->get_noise_ctx().has_psk()) { + this->flags_.outgoing_connection_target = true; + this->parent_->on_outgoing_target_client(this); + } +#endif + return this->send_message(resp); } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index af20a6a860..e8f8375aae 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -275,9 +275,6 @@ class APIConnection final : public APIServerConnectionBase { void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; -#ifdef USE_API_OUTGOING_CONNECTION - this->notify_state_subscription_(); -#endif // Start initial state iterator only if no iterator is active // If list_entities is running, we'll start initial_state when it completes if (this->active_iterator_ == ActiveIterator::NONE) { @@ -389,11 +386,6 @@ class APIConnection final : public APIServerConnectionBase { protected: bool try_to_clear_buffer_slow_(bool log_out_of_space); -#ifdef USE_API_OUTGOING_CONNECTION - // Out of line: forwards to the server, which is an incomplete type here - void notify_state_subscription_(); -#endif - // Helper function to handle authentication completion void complete_authentication_(); @@ -761,6 +753,9 @@ class APIConnection final : public APIServerConnectionBase { uint8_t batch_first_message : 1; // For batch buffer allocation uint8_t should_try_send_immediately : 1; // True after initial states are sent uint8_t may_have_remaining_data : 1; // Read loop hit limit, retry without ready check +#ifdef USE_API_OUTGOING_CONNECTION + uint8_t outgoing_connection_target : 1; // Client declared itself a dial-back target in its hello +#endif #ifdef HAS_PROTO_MESSAGE_DUMP uint8_t log_only_mode : 1; #endif diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index ff8aa7834c..88a738a490 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -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 @@ -282,7 +282,8 @@ class APIFrameHelper { DATA = 5, CLOSED = 6, FAILED = 7, - EXPLICIT_REJECT = 8, // Noise only + EXPLICIT_REJECT = 8, // Noise only + CLIENT_HELLO_OUTGOING = 9, // Noise only: like CLIENT_HELLO but the server hello already went out (outgoing conn) }; // Fast inline state check for read_packet/write_protobuf_messages hot path. diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 8324b6266d..fa9d7d3eee 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -61,6 +61,14 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; /// Initialize the frame helper, returns OK if successful. APIError APINoiseFrameHelper::init() { +#ifdef USE_API_OUTGOING_CONNECTION + // set_server_hello_first() marks the outgoing mode in state_; restore the + // state init_common_() expects before running it. + const bool outgoing = this->state_ == State::CLIENT_HELLO_OUTGOING; + if (outgoing) { + this->state_ = State::INITIALIZE; + } +#endif APIError err = init_common_(); if (err != APIError::OK) { return err; @@ -80,9 +88,10 @@ APIError APINoiseFrameHelper::init() { state_ = State::CLIENT_HELLO; #ifdef USE_API_OUTGOING_CONNECTION - if (this->server_hello_first_) { + if (outgoing) { // Outgoing connection: the peer needs our name and MAC to pick // the matching key before it can send its PSK-mixed handshake message. + state_ = State::CLIENT_HELLO_OUTGOING; return this->send_server_hello_frame_(); } #endif @@ -260,6 +269,9 @@ APIError APINoiseFrameHelper::state_action_() { HELPER_LOG("Bad state for method: %d", (int) this->state_); return APIError::BAD_STATE; case State::CLIENT_HELLO: +#ifdef USE_API_OUTGOING_CONNECTION + case State::CLIENT_HELLO_OUTGOING: +#endif return this->state_action_client_hello_(); case State::SERVER_HELLO: return this->state_action_server_hello_(); @@ -293,7 +305,7 @@ APIError APINoiseFrameHelper::state_action_client_hello_() { } #ifdef USE_API_OUTGOING_CONNECTION - if (this->server_hello_first_) { + if (this->state_ == State::CLIENT_HELLO_OUTGOING) { // Server hello already went out in init(); go straight to the handshake. aerr = init_handshake_(); if (aerr != APIError::OK) diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index 73b19bfb3b..cd74c13342 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -30,10 +30,11 @@ class APINoiseFrameHelper final : public APIFrameHelper { APIError init_from_handoff(const uint8_t *header, uint8_t header_len); #endif #ifdef USE_API_OUTGOING_CONNECTION - // Outgoing connection: send the server hello immediately in init() - // so the peer can identify this device and select the matching key before it - // sends the PSK-mixed first handshake message. Must be called before init(). - void set_server_hello_first() { this->server_hello_first_ = true; } + // Outgoing connection: init() sends the server hello immediately so the + // peer can identify this device and select the matching key before it sends + // the PSK-mixed first handshake message. Must be called before init(); + // stored in state_ so the helper does not grow. + void set_server_hello_first() { this->state_ = State::CLIENT_HELLO_OUTGOING; } #endif APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; @@ -76,9 +77,6 @@ class APINoiseFrameHelper final : public APIFrameHelper { // Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE]; uint8_t rx_header_buf_len_ = 0; -#ifdef USE_API_OUTGOING_CONNECTION - bool server_hello_first_{false}; -#endif // 4 bytes total, no padding }; diff --git a/esphome/components/api/api_outgoing_connection.cpp b/esphome/components/api/api_outgoing_connection.cpp index 7500dbeb39..7f2aabd482 100644 --- a/esphome/components/api/api_outgoing_connection.cpp +++ b/esphome/components/api/api_outgoing_connection.cpp @@ -1,7 +1,7 @@ #include "api_outgoing_connection.h" #if defined(USE_API) && defined(USE_API_OUTGOING_CONNECTION) -#include "api_frame_helper.h" +#include "api_connection.h" #include "api_server.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" @@ -26,86 +26,81 @@ void OutgoingConnectionManager::setup() { } void OutgoingConnectionManager::loop(APIServer *server) { - const uint32_t now = App.get_loop_component_start_time(); - const bool has_subscriber = server->is_connected_with_state_subscription(); - if (has_subscriber) { - if (this->state_ != DialState::DIAL_STATE_IDLE) { - this->abort_dial_(); - this->state_ = DialState::DIAL_STATE_IDLE; - this->backoff_ = BACKOFF_MIN_MS; - } + if (server->has_outgoing_target_client_()) { + // on_target_client() already reset the dial state when this client's + // hello arrived; nothing to do while it stays connected. return; } + 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. this->state_ = DialState::DIAL_STATE_WAITING; this->state_ts_ = now; + this->wait_ = API_OUTGOING_CONNECTION_DELAY; break; case DialState::DIAL_STATE_WAITING: - if (now - this->state_ts_ >= this->delay_) { + if (now - this->state_ts_ >= this->wait_) { this->try_dial_(server, now); } break; case DialState::DIAL_STATE_CONNECTING: this->poll_connect_(server, now); break; - case DialState::DIAL_STATE_COOLDOWN: - if (now - this->state_ts_ >= this->cooldown_wait_) { - this->try_dial_(server, now); - } - break; } } void OutgoingConnectionManager::try_dial_(APIServer *server, uint32_t now) { const char *host = this->target_host_(); - if (host == nullptr || !network::is_connected() || server->api_connection_count_ >= MAX_API_CONNECTIONS || - !server->noise_ctx_.has_psk()) { - this->enter_cooldown_(now); + if (host == nullptr || !network::is_connected() || server->at_client_limit_() || !server->noise_ctx_.has_psk()) { + this->schedule_retry_(now); return; } - struct sockaddr_storage addr {}; - socklen_t addr_len = socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, this->port_); + struct sockaddr_storage addr; + socklen_t addr_len = + socket::set_sockaddr((struct sockaddr *) &addr, sizeof(addr), host, API_OUTGOING_CONNECTION_PORT); if (addr_len == 0) { - ESP_LOGW(TAG, "Invalid outgoing connection target %s", host); - this->enter_cooldown_(now); + ESP_LOGW(TAG, "Invalid target %s", host); + this->schedule_retry_(now); return; } this->dial_socket_ = socket::socket_loop_monitored(((struct sockaddr *) &addr)->sa_family, SOCK_STREAM, IPPROTO_TCP); if (!this->dial_socket_ || this->dial_socket_->setblocking(false) != 0) { - this->abort_dial_(); - this->enter_cooldown_(now); + this->schedule_retry_(now); return; } - ESP_LOGD(TAG, "Dialing %s:%u", host, this->port_); + ESP_LOGD(TAG, "Dialing %s:%u", host, API_OUTGOING_CONNECTION_PORT); int err = this->dial_socket_->connect((struct sockaddr *) &addr, addr_len); if (err == 0) { // Immediate success (possible for localhost) server->add_outgoing_client_(std::move(this->dial_socket_)); - this->enter_cooldown_(now); + this->schedule_retry_(now); return; } if (errno != EINPROGRESS) { - ESP_LOGW(TAG, "Outgoing connect failed: errno %d", errno); - this->abort_dial_(); - this->enter_cooldown_(now); + ESP_LOGW(TAG, "Connect failed: errno %d", errno); + this->schedule_retry_(now); return; } this->state_ = DialState::DIAL_STATE_CONNECTING; this->state_ts_ = now; + this->last_poll_ = now; } void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { if (now - this->state_ts_ >= CONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Outgoing connect timeout"); - this->abort_dial_(); - this->enter_cooldown_(now); + ESP_LOGW(TAG, "Connect timeout"); + this->schedule_retry_(now); return; } + if (now - this->last_poll_ < CONNECT_POLL_INTERVAL_MS) { + return; + } + this->last_poll_ = now; int fd = this->dial_socket_->get_fd(); if (fd < 0) { - this->abort_dial_(); - this->enter_cooldown_(now); + this->schedule_retry_(now); return; } // Connect completion is a write event; the main loop select() only watches @@ -121,61 +116,53 @@ void OutgoingConnectionManager::poll_connect_(APIServer *server, uint32_t now) { int error = 0; socklen_t len = sizeof(error); if (this->dial_socket_->getsockopt(SOL_SOCKET, SO_ERROR, &error, &len) != 0 || error != 0) { - ESP_LOGW(TAG, "Outgoing connect failed: %d", error); - this->abort_dial_(); - this->enter_cooldown_(now); + ESP_LOGW(TAG, "Connect failed: %d", error); + this->schedule_retry_(now); return; } server->add_outgoing_client_(std::move(this->dial_socket_)); - // Stay in cooldown until the peer proves itself by subscribing to states; - // loop() flips back to idle and resets the backoff when that happens. - this->enter_cooldown_(now); + // Stay in a retry wait until the peer proves itself by sending a flagged + // hello; on_target_client() then resets to idle and clears the backoff. + this->schedule_retry_(now); } -void OutgoingConnectionManager::enter_cooldown_(uint32_t now) { - this->state_ = DialState::DIAL_STATE_COOLDOWN; +void OutgoingConnectionManager::schedule_retry_(uint32_t now) { + this->dial_socket_.reset(); // no-op when the socket was handed off + this->state_ = DialState::DIAL_STATE_WAITING; this->state_ts_ = now; // +/-20% jitter so a fleet of devices does not retry one server in lockstep const uint32_t jitter_span = this->backoff_ / 5; - this->cooldown_wait_ = this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)); + this->wait_ = this->backoff_ - jitter_span + (random_uint32() % (2 * jitter_span + 1)); this->backoff_ = std::min(this->backoff_ * 2, BACKOFF_MAX_MS); } -void OutgoingConnectionManager::on_state_subscription(const char *client_name, APIFrameHelper *helper) { - // A state subscriber is connected; any dial in flight is now pointless. - this->abort_dial_(); +void OutgoingConnectionManager::on_target_client(APIConnection *conn) { + // The target is connected; stop any dial in flight and reset the backoff. + this->dial_socket_.reset(); this->state_ = DialState::DIAL_STATE_IDLE; this->backoff_ = BACKOFF_MIN_MS; - if (strncmp(client_name, "Home Assistant", 14) != 0) { - return; - } - struct sockaddr_storage peer {}; - socklen_t peer_len = sizeof(peer); - if (helper->getpeername((struct sockaddr *) &peer, &peer_len) != 0) { - return; - } SavedOutgoingTarget target{}; - if (socket::format_sockaddr_to((struct sockaddr *) &peer, peer_len, target.host) == 0) { - return; - } - if (strcmp(target.host, this->saved_.host) == 0) { - return; // unchanged; avoid flash wear + conn->get_peername_to(target.host); + if (target.host[0] == '\0' || strcmp(target.host, this->saved_.host) == 0) { + return; // unknown peer or unchanged; avoid flash wear } this->saved_ = target; if (!this->target_pref_.save(&this->saved_) || !global_preferences->sync()) { - ESP_LOGW(TAG, "Failed to save outgoing connection target"); + ESP_LOGW(TAG, "Failed to save target"); return; } ESP_LOGD(TAG, "Saved %s as outgoing connection target", this->saved_.host); } void OutgoingConnectionManager::dump_config() const { - ESP_LOGCONFIG(TAG, " Outgoing connection port: %u", this->port_); - if (this->configured_host_ != nullptr) { - ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", this->configured_host_); - } else if (this->saved_.host[0] != '\0') { + ESP_LOGCONFIG(TAG, " Outgoing connection port: %u", API_OUTGOING_CONNECTION_PORT); +#ifdef API_OUTGOING_CONNECTION_HOST + ESP_LOGCONFIG(TAG, " Outgoing connection host: %s", API_OUTGOING_CONNECTION_HOST); +#else + if (this->saved_.host[0] != '\0') { ESP_LOGCONFIG(TAG, " Outgoing connection host: %s (remembered)", this->saved_.host); } +#endif } } // namespace esphome::api diff --git a/esphome/components/api/api_outgoing_connection.h b/esphome/components/api/api_outgoing_connection.h index 7ec6e24bc9..50d15fc54b 100644 --- a/esphome/components/api/api_outgoing_connection.h +++ b/esphome/components/api/api_outgoing_connection.h @@ -15,32 +15,29 @@ namespace esphome::api { class APIServer; -class APIFrameHelper; +class APIConnection; struct SavedOutgoingTarget { // Null-terminated IP string; empty when no peer has been remembered yet. // Stored as text so the platform-specific v4-mapped-IPv6 normalization in - // socket::format_sockaddr_to()/set_sockaddr() is reused on both ends. + // the socket component is reused on both ends. char host[socket::SOCKADDR_STR_LEN]; } PACKED; // NOLINT -/// Dials out to Home Assistant when no state-subscribed client is connected. -/// The TCP direction flips but the protocol roles do not: this device stays the -/// Noise responder, so both sides still verify each other by the shared key. -/// The target is either a host from YAML or the last persisted Home Assistant -/// peer address; the listening socket keeps accepting inbound clients the -/// whole time. +/// Dials out to Home Assistant when no dial-back target client is connected. +/// The TCP direction flips but the protocol roles do not: this device stays +/// the Noise responder, so both sides still verify each other by the shared +/// key. The target is either a host from YAML or the persisted address of the +/// last client whose hello declared it a dial-back target; the listening +/// socket keeps accepting inbound clients the whole time. class OutgoingConnectionManager { public: void setup(); void loop(APIServer *server); - /// Called when a client subscribes to states. Home Assistant peers are - /// persisted as the dial-back target; any subscriber cancels dialing. - void on_state_subscription(const char *client_name, APIFrameHelper *helper); - void on_shutdown() { this->abort_dial_(); } - void set_target_host(const char *host) { this->configured_host_ = host; } - void set_port(uint16_t port) { this->port_ = port; } - void set_delay(uint32_t delay) { this->delay_ = delay; } + /// Called when a key-verified client declares itself a dial-back target; + /// the last such client wins as the remembered address. + void on_target_client(APIConnection *conn); + void on_shutdown() { this->dial_socket_.reset(); } void dump_config() const; protected: @@ -48,32 +45,32 @@ class OutgoingConnectionManager { DIAL_STATE_IDLE, DIAL_STATE_WAITING, DIAL_STATE_CONNECTING, - DIAL_STATE_COOLDOWN, }; static constexpr uint32_t BACKOFF_MIN_MS = 5000; static constexpr uint32_t BACKOFF_MAX_MS = 300000; static constexpr uint32_t CONNECT_TIMEOUT_MS = 10000; + static constexpr uint32_t CONNECT_POLL_INTERVAL_MS = 250; void try_dial_(APIServer *server, uint32_t now); void poll_connect_(APIServer *server, uint32_t now); - void abort_dial_() { this->dial_socket_.reset(); } - void enter_cooldown_(uint32_t now); + // Close any half-open dial and wait a jittered backoff before retrying + void schedule_retry_(uint32_t now); const char *target_host_() const { - if (this->configured_host_ != nullptr) - return this->configured_host_; +#ifdef API_OUTGOING_CONNECTION_HOST + return API_OUTGOING_CONNECTION_HOST; +#else return this->saved_.host[0] != '\0' ? this->saved_.host : nullptr; +#endif } std::unique_ptr dial_socket_; - const char *configured_host_{nullptr}; ESPPreferenceObject target_pref_; SavedOutgoingTarget saved_{}; - uint32_t delay_{60000}; uint32_t backoff_{BACKOFF_MIN_MS}; - uint32_t cooldown_wait_{0}; + uint32_t wait_{0}; uint32_t state_ts_{0}; - uint16_t port_{6054}; + uint32_t last_poll_{0}; DialState state_{DialState::DIAL_STATE_IDLE}; }; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 2de1f0a15c..ee28bbc6dc 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -15,6 +15,11 @@ bool HelloRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) case 3: this->api_version_minor = value; break; +#ifdef USE_API_OUTGOING_CONNECTION + case 4: + this->outgoing_connection_target = value != 0; + break; +#endif default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 5c3429a63a..0d305c4400 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -412,13 +412,16 @@ class CommandProtoMessage : public ProtoDecodableMessage { class HelloRequest final : public ProtoDecodableMessage { public: static constexpr uint16_t MESSAGE_TYPE = 1; - static constexpr uint8_t ESTIMATED_SIZE = 17; + static constexpr uint8_t ESTIMATED_SIZE = 19; #ifdef HAS_PROTO_MESSAGE_DUMP const LogString *message_name() const override { return LOG_STR("hello_request"); } #endif StringRef client_info{}; uint32_t api_version_major{0}; uint32_t api_version_minor{0}; +#ifdef USE_API_OUTGOING_CONNECTION + bool outgoing_connection_target{false}; +#endif #ifdef HAS_PROTO_MESSAGE_DUMP const char *dump_to(DumpBuffer &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index dced81ee30..f5f3de3917 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -885,6 +885,9 @@ const char *HelloRequest::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("client_info"), this->client_info); dump_field(out, ESPHOME_PSTR("api_version_major"), this->api_version_major); dump_field(out, ESPHOME_PSTR("api_version_minor"), this->api_version_minor); +#ifdef USE_API_OUTGOING_CONNECTION + dump_field(out, ESPHOME_PSTR("outgoing_connection_target"), this->outgoing_connection_target); +#endif return out.c_str(); } const char *HelloResponse::dump_to(DumpBuffer &out) const { diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 575c20a578..26b5ce330f 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -212,6 +212,12 @@ void APIServer::remove_client_(uint8_t client_index) { std::string client_peername(client->get_peername_to(peername_buf)); #endif +#ifdef USE_API_OUTGOING_CONNECTION + if (client->flags_.outgoing_connection_target) { + this->outgoing_target_count_--; + } +#endif + // Close socket now (was deferred from on_fatal_error to allow getpeername) client->helper_->close(); @@ -254,7 +260,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() { sock->getpeername_to(peername); // Check if we're at the connection limit - if (this->api_connection_count_ >= MAX_API_CONNECTIONS) { + if (this->at_client_limit_()) { ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername); // Immediately close - socket destructor will handle cleanup sock.reset(); @@ -280,17 +286,14 @@ void APIServer::add_client_(APIConnection *conn) { #ifdef USE_API_OUTGOING_CONNECTION void APIServer::add_outgoing_client_(std::unique_ptr sock) { - char peername[socket::SOCKADDR_STR_LEN]; - sock->getpeername_to(peername); - ESP_LOGD(TAG, "Outgoing connection to %s", peername); - auto *conn = new APIConnection(std::move(sock), this); conn->mark_outgoing(); this->add_client_(conn); } -void APIServer::on_client_state_subscription(APIConnection *conn) { - this->outgoing_conn_.on_state_subscription(conn->get_name(), conn->helper_.get()); +void APIServer::on_outgoing_target_client(APIConnection *conn) { + this->outgoing_target_count_++; + this->outgoing_conn_.on_target_client(conn); } #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index e985a3beb2..664ec19a14 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -83,11 +83,8 @@ class APIServer final : public Component, noise::NoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE #ifdef USE_API_OUTGOING_CONNECTION - void set_outgoing_connection_host(const char *host) { this->outgoing_conn_.set_target_host(host); } - void set_outgoing_connection_port(uint16_t port) { this->outgoing_conn_.set_port(port); } - void set_outgoing_connection_delay(uint32_t delay) { this->outgoing_conn_.set_delay(delay); } - // Called by APIConnection when a client subscribes to states - void on_client_state_subscription(APIConnection *conn); + // Called by APIConnection when a client declares itself a dial-back target in its hello + void on_outgoing_target_client(APIConnection *conn); #endif void handle_disconnect(APIConnection *conn); @@ -268,8 +265,10 @@ class APIServer final : public Component, void __attribute__((noinline)) accept_new_connections_(); // Insert a constructed connection into the client slots and start it. void add_client_(APIConnection *conn); + bool at_client_limit_() const { return this->api_connection_count_ >= MAX_API_CONNECTIONS; } #ifdef USE_API_OUTGOING_CONNECTION void add_outgoing_client_(std::unique_ptr sock); + bool has_outgoing_target_client_() const { return this->outgoing_target_count_ != 0; } friend class OutgoingConnectionManager; #endif // Remove a disconnected client by index. Swaps with the last populated slot and resets it. @@ -365,6 +364,10 @@ class APIServer final : public Component, uint8_t listen_backlog_{4}; bool shutting_down_ = false; uint8_t api_connection_count_{0}; +#ifdef USE_API_OUTGOING_CONNECTION + // Connected clients whose hello declared them a dial-back target + uint8_t outgoing_target_count_{0}; +#endif #if defined(USE_PROVISIONING) && defined(USE_API_NOISE) // Index assigned by the provisioning manager for reporting this transport's state. uint8_t provisioning_source_{0}; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index c740cd1d2d..302245ea0e 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -215,6 +215,8 @@ #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE #define USE_API_OUTGOING_CONNECTION +#define API_OUTGOING_CONNECTION_PORT 6054 +#define API_OUTGOING_CONNECTION_DELAY 60000 #define USE_API_VARINT64 #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS diff --git a/tests/component_tests/api/test_outgoing_connection.py b/tests/component_tests/api/test_outgoing_connection.py index d694ede168..c21f81161e 100644 --- a/tests/component_tests/api/test_outgoing_connection.py +++ b/tests/component_tests/api/test_outgoing_connection.py @@ -9,6 +9,7 @@ from esphome.components.api import CONFIG_SCHEMA from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32 import esphome.config_validation as cv from esphome.const import PlatformFramework +from esphome.core import CORE from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable @@ -23,15 +24,17 @@ def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType: return config -def test_outgoing_connection_generates_setters( +def test_outgoing_connection_generates_defines( generate_main: Callable[[str | Path], str], ) -> None: - """A valid config emits the setters with defaults applied.""" - main_cpp = generate_main("tests/component_tests/api/test_outgoing_connection.yaml") + """A valid config emits the compile-time defines with defaults applied.""" + generate_main("tests/component_tests/api/test_outgoing_connection.yaml") - assert 'set_outgoing_connection_host("192.168.1.2")' in main_cpp - assert "set_outgoing_connection_port(6054)" in main_cpp - assert "set_outgoing_connection_delay(60000)" in main_cpp + defines = {define.name: define.value for define in CORE.defines} + assert "USE_API_OUTGOING_CONNECTION" in defines + assert str(defines["API_OUTGOING_CONNECTION_HOST"]) == '"192.168.1.2"' + assert str(defines["API_OUTGOING_CONNECTION_PORT"]) == "6054" + assert str(defines["API_OUTGOING_CONNECTION_DELAY"]) == "60000" def test_outgoing_connection_defaults( @@ -69,5 +72,5 @@ def test_outgoing_connection_rejects_hostnames( set_core_config: SetCoreConfigCallable, ) -> None: set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA) - with pytest.raises(cv.Invalid, match="must be an IP address"): + with pytest.raises(cv.Invalid, match="not a valid IP address"): CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"})) diff --git a/tests/integration/test_api_outgoing_connection.py b/tests/integration/test_api_outgoing_connection.py index 374be6ed21..fc60835b42 100644 --- a/tests/integration/test_api_outgoing_connection.py +++ b/tests/integration/test_api_outgoing_connection.py @@ -1,11 +1,13 @@ """Integration tests for the api outgoing_connection option. -The device dials out to the test's listener when no client with a state -subscription is connected. The listener plays the Home Assistant side over the -accepted socket using aioesphomeapi's sans-IO Noise handshake: the device -sends its server hello first so the listener could pick the right key, and the -NNpsk0 handshake then verifies both sides. Protocol roles stay unchanged, so -the client speaks exactly the same frames as over a normal connection. +The device dials out to the test's listener when no dial-back target client is +connected. The listener plays the Home Assistant side over the accepted socket +using aioesphomeapi's sans-IO Noise handshake: the device sends its server +hello first so the listener could pick the right key, and the NNpsk0 handshake +then verifies both sides. Protocol roles stay unchanged, so the client speaks +exactly the same frames as over a normal connection. A client becomes the +remembered dial-back target by setting the outgoing_connection_target flag in +its hello. """ from __future__ import annotations @@ -14,15 +16,18 @@ import asyncio import socket from typing import Any -from aioesphomeapi import APIClient, api_pb2 +from aioesphomeapi import api_pb2 import pytest from .raw_api_client import MESSAGE_TYPE_OF -from .types import APIClientConnectedFactory, RunCompiledFunction +from .types import RunCompiledFunction KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" DEVICE_NAME = "outgoing-conn-test" HA_CLIENT_INFO = "Home Assistant 2026.8.0" +# HelloRequest field 4 (outgoing_connection_target) as raw protobuf bytes; the +# installed aioesphomeapi's api_pb2 predates the field, so append it manually. +HELLO_TARGET_FLAG = b"\x20\x01" @pytest.fixture(autouse=True) @@ -43,76 +48,78 @@ async def _read_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> by ) -async def _serve_home_assistant( - listener: socket.socket, *, subscribe_states: bool = False +def _check_server_hello(server_hello: bytes) -> None: + assert server_hello[0] == 0x01, "Bad chosen proto in server hello" + name, mac, _rest = server_hello[1:].split(b"\x00", 2) + assert name.decode() == DEVICE_NAME + assert len(mac) == 12, f"Expected bare MAC, got {mac!r}" + + +async def _run_ha_session( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + device_dialed_out: bool, ) -> None: - """Accept one dial-in from the device and run the client side over it.""" + """Handshake and exchange the usual first messages as Home Assistant would.""" # Lazy import per the module's own contract (pulls in the noise stack) from aioesphomeapi.noise import NoiseHandshake + if device_dialed_out: + # On an outgoing connection the device announces itself first so the + # peer can pick the matching key before its PSK-mixed first message. + _check_server_hello(await _read_frame(reader)) + + handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00") + writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message())) + await writer.drain() + + if not device_dialed_out: + _check_server_hello(await _read_frame(reader)) + + reply = await _read_frame(reader) + assert reply[0] == 0, f"Handshake rejected: {reply[1:].decode(errors='replace')}" + handshake.read_message(reply[1:]) + encrypt_cipher, decrypt_cipher = handshake.get_ciphers() + + async def transact(msg: Any, response_cls: Any, extra_payload: bytes = b"") -> Any: + msg_type = MESSAGE_TYPE_OF[type(msg)] + payload = msg.SerializeToString() + extra_payload + plaintext = ( + bytes( + (msg_type >> 8, msg_type & 0xFF, len(payload) >> 8, len(payload) & 0xFF) + ) + + payload + ) + writer.write(_frame(encrypt_cipher.encrypt(plaintext))) + await writer.drain() + want = MESSAGE_TYPE_OF[response_cls] + while True: + plain = decrypt_cipher.decrypt(await _read_frame(reader)) + if ((plain[0] << 8) | plain[1]) == want: + response = response_cls() + response.ParseFromString(bytes(plain[4:])) + return response + + # Declare this client a dial-back target in the hello + await transact( + api_pb2.HelloRequest(client_info=HA_CLIENT_INFO), + api_pb2.HelloResponse, + extra_payload=HELLO_TARGET_FLAG, + ) + device_info = await transact( + api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse + ) + assert device_info.name == DEVICE_NAME + + +async def _serve_home_assistant(listener: socket.socket) -> None: + """Accept one dial-in from the device and run the client side over it.""" loop = asyncio.get_running_loop() conn, _ = await asyncio.wait_for(loop.sock_accept(listener), timeout=30) reader, writer = await asyncio.open_connection(sock=conn) try: - # On an outgoing connection the device announces itself first so the - # peer can pick the matching key before its PSK-mixed first message. - server_hello = await _read_frame(reader) - assert server_hello[0] == 0x01, "Bad chosen proto in server hello" - name, mac, _rest = server_hello[1:].split(b"\x00", 2) - assert name.decode() == DEVICE_NAME - assert len(mac) == 12, f"Expected bare MAC, got {mac!r}" - - # Normal NNpsk0 handshake: client hello plus PSK-mixed message one, - # then the device's response completes it and proves the key matches. - handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00") - writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message())) - await writer.drain() - reply = await _read_frame(reader) - assert reply[0] == 0, ( - f"Handshake rejected: {reply[1:].decode(errors='replace')}" - ) - handshake.read_message(reply[1:]) - encrypt_cipher, decrypt_cipher = handshake.get_ciphers() - - async def transact(msg: Any, response_cls: Any | None) -> Any: - msg_type = MESSAGE_TYPE_OF[type(msg)] - payload = msg.SerializeToString() - plaintext = ( - bytes( - ( - msg_type >> 8, - msg_type & 0xFF, - len(payload) >> 8, - len(payload) & 0xFF, - ) - ) - + payload - ) - writer.write(_frame(encrypt_cipher.encrypt(plaintext))) - await writer.drain() - if response_cls is None: - return None - want = MESSAGE_TYPE_OF[response_cls] - while True: - plain = decrypt_cipher.decrypt(await _read_frame(reader)) - if ((plain[0] << 8) | plain[1]) == want: - response = response_cls() - response.ParseFromString(bytes(plain[4:])) - return response - - await transact( - api_pb2.HelloRequest(client_info=HA_CLIENT_INFO), api_pb2.HelloResponse - ) - device_info = await transact( - api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse - ) - assert device_info.name == DEVICE_NAME - - if subscribe_states: - await transact(api_pb2.SubscribeStatesRequest(), None) - # No entities are configured, so there is nothing to wait for; - # give the device a moment to process the subscription. - await asyncio.sleep(0.5) + await _run_ha_session(reader, writer, device_dialed_out=True) finally: writer.close() @@ -132,7 +139,7 @@ async def test_api_outgoing_connection( try: yaml = yaml_config.replace("OUTGOING_PORT", str(port)) async with run_compiled(yaml): - await _serve_home_assistant(listener, subscribe_states=True) + await _serve_home_assistant(listener) finally: listener.close() @@ -141,10 +148,10 @@ async def test_api_outgoing_connection( async def test_api_outgoing_connection_remembered( yaml_config: str, run_compiled: RunCompiledFunction, - api_client_connected: APIClientConnectedFactory, + unused_tcp_port: int, ) -> None: - """No host configured: the device remembers the Home Assistant client that - connected inbound and dials that address after a restart.""" + """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. @@ -154,17 +161,14 @@ async def test_api_outgoing_connection_remembered( try: yaml = yaml_config.replace("OUTGOING_PORT", str(port)) - async with ( - run_compiled(yaml), - api_client_connected(noise_psk=KEY, client_info=HA_CLIENT_INFO) as client, - ): - client: APIClient - device_info = await client.device_info() - assert device_info.name == DEVICE_NAME - # Subscribing to states marks this client as Home Assistant; - # the device persists the peer address for dial-back. - client.subscribe_states(lambda state: None) - await asyncio.sleep(1.0) + async with run_compiled(yaml): + # Connect inbound with the dial-back flag; the device persists the + # peer address during the hello. + reader, writer = await asyncio.open_connection("127.0.0.1", unused_tcp_port) + try: + await _run_ha_session(reader, writer, device_dialed_out=False) + finally: + writer.close() # Restart with the same preferences: the device now dials the # remembered address on its own.