diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index effa7d864fc..09d58d22e9f 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -274,6 +274,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines( "lwip_raw_udp_impl.cpp": "USE_SOCKET_IMPL_LWIP_TCP", "bsd_sockets_impl.cpp": "USE_SOCKET_IMPL_BSD_SOCKETS", "lwip_sockets_impl.cpp": "USE_SOCKET_IMPL_LWIP_SOCKETS", + "ipv4_allow.cpp": "USE_SOCKET_IPV4_ALLOW", "ipv4_resolve.cpp": "USE_SOCKET_IPV4_RESOLVE", "tcp_client_link.cpp": "USE_SOCKET_TCP_CLIENT_LINK", "tcp_listener.cpp": "USE_SOCKET_TCP_LISTENER", diff --git a/esphome/components/socket/ipv4_allow.cpp b/esphome/components/socket/ipv4_allow.cpp new file mode 100644 index 00000000000..4791eeda369 --- /dev/null +++ b/esphome/components/socket/ipv4_allow.cpp @@ -0,0 +1,36 @@ +#include "ipv4_allow.h" + +#ifdef USE_SOCKET_IPV4_ALLOW + +#include "esphome/core/log.h" + +#include + +namespace esphome::socket { + +bool Ipv4Allow::admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now) { + if (this->allows(peer)) { + return true; + } + if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { + this->last_reject_log_ms_ = now; + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(peer, len, std::span(text)); + ESP_LOGW(tag, "Rejected %s", text); + } + return false; +} + +void Ipv4Allow::dump_config(const char *tag) const { + for (size_t i = 0; i < this->count_; i++) { + const Ipv4AllowEntry e = this->entry(i); + // Network order is dotted order, and the contiguous mask's popcount is the prefix. + const auto *b = reinterpret_cast(&e.addr); + ESP_LOGCONFIG(tag, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], + static_cast(__builtin_popcount(e.mask))); + } +} + +} // namespace esphome::socket + +#endif diff --git a/esphome/components/socket/ipv4_allow.h b/esphome/components/socket/ipv4_allow.h index f2f66edc05f..64116691812 100644 --- a/esphome/components/socket/ipv4_allow.h +++ b/esphome/components/socket/ipv4_allow.h @@ -47,6 +47,10 @@ class Ipv4Allow { return false; } + /// allows(peer), logging a refused peer at most once every REJECT_LOG_INTERVAL_MS. + bool admit(const struct sockaddr *peer, socklen_t len, const char *tag, uint32_t now); + /// One config line per allowed network. + void dump_config(const char *tag) const; size_t size() const { return this->count_; } /// A copy of entry i, read from flash. Ipv4AllowEntry entry(size_t i) const { @@ -55,9 +59,12 @@ class Ipv4Allow { return e; } - private: + protected: + static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; + const Ipv4AllowEntry *entries_{nullptr}; size_t count_{0}; + uint32_t last_reject_log_ms_{0}; }; } // namespace esphome::socket diff --git a/esphome/components/socket/tcp_listener.cpp b/esphome/components/socket/tcp_listener.cpp index e1f00e29277..cfd53b9e83f 100644 --- a/esphome/components/socket/tcp_listener.cpp +++ b/esphome/components/socket/tcp_listener.cpp @@ -12,9 +12,6 @@ namespace esphome::socket { // One client at a time; a second connection waits in the stack until the first drops. static constexpr int LISTEN_BACKLOG = 1; -#ifdef USE_SOCKET_IPV4_ALLOW -static constexpr uint32_t REJECT_LOG_INTERVAL_MS = 5000; -#endif void TcpListener::try_listen_(TcpClientLink &link) { this->listen_ = socket_ip_loop_monitored(SOCK_STREAM, IPPROTO_TCP); @@ -57,31 +54,20 @@ void TcpListener::accept_(TcpClientLink &link) { return; } const auto *sa = reinterpret_cast(&peer); - char text[SOCKADDR_STR_LEN]; - format_sockaddr_to(sa, peer_len, std::span(text)); #ifdef USE_SOCKET_IPV4_ALLOW - if (!this->allow_.allows(sa)) { - uint32_t now = App.get_loop_component_start_time(); - if (this->last_reject_log_ms_ == 0 || now - this->last_reject_log_ms_ >= REJECT_LOG_INTERVAL_MS) { - this->last_reject_log_ms_ = now; - ESP_LOGW(this->tag_, "Rejected %s", text); - } + if (!this->allow_.admit(sa, peer_len, this->tag_, App.get_loop_component_start_time())) { return; } #endif + char text[SOCKADDR_STR_LEN]; + format_sockaddr_to(sa, peer_len, std::span(text)); link.adopt(std::move(client)); ESP_LOGI(this->tag_, "Client connected from %s", text); } void TcpListener::dump_config() const { #ifdef USE_SOCKET_IPV4_ALLOW - for (size_t i = 0; i < this->allow_.size(); i++) { - Ipv4AllowEntry e = this->allow_.entry(i); - // Network order is dotted order, and the contiguous mask's popcount is the prefix. - const auto *b = reinterpret_cast(&e.addr); - ESP_LOGCONFIG(this->tag_, " Allowed IP: %u.%u.%u.%u/%u", b[0], b[1], b[2], b[3], - static_cast(__builtin_popcount(e.mask))); - } + this->allow_.dump_config(this->tag_); #endif } diff --git a/esphome/components/socket/tcp_listener.h b/esphome/components/socket/tcp_listener.h index a42080e641f..6448a969bdd 100644 --- a/esphome/components/socket/tcp_listener.h +++ b/esphome/components/socket/tcp_listener.h @@ -50,7 +50,6 @@ class TcpListener { std::unique_ptr listen_; const char *tag_{nullptr}; #ifdef USE_SOCKET_IPV4_ALLOW - uint32_t last_reject_log_ms_{0}; Ipv4Allow allow_; #endif }; diff --git a/tests/components/socket/test_ipv4_allow_host.cpp b/tests/components/socket/test_ipv4_allow_host.cpp index a8924de3713..ab89a59b78a 100644 --- a/tests/components/socket/test_ipv4_allow_host.cpp +++ b/tests/components/socket/test_ipv4_allow_host.cpp @@ -9,8 +9,8 @@ namespace esphome::socket::testing { -// The size_t count packs into the pointer's padding; no RAM over a uint8_t. -static_assert(sizeof(Ipv4Allow) == 2 * sizeof(void *), "unexpected padding in Ipv4Allow"); +// Entries pointer, count and the reject-log timestamp; nothing else may creep in. +static_assert(sizeof(Ipv4Allow) <= 3 * sizeof(void *), "unexpected padding in Ipv4Allow"); // 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared, // mirroring what add_ipv4_allow emits. @@ -26,6 +26,42 @@ static bool allows_peer(const Ipv4Allow &list, const char *ip) { return list.allows(reinterpret_cast(&peer)); } +// Exposes the reject-log timestamp, the only trace the throttle leaves besides the log line. +struct ThrottledAllow : public Ipv4Allow { + using Ipv4Allow::last_reject_log_ms_; +}; + +// admit() with the same production parser. +static bool admits_peer(Ipv4Allow &list, const char *ip, uint32_t now) { + struct sockaddr_storage peer {}; + socklen_t len = set_sockaddr(reinterpret_cast(&peer), sizeof(peer), ip, 0); + EXPECT_NE(len, 0); + return list.admit(reinterpret_cast(&peer), len, "test", now); +} + +TEST(Ipv4Allow, AdmitLogsARejectAtMostOncePerInterval) { + ThrottledAllow list; + list.set(ENTRIES, std::size(ENTRIES)); + EXPECT_TRUE(admits_peer(list, "192.168.175.66", 1000)); + EXPECT_TRUE(admits_peer(list, "::ffff:192.168.175.20", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 0u); + + EXPECT_FALSE(admits_peer(list, "10.0.0.1", 1000)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Inside the interval the peer is still refused, but the log line is skipped. + EXPECT_FALSE(admits_peer(list, "10.0.0.2", 1001)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + EXPECT_FALSE(admits_peer(list, "10.0.0.3", 5999)); + EXPECT_EQ(list.last_reject_log_ms_, 1000u); + // Exactly one interval later it logs again. + EXPECT_FALSE(admits_peer(list, "10.0.0.4", 6000)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + // An admitted peer leaves the throttle alone. + EXPECT_TRUE(admits_peer(list, "192.168.175.1", 6001)); + EXPECT_EQ(list.last_reject_log_ms_, 6000u); + list.dump_config("test"); +} + TEST(Ipv4Allow, EmptyAllowsEveryPeer) { Ipv4Allow list; EXPECT_TRUE(list.allows(htonl(0xC0A8AF01)));