mirror of
https://github.com/esphome/esphome.git
synced 2026-09-10 06:48:45 +00:00
Select the dial-back target with an explicit hello flag instead of guessing from client info
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<socket::Socket> 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};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<socket::Socket> 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
|
||||
|
||||
|
||||
@@ -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<socket::Socket> 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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user