]> git.ipfire.org Git - thirdparty/kea.git/commitdiff
[#4190] Support CIDRs in leasequery requesters
authorThomas Markwalder <tmark@isc.org>
Thu, 30 Oct 2025 14:34:28 +0000 (10:34 -0400)
committerThomas Markwalder <tmark@isc.org>
Fri, 7 Nov 2025 17:32:36 +0000 (17:32 +0000)
modified:   doc/sphinx/arm/hooks-lease-query.rst
modified:   src/hooks/dhcp/lease_query/lease_query_impl.cc
modified:   src/hooks/dhcp/lease_query/lease_query_impl.h
modified:   src/hooks/dhcp/lease_query/tests/bulk_lease_query4_unittest.cc
modified:   src/hooks/dhcp/lease_query/tests/bulk_lease_query6_unittest.cc
modified:   src/hooks/dhcp/lease_query/tests/lease_query_impl4_unittest.cc
modified:   src/hooks/dhcp/lease_query/tests/lease_query_impl6_unittest.cc

doc/sphinx/arm/hooks-lease-query.rst
src/hooks/dhcp/lease_query/lease_query_impl.cc
src/hooks/dhcp/lease_query/lease_query_impl.h
src/hooks/dhcp/lease_query/tests/bulk_lease_query4_unittest.cc
src/hooks/dhcp/lease_query/tests/bulk_lease_query6_unittest.cc
src/hooks/dhcp/lease_query/tests/lease_query_impl4_unittest.cc
src/hooks/dhcp/lease_query/tests/lease_query_impl6_unittest.cc

index a080ed3a8976c4c3189e71424daadf60c3ff32c9..87d578914b0a5e6c390a6065234108c055fce726 100644 (file)
@@ -153,8 +153,9 @@ addresses:
 
 .. note::
 
-    For security purposes, there is no way to specify wildcards. Each requester address
-    must be explicitly listed.
+    As of Kea 3.1.4, it is also possible to include ranges of requester addresses in CIDR
+    format such as "192.0.1.0/24" or "2001:db8:1::/64". Please note that specifying ranges
+    may be less secure than only using explicit IP addresses.
 
 .. _lease-query-dhcpv6:
 
index a14fc9a1d7e982bf938155c77da8ad1a320738f8..1fa72b3e17cd1425f70262feb2e26e5507c01ce5 100644 (file)
@@ -37,6 +37,38 @@ AddressList::contains(const IOAddress& address) const {
     return (addresses_.count(address));
 }
 
+void
+PoolSet::insert(const isc::asiolink::IOAddress& prefix, uint8_t prefix_len) {
+    PoolPtr pool;
+    if (getFamily() == AF_INET) {
+        pool = Pool4::create(prefix, prefix_len);
+    } else {
+        pool = Pool6::create(Lease::TYPE_NA, prefix, prefix_len);
+    }
+
+    if (pools_.find(pool) != pools_.end()) {
+        isc_throw(BadValue, "entry already exists");
+    }
+
+    pools_.emplace(pool);
+}
+
+bool
+PoolSet::contains(const IOAddress& address) const {
+    if (address.getFamily() != family_) {
+        isc_throw(BadValue, "not a " << (family_ == AF_INET ? "IPv4" : "IPv6")
+                   << " address");
+    }
+
+    for (auto pool : pools_) {
+        if (pool->inRange(address)) {
+            return (true);
+        }
+    }
+
+    return (false);
+}
+
 const SimpleKeywords
 LeaseQueryImpl::LEASE_QUERY_KEYWORDS =
 {
@@ -48,46 +80,82 @@ LeaseQueryImpl::LEASE_QUERY_KEYWORDS =
 
 LeaseQueryImpl::LeaseQueryImpl(uint16_t family,
                                const ConstElementPtr config)
-    : io_service_(new IOService()), address_list_(family) {
+    : io_service_(new IOService()), address_list_(family), pool_set_(family) {
 
     if (!config || (config->getType() != Element::map)) {
         isc_throw(BadValue, "Lease Query config is empty or not a map");
     }
 
-    ConstElementPtr requesters = config->get("requesters");
+    parserRequesters(config-get("requesters"));
+
+    ConstElementPtr advanced = config->get("advanced");
+    if (advanced) {
+        BulkLeaseQueryService::create(this, advanced);
+    }
+}
+
+LeaseQueryImpl::~LeaseQueryImpl() {
+    io_service_->stopAndPoll();
+}
+
+void
+LeaseQueryImpl::parserRequesters(ConstElementPtr requesters) {
     if (!requesters || (requesters->getType() != Element::list)) {
         isc_throw(BadValue,
                   "'requesters' address list is missing or not a list");
     }
 
     for (auto const& address_elem : requesters->listValue()) {
-        try {
-            IOAddress address(address_elem->stringValue());
-            address_list_.insert(address);
-        } catch (const std::exception& ex) {
-            isc_throw(BadValue,
-                      "'requesters' entry '" << address_elem->stringValue()
+        auto entry_txt = address_elem->stringValue();
+        // first let's remove any whitespaces
+        boost::erase_all(entry_txt, " "); // space
+        boost::erase_all(entry_txt, "\t"); // tabulation
+
+        // Is this just an address or is it CIDR?
+        size_t pos = entry_txt.find("/");
+        if (pos == std::string::npos) {
+            try {
+                IOAddress address(entry_txt);
+                address_list_.insert(address);
+            } catch (const std::exception& ex) {
+                isc_throw(BadValue,
+                      "'requesters' address entry '" << address_elem->stringValue()
                       << "' is invalid: " << ex.what());
+            }
+        } else {
+            try {
+                IOAddress prefix = IOAddress(entry_txt.substr(0, pos));
+
+                // start with the first character after /
+                auto len_txt = entry_txt.substr(pos + 1);
+                int prefix_len = boost::lexical_cast<int>(len_txt);
+                if ((prefix_len < std::numeric_limits<uint8_t>::min()) ||
+                    (prefix_len > std::numeric_limits<uint8_t>::max())) {
+                    // This exception will be handled 4 line later!
+                    isc_throw(OutOfRange, "prefix length " << len_txt << " is out of range");
+                }
+
+                pool_set_.insert(prefix, prefix_len);
+            } catch (const std::exception& ex) {
+                isc_throw(BadValue,
+                      "'requesters' CIDR entry '" << entry_txt
+                      << "' is invalid: " << ex.what());
+            }
         }
     }
 
-    if (address_list_.size() == 0) {
-        isc_throw(BadValue, "'requesters' address list cannot be empty");
+    if (address_list_.size() == 0 && pool_set_.size() == 0) {
+        isc_throw(BadValue, "'requesters' list cannot be empty");
     }
-
-    ConstElementPtr advanced = config->get("advanced");
-    if (advanced) {
-        BulkLeaseQueryService::create(this, advanced);
-    }
-}
-
-LeaseQueryImpl::~LeaseQueryImpl() {
-    io_service_->stopAndPoll();
 }
 
 bool
 LeaseQueryImpl::isRequester(const IOAddress& address) const {
-    return (address_list_.contains(address));
+    if (address_list_.contains(address)) {
+        return (true);
+    }
+
+    return (pool_set_.contains(address));
 }
 
 bool
index f7a0b7374d695d413243216e988e47a2b585bd32..79c04a376b2a61dde350de6d60d9f696fa1ff026 100644 (file)
@@ -11,6 +11,7 @@
 #include <asiolink/io_address.h>
 #include <asiolink/io_service.h>
 #include <dhcp/pkt.h>
+#include <dhcpsrv/pool.h>
 #include <cc/data.h>
 #include <cc/simple_parser.h>
 
@@ -77,6 +78,81 @@ private:
     std::unordered_set<asiolink::IOAddress, boost::hash<asiolink::IOAddress> > addresses_;
 };
 
+/// @brief Hash for a Pool based on it's address range.
+struct PoolRangeHash {
+    std::size_t operator()(const isc::dhcp::PoolPtr& p) const noexcept {
+        const auto& f = p->getFirstAddress();
+        const auto& l = p->getLastAddress();
+
+        isc::asiolink::IOAddress::Hash haddr;
+        std::size_t h1 = haddr(f);
+        std::size_t h2 = haddr(l);
+
+        // hash_combine
+        return h1 ^ (h2 + 0x9e3779b97f4a7c15ULL + (h1 << 6) + (h1 >> 2));
+    }
+};
+
+/// @brief Equality comparator for two pools based on their address range.
+struct PoolRangeEqual {
+    bool operator()(const isc::dhcp::PoolPtr& a,
+                    const isc::dhcp::PoolPtr& b) const noexcept {
+        return a->getFirstAddress() == b->getFirstAddress() &&
+               a->getLastAddress()  == b->getLastAddress();
+    }
+};
+
+/// @brief Defines an alias for a set of pools hashed by range.
+using PoolRangeSet = std::unordered_set<isc::dhcp::PoolPtr, PoolRangeHash, PoolRangeEqual>;
+
+/// @brief Manages a unique set of Pools of a given protocol family.
+/// The pools are hashed by their address range.
+class PoolSet {
+public:
+    /// @brief Constructor
+    ///
+    /// @param family protocol family of the set (AF_INET or AF_INET6)
+    PoolSet(uint16_t family)
+    : family_(family) { };
+
+    /// @brief Inserts an pool into the set.
+    ///
+    /// Creates a pool and adds it to the set, assuming it is not
+    /// already in the set.
+    ///
+    /// @param prefix prefix of the pool
+    /// @param prefix_len length of the pool prefix
+    /// @throw BadValue if the prefix family does not match
+    /// the set's family, prefix length is invalid, or the pool is
+    /// already in the set.
+    void insert(const isc::asiolink::IOAddress& prefix, uint8_t prefix_len);
+
+    /// @brief Checks if an address is present in the set.
+    ///
+    /// @param address address to look for.
+    /// @return true if the address is within a pool in the pool set
+    /// @throw BadValue if the address's family does not match
+    /// the set's family.
+    bool contains(const isc::asiolink::IOAddress& address) const;
+
+    /// @brief Returns the number of pools in the set.
+    size_t size() const {
+        return (pools_.size());
+    }
+
+    /// @brief Returns the protocol family of the address set.
+    uint16_t getFamily() const {
+        return (family_);
+    }
+
+private:
+    /// @brief protocol family of the set (AF_INET or AF_INET6)
+    uint16_t family_;
+
+    /// @brief Unique set of pools.
+    PoolRangeSet pools_;
+};
+
 /// @brief Provides configuration and control flow for processing queries.
 class LeaseQueryImpl : public boost::noncopyable {
 public:
@@ -101,6 +177,11 @@ public:
         return (address_list_.size());
     }
 
+    /// @brief Returns the number of valid requester pools.
+    size_t getNumRequesterPools() const {
+        return (pool_set_.size());
+    }
+
     /// @brief Processes a single client Lease Query
     ///
     /// - Validates query content
@@ -145,12 +226,23 @@ public:
     static size_t PageSize;
 
 private:
+    /// @brief Parses 'requesters' list element.
+    ///
+    /// @param requesters pointer to the list element containing requestor
+    /// entris. Entries may be a mix of IP addresses or subnets in CIDR format.
+    ///
+    /// @throw BadValue if the list is empty or if any of the entries are
+    /// not valid addresses or  CIDRs.
+    void parserRequesters(isc::data::ConstElementPtr requesters);
 
     /// @brief The I/O context.
     isc::asiolink::IOServicePtr io_service_;
 
     /// @param list of addresses from which queries can be accepted.
     AddressList address_list_;
+
+    /// @param set of valid requester pools.
+    PoolSet pool_set_;
 };
 
 /// @brief Defines a smart pointer to LeaseQueryImpl instance.
index 81c9b6108b3e42f01fefc506234327f62e4c71ce..e4c97bcba066ce04697223dcb98223ba0b336f69 100644 (file)
@@ -757,7 +757,7 @@ class MemfileBulkLeaseQuery4ProcessTest : public
 TEST_F(MemfileBulkLeaseQuery4ProcessTest, validConfig4) {
     // Create an implementation with two requesters.
     const string json = "{\n"
-        " \"requesters\" : [ \"127.0.0.1\", \"192.0.2.2\" ],\n"
+        " \"requesters\" : [ \"127.0.0.1\", \"192.0.2.2\" , \"192.0.3.0/24\" ],\n"
         " \"advanced\": {\n"
         "  \"bulk-query-enabled\": true,\n"
         "  \"active-query-enabled\": false,\n"
@@ -783,6 +783,7 @@ TEST_F(MemfileBulkLeaseQuery4ProcessTest, validConfig4) {
     EXPECT_FALSE(impl->isRequester(IOAddress("192.0.2.1")));
     EXPECT_TRUE(impl->isRequester(IOAddress("127.0.0.1")));
     EXPECT_TRUE(impl->isRequester(IOAddress("192.0.2.2")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.3.17")));
 
     // Make sure a test with a v6 address complains.
     ASSERT_THROW_MSG(impl->isRequester(IOAddress("2001:db8:1::1")), BadValue,
index b90694230907c1f17e39129c5c858d90d54229b1..33d4365994ff8a0d07f6cce899b799213412e718 100644 (file)
@@ -1062,7 +1062,7 @@ class MemfileBulkLeaseQuery6ProcessTest : public
 TEST_F(MemfileBulkLeaseQuery6ProcessTest, validConfig6) {
     // Create an implementation with two requesters.
     const string json = "{\n"
-        " \"requesters\" : [ \"2001:db8:1::1\", \"2001:db8:1::3\" ],\n"
+        " \"requesters\" : [ \"2001:db8:1::1\", \"2001:db8:1::3\", \"3001::/64\" ],\n"
         " \"prefix-lengths\": [ 72, 64 ],\n"
         " \"advanced\": {\n"
         "  \"bulk-query-enabled\": true,\n"
@@ -1090,6 +1090,7 @@ TEST_F(MemfileBulkLeaseQuery6ProcessTest, validConfig6) {
     EXPECT_TRUE(impl->isRequester(IOAddress("2001:db8:1::1")));
     EXPECT_FALSE(impl->isRequester(IOAddress("2001:db8:1::2")));
     EXPECT_TRUE(impl->isRequester(IOAddress("2001:db8:1::3")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("3001::1")));
 
     // Make sure a test with a v4 address complains.
     ASSERT_THROW_MSG(impl->isRequester(IOAddress("192.0.2.1")), BadValue,
index 30e09802f515826ee1f54d8a058bdc2eaf3e959c..141c807d20bacfa660f6b4a722f2811da1f72b7d 100644 (file)
@@ -575,23 +575,40 @@ TEST(LeaseQueryImpl4Test, invalidConfig4) {
         {
             "requesters list is empty",
             Element::fromJSON("{ \"requesters\" : [] }"),
-            "'requesters' address list cannot be empty"
+            "'requesters' list cannot be empty"
         },
         {
             "requesters entry not an address",
             Element::fromJSON("{ \"requesters\" : [ \"foo\" ] }"),
-            "'requesters' entry 'foo' is invalid: Failed to convert"
+            "'requesters' address entry 'foo' is invalid: Failed to convert"
             " string to address 'foo': Invalid argument"
         },
         {
             "requesters entry not a v4 address",
             Element::fromJSON("{ \"requesters\" : [ \"2001:db8:1::\" ] }"),
-            "'requesters' entry '2001:db8:1::' is invalid: not a IPv4 address"
+            "'requesters' address entry '2001:db8:1::' is invalid: not a IPv4 address"
         },
         {
             "requesters entry is a duplicate",
             Element::fromJSON("{ \"requesters\" : [ \"192.0.2.1\", \"192.0.2.1\" ] }"),
-            "'requesters' entry '192.0.2.1' is invalid: address is already in the list"
+            "'requesters' address entry '192.0.2.1' is invalid: address is already in the list"
+        },
+        {
+            "requesters CIDR entry address is a invalid",
+            Element::fromJSON("{ \"requesters\" : [ \"192.0.2.x/24\" ] }"),
+            "'requesters' CIDR entry '192.0.2.x/24' is invalid:"
+            " Failed to convert string to address '192.0.2.x': Invalid argument"
+        },
+        {
+            "requesters CIDR length is a invalid",
+            Element::fromJSON("{ \"requesters\" : [ \"192.0.2.1/777\" ] }"),
+            "'requesters' CIDR entry '192.0.2.1/777' is invalid:"
+            " prefix length 777 is out of range"
+        },
+        {
+            "requesters CIDR entry is a duplicate",
+            Element::fromJSON("{ \"requesters\" : [ \"192.0.2.0/24\", \"192.0.2.0/24\" ] }"),
+            "'requesters' CIDR entry '192.0.2.0/24' is invalid: entry already exists"
         }
     };
 
@@ -625,6 +642,45 @@ TEST(LeaseQueryImpl4Test, validConfig4) {
                      "not a IPv4 address");
 }
 
+// Verifies that valid v4 configuration using only CIDR entries
+// parses and that requesters can be validated.
+TEST(LeaseQueryImpl4Test, validConfig4CIDROnly) {
+    // Create an implementation with two requesters.
+    const std::string json = "{ \"requesters\" : [ \"192.0.2.0/24\", \"192.0.3.0/24\" ] }";
+    ConstElementPtr config;
+    ASSERT_NO_THROW_LOG(config = Element::fromJSON(json));
+
+    LeaseQueryImpl4Ptr impl;
+    ASSERT_NO_THROW_LOG(impl.reset(new LeaseQueryImpl4(config)));
+
+    // Verify known and unknown requesters check correctly.
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.2.0")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.2.10")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.2.255")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.3.80")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.3.255")));
+    EXPECT_FALSE(impl->isRequester(IOAddress("192.0.4.255")));
+}
+
+// Verifies that valid v4 configuration both address and CIDR entries
+// parses and that requesters can be validated.
+TEST(LeaseQueryImpl4Test, validConfig4Mix) {
+    // Create an implementation with two requesters.
+    const std::string json = "{ \"requesters\" : [ \"192.0.2.0/24\", \"192.0.3.25\" ] }";
+    ConstElementPtr config;
+    ASSERT_NO_THROW_LOG(config = Element::fromJSON(json));
+
+    LeaseQueryImpl4Ptr impl;
+    ASSERT_NO_THROW_LOG(impl.reset(new LeaseQueryImpl4(config)));
+
+    // Verify known and unknown requesters check correctly.
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.2.0")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.2.10")));
+    EXPECT_TRUE(impl->isRequester(IOAddress("192.0.3.25")));
+    EXPECT_FALSE(impl->isRequester(IOAddress("192.0.3.255")));
+    EXPECT_FALSE(impl->isRequester(IOAddress("192.0.4.255")));
+}
+
 // Verifies the invalid combinations of query parameters (ciaddr, HWAddr,
 // and client id) are detected.
 TEST(LeaseQueryImpl4Test, processQueryInvalidQuery) {
index 1f7ffb0de39ce625f02c0f837018c05179e19161..42809ec6e67e1908f702d79ebf04460844203b3f 100644 (file)
@@ -585,23 +585,41 @@ TEST(LeaseQueryImpl6Test, invalidConfig6) {
         {
             "requesters list is empty",
             Element::fromJSON("{ \"requesters\" : [] }"),
-            "'requesters' address list cannot be empty"
+            "'requesters' list cannot be empty"
         },
         {
             "requesters entry not an address",
             Element::fromJSON("{ \"requesters\" : [ \"foo\" ] }"),
-            "'requesters' entry 'foo' is invalid: Failed to convert"
-            " string to address 'foo': Invalid argument"
+            "'requesters' address entry 'foo' is invalid: Failed to convert string"
+            " to address 'foo': Invalid argument"
         },
         {
             "requesters entry not a v6 address",
             Element::fromJSON("{ \"requesters\" : [ \"192.0.2.1\" ] }"),
-            "'requesters' entry '192.0.2.1' is invalid: not a IPv6 address"
+            "'requesters' address entry '192.0.2.1' is invalid: not a IPv6 address"
         },
         {
             "requesters entry is a duplicate",
             Element::fromJSON("{ \"requesters\" : [ \"2001:db8:1::\", \"2001:db8:1::\" ] }"),
-            "'requesters' entry '2001:db8:1::' is invalid: address is already in the list"
+            "'requesters' address entry '2001:db8:1::' is invalid: address is already in the list"
+        },
+        {
+            "requesters CIDR entry address is invalid",
+            Element::fromJSON("{ \"requesters\" : [ \"2001:db8:1::x/64\" ] }"),
+            "'requesters' CIDR entry '2001:db8:1::x/64' is invalid:"
+            " Failed to convert string to address '2001:db8:1::x': Invalid argument"
+
+        },
+        {
+            "requesters CIDR entry length is invalid",
+            Element::fromJSON("{ \"requesters\" : [ \"2001:db8:1::/777\" ] }"),
+            "'requesters' CIDR entry '2001:db8:1::/777' is invalid:"
+            " prefix length 777 is out of range"
+        },
+        {
+            "requesters CIDR is a duplicate",
+            Element::fromJSON("{ \"requesters\" : [ \"2001:db8:1::/64\", \"2001:db8:1::/64\"] }"),
+            "'requesters' CIDR entry '2001:db8:1::/64' is invalid: entry already exists"
         },
         {
             "prefix_lengths not a list",