mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 19:44:08 +00:00
[socket] Add an IPv4 allow list (#20025)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
co-authored by
J. Nick Koston
parent
a531ea07a2
commit
5def4d46ca
@@ -1,18 +1,22 @@
|
||||
from collections.abc import Callable, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.config_helpers import filter_source_files_from_defines
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
|
||||
socket_ns = cg.esphome_ns.namespace("socket")
|
||||
Ipv4AllowEntry = socket_ns.struct("Ipv4AllowEntry")
|
||||
|
||||
CONF_IMPLEMENTATION = "implementation"
|
||||
IMPLEMENTATION_LWIP_TCP = "lwip_tcp"
|
||||
IMPLEMENTATION_LWIP_SOCKETS = "lwip_sockets"
|
||||
@@ -140,6 +144,37 @@ def require_wake_loop_threadsafe() -> None:
|
||||
cg.add_define("USE_SOCKET_SELECT_SUPPORT")
|
||||
|
||||
|
||||
# For an Ipv4Allow config option; a sanity cap on the list length.
|
||||
IPV4_ALLOW_SCHEMA = cv.All(cv.ensure_list(cv.ipv4network), cv.Length(max=255))
|
||||
|
||||
|
||||
def _network_order(addr: IPv4Address) -> int:
|
||||
"""The s_addr value for addr on the little endian targets."""
|
||||
return int.from_bytes(addr.packed, "little")
|
||||
|
||||
|
||||
def add_ipv4_allow(
|
||||
setter: cg.MockObj, networks: list[IPv4Network], owner_id: ID | str
|
||||
) -> None:
|
||||
"""Emit a flash array for validated IPV4_ALLOW_SCHEMA entries and wire it to setter.
|
||||
|
||||
PROGMEM on esp8266. Emits nothing for an empty list.
|
||||
"""
|
||||
if not networks:
|
||||
return
|
||||
entries = [
|
||||
cg.StructInitializer(
|
||||
Ipv4AllowEntry,
|
||||
("addr", _network_order(net.network_address)),
|
||||
("mask", _network_order(net.netmask)),
|
||||
)
|
||||
for net in networks
|
||||
]
|
||||
arr_id = ID(f"{owner_id}_ipv4_allow", is_declaration=True, type=Ipv4AllowEntry)
|
||||
arr = cg.progmem_array(arr_id, cg.ArrayInitializer(*entries))
|
||||
cg.add(setter(arr, len(entries)))
|
||||
|
||||
|
||||
def require_ipv4_resolve() -> None:
|
||||
"""Compile the shared IPv4 lookup; call from a consumer's to_code."""
|
||||
cg.add_define("USE_SOCKET_IPV4_RESOLVE")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include "headers.h"
|
||||
#include "socket.h"
|
||||
#include "esphome/core/hal.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::socket {
|
||||
|
||||
/// One allowed IPv4 network, network byte order, host bits cleared.
|
||||
/// Lives in flash; read via progmem_memcpy.
|
||||
struct Ipv4AllowEntry {
|
||||
uint32_t addr;
|
||||
uint32_t mask;
|
||||
};
|
||||
|
||||
/// IPv4 peers that may connect. An empty list allows every peer.
|
||||
class Ipv4Allow {
|
||||
public:
|
||||
void set(const Ipv4AllowEntry *entries, size_t count) {
|
||||
this->entries_ = entries;
|
||||
this->count_ = count;
|
||||
}
|
||||
|
||||
/// A v4 mapped IPv6 peer is unwrapped; any other family fails a non empty list.
|
||||
bool allows(const struct sockaddr *peer) const {
|
||||
if (this->count_ == 0) {
|
||||
return true;
|
||||
}
|
||||
uint32_t addr;
|
||||
return sockaddr_to_ipv4(peer, &addr) && this->allows(addr);
|
||||
}
|
||||
|
||||
/// addr is network byte order, as it sits in a sockaddr_in.
|
||||
bool allows(uint32_t addr) const {
|
||||
if (this->count_ == 0) {
|
||||
return true;
|
||||
}
|
||||
for (size_t i = 0; i != this->count_; i++) {
|
||||
Ipv4AllowEntry entry;
|
||||
progmem_memcpy(&entry, &this->entries_[i], sizeof(entry));
|
||||
if ((addr & entry.mask) == entry.addr) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
const Ipv4AllowEntry *entries_{nullptr};
|
||||
size_t count_{0};
|
||||
};
|
||||
|
||||
} // namespace esphome::socket
|
||||
@@ -78,6 +78,25 @@ static inline const char *esphome_inet_ntop6(const void *addr, char *buf, size_t
|
||||
#endif
|
||||
#endif
|
||||
|
||||
bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out) {
|
||||
if (addr->sa_family == AF_INET) {
|
||||
*out = reinterpret_cast<const struct sockaddr_in *>(addr)->sin_addr.s_addr;
|
||||
return true;
|
||||
}
|
||||
#if USE_NETWORK_IPV6
|
||||
if (addr->sa_family == AF_INET6) {
|
||||
// ::ffff:a.b.c.d; s6_addr is the portable byte view on every stack.
|
||||
static constexpr uint8_t V4_MAPPED_PREFIX[12] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF};
|
||||
const uint8_t *bytes = reinterpret_cast<const struct sockaddr_in6 *>(addr)->sin6_addr.s6_addr;
|
||||
if (memcmp(bytes, V4_MAPPED_PREFIX, sizeof(V4_MAPPED_PREFIX)) == 0) {
|
||||
memcpy(out, bytes + sizeof(V4_MAPPED_PREFIX), sizeof(*out));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
|
||||
// Format sockaddr into caller-provided buffer, returns length written (excluding null)
|
||||
size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::span<char, SOCKADDR_STR_LEN> buf) {
|
||||
if (addr_ptr->sa_family == AF_INET && len >= sizeof(const struct sockaddr_in)) {
|
||||
@@ -88,29 +107,10 @@ size_t format_sockaddr_to(const struct sockaddr *addr_ptr, socklen_t len, std::s
|
||||
#if USE_NETWORK_IPV6
|
||||
else if (addr_ptr->sa_family == AF_INET6 && len >= sizeof(sockaddr_in6)) {
|
||||
const auto *addr = reinterpret_cast<const struct sockaddr_in6 *>(addr_ptr);
|
||||
#ifdef USE_HOST
|
||||
// Format IPv4-mapped IPv6 addresses as regular IPv4 (POSIX layout, no LWIP union)
|
||||
if (IN6_IS_ADDR_V4MAPPED(&addr->sin6_addr) &&
|
||||
esphome_inet_ntop4(&addr->sin6_addr.s6_addr[12], buf.data(), buf.size()) != nullptr) {
|
||||
uint32_t v4;
|
||||
// Format a v4 mapped peer as plain IPv4.
|
||||
if (sockaddr_to_ipv4(addr_ptr, &v4) && esphome_inet_ntop4(&v4, buf.data(), buf.size()) != nullptr)
|
||||
return strlen(buf.data());
|
||||
}
|
||||
#elif defined(USE_ZEPHYR)
|
||||
// Format IPv4-mapped IPv6 addresses as regular IPv4. Zephyr uses the standard POSIX
|
||||
// s6_addr layout (not the LWIP union) but provides no IN6_IS_ADDR_V4MAPPED macro, so
|
||||
// detect the ::ffff:0:0/96 prefix directly on the address words.
|
||||
if (addr->sin6_addr.s6_addr32[0] == 0 && addr->sin6_addr.s6_addr32[1] == 0 &&
|
||||
addr->sin6_addr.s6_addr32[2] == htonl(0xFFFF) &&
|
||||
esphome_inet_ntop4(&addr->sin6_addr.s6_addr32[3], buf.data(), buf.size()) != nullptr) {
|
||||
return strlen(buf.data());
|
||||
}
|
||||
#elif !defined(USE_SOCKET_IMPL_LWIP_TCP)
|
||||
// Format IPv4-mapped IPv6 addresses as regular IPv4 (LWIP layout)
|
||||
if (addr->sin6_addr.un.u32_addr[0] == 0 && addr->sin6_addr.un.u32_addr[1] == 0 &&
|
||||
addr->sin6_addr.un.u32_addr[2] == htonl(0xFFFF) &&
|
||||
esphome_inet_ntop4(&addr->sin6_addr.un.u32_addr[3], buf.data(), buf.size()) != nullptr) {
|
||||
return strlen(buf.data());
|
||||
}
|
||||
#endif
|
||||
if (esphome_inet_ntop6(&addr->sin6_addr, buf.data(), buf.size()) != nullptr)
|
||||
return strlen(buf.data());
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ inline std::unique_ptr<ListenSocket> socket_ip_loop_monitored(int type, int prot
|
||||
/// @return Size of the sockaddr structure used, or 0 on error
|
||||
socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const char *ip_address, uint16_t port);
|
||||
|
||||
/// Network order IPv4 of an AF_INET or v4 mapped AF_INET6 peer; false for anything else.
|
||||
/// addr must hold the family's full struct.
|
||||
bool sockaddr_to_ipv4(const struct sockaddr *addr, uint32_t *out);
|
||||
|
||||
/// Convenience overload for std::string (backward compatible).
|
||||
inline socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::string &ip_address, uint16_t port) {
|
||||
return set_sockaddr(addr, addrlen, ip_address.c_str(), port);
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <iterator>
|
||||
|
||||
#include "esphome/components/socket/ipv4_allow.h"
|
||||
#include "esphome/components/socket/socket.h"
|
||||
|
||||
#ifdef USE_HOST
|
||||
|
||||
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");
|
||||
|
||||
// 192.168.175.20/32 and 192.168.175.0/24, network order, host bits cleared,
|
||||
// mirroring what add_ipv4_allow emits.
|
||||
static const Ipv4AllowEntry ENTRIES[] = {
|
||||
{htonl(0xC0A8AF14), htonl(0xFFFFFFFF)},
|
||||
{htonl(0xC0A8AF00), htonl(0xFFFFFF00)},
|
||||
};
|
||||
|
||||
// Runs the peer through the same parser production addresses go through.
|
||||
static bool allows_peer(const Ipv4Allow &list, const char *ip) {
|
||||
struct sockaddr_storage peer {};
|
||||
EXPECT_NE(set_sockaddr(reinterpret_cast<struct sockaddr *>(&peer), sizeof(peer), ip, 0), 0);
|
||||
return list.allows(reinterpret_cast<const struct sockaddr *>(&peer));
|
||||
}
|
||||
|
||||
TEST(Ipv4Allow, EmptyAllowsEveryPeer) {
|
||||
Ipv4Allow list;
|
||||
EXPECT_TRUE(list.allows(htonl(0xC0A8AF01)));
|
||||
EXPECT_TRUE(allows_peer(list, "10.0.0.1"));
|
||||
EXPECT_TRUE(allows_peer(list, "fe80::1"));
|
||||
}
|
||||
|
||||
TEST(Ipv4Allow, MatchesHostAndNetworkEntries) {
|
||||
Ipv4Allow list;
|
||||
list.set(ENTRIES, std::size(ENTRIES));
|
||||
EXPECT_TRUE(list.allows(htonl(0xC0A8AF14)));
|
||||
EXPECT_TRUE(list.allows(htonl(0xC0A8AF01)));
|
||||
EXPECT_TRUE(list.allows(htonl(0xC0A8AFFF)));
|
||||
EXPECT_FALSE(list.allows(htonl(0xC0A8B001)));
|
||||
}
|
||||
|
||||
TEST(Ipv4Allow, ChecksTheV4PeerInsideASockaddr) {
|
||||
Ipv4Allow list;
|
||||
list.set(ENTRIES, std::size(ENTRIES));
|
||||
EXPECT_TRUE(allows_peer(list, "192.168.175.66"));
|
||||
EXPECT_FALSE(allows_peer(list, "10.0.0.1"));
|
||||
}
|
||||
|
||||
TEST(Ipv4Allow, UnwrapsAV4MappedIpv6Peer) {
|
||||
Ipv4Allow list;
|
||||
list.set(ENTRIES, std::size(ENTRIES));
|
||||
EXPECT_TRUE(allows_peer(list, "::ffff:192.168.175.66"));
|
||||
// A native IPv6 peer cannot match an IPv4 list.
|
||||
EXPECT_FALSE(allows_peer(list, "fe80::1"));
|
||||
}
|
||||
|
||||
TEST(Ipv4Allow, InstancesKeepIndependentLists) {
|
||||
// One bridge per allow list; each instance points at its own entries.
|
||||
static const Ipv4AllowEntry OTHER[] = {{htonl(0x0A000000), htonl(0xFF000000)}};
|
||||
Ipv4Allow first;
|
||||
Ipv4Allow second;
|
||||
first.set(ENTRIES, std::size(ENTRIES));
|
||||
second.set(OTHER, std::size(OTHER));
|
||||
EXPECT_TRUE(first.allows(htonl(0xC0A8AF14)));
|
||||
EXPECT_FALSE(second.allows(htonl(0xC0A8AF14)));
|
||||
EXPECT_TRUE(second.allows(htonl(0x0A00002A)));
|
||||
EXPECT_FALSE(first.allows(htonl(0x0A00002A)));
|
||||
}
|
||||
|
||||
} // namespace esphome::socket::testing
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for the socket component's IPv4 allow list codegen helper."""
|
||||
|
||||
from ipaddress import IPv4Address, IPv4Network
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components import socket
|
||||
import esphome.config_validation as cv
|
||||
|
||||
|
||||
def test_network_order_swaps_to_sockaddr_value() -> None:
|
||||
"""The emitted uint32 must equal s_addr on the little endian targets."""
|
||||
assert socket._network_order(IPv4Address("192.168.175.20")) == 0x14AFA8C0
|
||||
assert socket._network_order(IPv4Address("255.255.255.0")) == 0x00FFFFFF
|
||||
assert socket._network_order(IPv4Address("0.0.0.0")) == 0
|
||||
|
||||
|
||||
def test_add_ipv4_allow_emits_nothing_for_an_empty_list() -> None:
|
||||
setter = MagicMock()
|
||||
with patch.object(socket.cg, "add") as add:
|
||||
socket.add_ipv4_allow(setter, [], "bridge")
|
||||
add.assert_not_called()
|
||||
setter.assert_not_called()
|
||||
|
||||
|
||||
def test_add_ipv4_allow_wires_the_setter_with_cleared_host_bits() -> None:
|
||||
setter = MagicMock()
|
||||
networks = [IPv4Network("192.168.175.33/24", strict=False)]
|
||||
with (
|
||||
patch.object(socket.cg, "add") as add,
|
||||
patch.object(socket.cg, "progmem_array") as array,
|
||||
):
|
||||
socket.add_ipv4_allow(setter, networks, "bridge")
|
||||
rendered = str(array.call_args.args[1])
|
||||
assert str(socket._network_order(IPv4Address("192.168.175.0"))) in rendered
|
||||
assert str(socket._network_order(IPv4Address("255.255.255.0"))) in rendered
|
||||
setter.assert_called_once_with(array.return_value, 1)
|
||||
add.assert_called_once()
|
||||
|
||||
|
||||
def test_schema_caps_the_list_length() -> None:
|
||||
"""The sanity cap rejects a list past 255 entries."""
|
||||
assert len(socket.IPV4_ALLOW_SCHEMA([f"10.0.{i}.0/24" for i in range(255)])) == 255
|
||||
with pytest.raises(cv.Invalid):
|
||||
socket.IPV4_ALLOW_SCHEMA([f"10.0.{i}.0/24" for i in range(256)])
|
||||
Reference in New Issue
Block a user