From 6018f5f5d12e5a84f4398143c0070537e3960db9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Sep 2025 04:24:19 -0500 Subject: [PATCH] [api] Add configurable connection limits (#10939) --- esphome/components/api/__init__.py | 29 +++++++++++++++++++++++++++ esphome/components/api/api_server.cpp | 18 ++++++++++++++--- esphome/components/api/api_server.h | 8 +++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6a0e092008..c91051ba20 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -59,6 +59,8 @@ CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" +CONF_LISTEN_BACKLOG = "listen_backlog" +CONF_MAX_CONNECTIONS = "max_connections" def validate_encryption_key(value): @@ -158,6 +160,29 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation( single=True ), + # Connection limits to prevent memory exhaustion on resource-constrained devices + # Each connection uses ~500-1000 bytes of RAM plus system resources + # Platform defaults based on available RAM and network stack implementation: + cv.SplitDefault( + CONF_LISTEN_BACKLOG, + esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets + esp32=4, # More RAM (520KB), BSD sockets + rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + bk72xx=4, # Moderate RAM, BSD-style sockets + rtl87xx=4, # Moderate RAM, BSD-style sockets + host=4, # Abundant resources + ln882x=4, # Moderate RAM + ): cv.int_range(min=1, max=10), + cv.SplitDefault( + CONF_MAX_CONNECTIONS, + esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes + esp32=8, # 520KB RAM available + rp2040=4, # 264KB RAM but LWIP constraints + bk72xx=8, # Moderate RAM + rtl87xx=8, # Moderate RAM + host=8, # Abundant resources + ln882x=8, # Moderate RAM + ): cv.int_range(min=1, max=20), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), @@ -176,6 +201,10 @@ async def to_code(config): cg.add(var.set_password(config[CONF_PASSWORD])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + if CONF_LISTEN_BACKLOG in config: + cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + if CONF_MAX_CONNECTIONS in config: + cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index dd6eb950a6..7fbe0e27f3 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -87,7 +87,7 @@ void APIServer::setup() { return; } - err = this->socket_->listen(4); + err = this->socket_->listen(this->listen_backlog_); if (err != 0) { ESP_LOGW(TAG, "Socket unable to listen: errno %d", errno); this->mark_failed(); @@ -140,9 +140,19 @@ void APIServer::loop() { while (true) { struct sockaddr_storage source_addr; socklen_t addr_len = sizeof(source_addr); + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); if (!sock) break; + + // Check if we're at the connection limit + if (this->clients_.size() >= this->max_connections_) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, sock->getpeername().c_str()); + // Immediately close - socket destructor will handle cleanup + sock.reset(); + continue; + } + ESP_LOGD(TAG, "Accept %s", sock->getpeername().c_str()); auto *conn = new APIConnection(std::move(sock), this); @@ -206,8 +216,10 @@ void APIServer::loop() { void APIServer::dump_config() { ESP_LOGCONFIG(TAG, "Server:\n" - " Address: %s:%u", - network::get_use_address().c_str(), this->port_); + " Address: %s:%u\n" + " Listen backlog: %u\n" + " Max connections: %u", + network::get_use_address().c_str(), this->port_, this->listen_backlog_, this->max_connections_); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); if (!this->noise_ctx_->has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 627870af1d..b9049c1700 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -44,6 +44,8 @@ class APIServer : public Component, public Controller { void set_reboot_timeout(uint32_t reboot_timeout); void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } + void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } + void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections std::vector &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -189,8 +191,12 @@ class APIServer : public Component, public Controller { // Group smaller types together uint16_t port_{6053}; uint16_t batch_delay_{100}; + // Connection limits - these defaults will be overridden by config values + // from cv.SplitDefault in __init__.py which sets platform-specific defaults + uint8_t listen_backlog_{4}; + uint8_t max_connections_{8}; bool shutting_down_ = false; - // 5 bytes used, 3 bytes padding + // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE std::shared_ptr noise_ctx_ = std::make_shared();