[api] Add handshake timeout to prevent connection slot exhaustion

Add a 15-second timeout for completing the API handshake (Noise
transport + HelloRequest). Previously, a client could connect and
stall mid-handshake, holding a connection slot for up to 150 seconds
(the keepalive disconnect timeout). With max_connections defaulting
to 8 on ESP32, this allowed all slots to be blocked with minimal
effort.

Normal clients complete the full handshake in milliseconds, so 15
seconds is generous. The check short-circuits for authenticated
connections (single bitfield compare) so there is no overhead for
established sessions.
This commit is contained in:
J. Nick Koston
2026-02-18 10:01:25 -06:00
parent f73bcc0e7b
commit 5bc5ec921f

View File

@@ -60,6 +60,11 @@ static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5;
static constexpr uint8_t MAX_PING_RETRIES = 60;
static constexpr uint16_t PING_RETRY_INTERVAL = 1000;
static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2;
// Timeout for completing the handshake (Noise transport + HelloRequest).
// A stalled handshake from a buggy client or network glitch holds a connection
// slot, which can prevent legitimate clients from reconnecting. Also hardens
// against the less likely case of intentional connection slot exhaustion.
static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 15000;
static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION);
@@ -205,7 +210,12 @@ void APIConnection::loop() {
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return;
} else {
this->last_traffic_ = now;
// Only update last_traffic_ after authentication to ensure the
// handshake timeout is an absolute deadline from connection start.
// Pre-auth messages (e.g. PingRequest) must not reset the timer.
if (this->is_authenticated()) {
this->last_traffic_ = now;
}
// read a packet
this->read_message(buffer.data_len, buffer.type, buffer.data);
if (this->flags_.remove)
@@ -223,6 +233,15 @@ void APIConnection::loop() {
this->process_active_iterator_();
}
// Disconnect clients that haven't completed the handshake in time.
// Stale half-open connections from buggy clients or network issues can
// accumulate and block legitimate clients from reconnecting.
if (!this->is_authenticated() && now - this->last_traffic_ > HANDSHAKE_TIMEOUT_MS) {
this->on_fatal_error();
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("handshake timeout; disconnecting"));
return;
}
if (this->flags_.sent_ping) {
// Disconnect if not responded within 2.5*keepalive
if (now - this->last_traffic_ > KEEPALIVE_DISCONNECT_TIMEOUT) {
@@ -1484,6 +1503,8 @@ void APIConnection::complete_authentication_() {
}
this->flags_.connection_state = static_cast<uint8_t>(ConnectionState::AUTHENTICATED);
// Reset traffic timer so keepalive starts from authentication, not connection start
this->last_traffic_ = App.get_loop_component_start_time();
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("connected"));
#ifdef USE_API_CLIENT_CONNECTED_TRIGGER
{