]> git.ipfire.org Git - thirdparty/kea.git/commitdiff
[#3583] kea-dhcp6 now supports option class tagging
authorThomas Markwalder <tmark@isc.org>
Fri, 27 Sep 2024 19:48:05 +0000 (15:48 -0400)
committerThomas Markwalder <tmark@isc.org>
Tue, 15 Oct 2024 17:51:57 +0000 (13:51 -0400)
/src/bin/dhcp4/tests/classify_unittest.cc
    Clean up of new tests.

/src/bin/dhcp6/dhcp6_srv.cc
    Dhcpv6Srv::appendRequestedOptions()
    Dhcpv6Srv::appendRequestedVendorOptions()
    - check OptionDescriptor::allowedForClientClasses()

/src/bin/dhcp6/tests/classify_unittests.cc
    TEST_F(ClassifyTest, requestedOptionClassTag)
    TEST_F(ClassifyTest, vendorClassOptionClassTag)
    TEST_F(ClassifyTest, persistedVendorOptsOptionClassTag)
    TEST_F(ClassifyTest, requestedVendorOptionsClassTag)

src/bin/dhcp4/tests/classify_unittest.cc
src/bin/dhcp6/dhcp6_srv.cc
src/bin/dhcp6/tests/classify_unittest.cc

index 4c8d7c5c6a0e4ce8dfb0796a8edee0d03d885805..86007cc7cee254223c09e311e641b85f921c9639 100644 (file)
@@ -1782,7 +1782,7 @@ TEST_F(ClassifyTest, vendorOptionClassTagTest) {
     // Configure DHCP server.
     configure(config, srv);
 
-    // Create packets with enough to select the subnet
+    // Create a DISCOVER that matches class "melon" but not "ball".
     auto id = ClientId::fromText("31:31:31");
     OptionPtr clientid = (OptionPtr(new Option(Option::V4,
                                                DHO_DHCP_CLIENT_IDENTIFIER,
@@ -1794,27 +1794,26 @@ TEST_F(ClassifyTest, vendorOptionClassTagTest) {
     query1->setIface("eth1");
     query1->setIndex(ETH1_INDEX);
 
-    // Let's add a vendor-option (vendor-id=4491).
+    // Add a vendor-option (vendor-id=4491).
     OptionPtr vendor(new OptionVendor(Option::V4, 4491));
     query1->addOption(vendor);
 
-    // Let's add a vendor-option (vendor-id=4491) with three sub-options.
+    // Add the ORO sub-option requesting all three sub-options.
     boost::shared_ptr<OptionUint8Array> vendor_oro(new OptionUint8Array(Option::V4,
                                                                         DOCSIS3_V4_ORO));
-    // Request the suboptions.
     vendor_oro->addValue(101); 
     vendor_oro->addValue(102);
     vendor_oro->addValue(103);
     vendor->addOption(vendor_oro);
 
-    // Classify packets
+    // Classify query.
     srv.classifyPacket(query1);
 
-    // Verify class membership is as expected.
+    // Verify it belongs to "melon" but not "ball".
     EXPECT_TRUE(query1->inClass("melon"));
     EXPECT_FALSE(query1->inClass("ball"));
 
-    // Process query
+    // Process the query
     Pkt4Ptr response1 = srv.processDiscover(query1);
 
     // Check if there is a vendor option response
@@ -1826,14 +1825,14 @@ TEST_F(ClassifyTest, vendorOptionClassTagTest) {
         boost::dynamic_pointer_cast<OptionVendor>(tmp);
     ASSERT_TRUE(vendor_resp);
 
-    // Should have options 1 and 3.
+    // Should have options 1 and 3, but not 2.
     EXPECT_EQ(2, vendor_resp->getOptions().size());
     EXPECT_TRUE(vendor_resp->getOption(101));
     EXPECT_FALSE(vendor_resp->getOption(102));
     EXPECT_TRUE(vendor_resp->getOption(103));
 }
 
-// Verifies that requested VIVCO suboption can be gated by 
+// Verifies that requested VIVCO suboption can be gated by 
 // option class tagging.
 TEST_F(ClassifyTest, vivcoOptionClassTagTest) {
     IfaceMgrTestConfig test_config(true);
@@ -1871,7 +1870,7 @@ TEST_F(ClassifyTest, vivcoOptionClassTagTest) {
     // Configure DHCP server.
     configure(config, srv);
 
-    // Create packet with enough to select the subnet
+    // Create a DISCOVER that matches class "melon".
     auto id = ClientId::fromText("31:31:31");
     OptionPtr clientid = (OptionPtr(new Option(Option::V4,
                                                DHO_DHCP_CLIENT_IDENTIFIER,
@@ -1883,7 +1882,7 @@ TEST_F(ClassifyTest, vivcoOptionClassTagTest) {
     query1->setIface("eth1");
     query1->setIndex(ETH1_INDEX);
 
-    // Create and add a PRL option.
+    // Add a PRL option requesting the VIVCO suboption.
     OptionUint8ArrayPtr prl(new OptionUint8Array(Option::V4,
                                                  DHO_DHCP_PARAMETER_REQUEST_LIST));
     prl->addValue(DHO_VIVCO_SUBOPTIONS);
@@ -1902,7 +1901,7 @@ TEST_F(ClassifyTest, vivcoOptionClassTagTest) {
     OptionPtr tmp = response1->getOption(DHO_VIVCO_SUBOPTIONS);
     EXPECT_TRUE(tmp);
 
-    // Try again with a different client id.
+    // Try again with a client id that does not match "melon".
     id = ClientId::fromText("31:31:32");
     clientid = (OptionPtr(new Option(Option::V4, DHO_DHCP_CLIENT_IDENTIFIER,
                                                  id->getClientId())));
@@ -1965,7 +1964,7 @@ TEST_F(ClassifyTest, vivsoOptionClassTagTest) {
     // Configure DHCP server.
     configure(config, srv);
 
-    // Create packet with enough to select the subnet
+    // Create a DISCOVER that matches class "melon".
     auto id = ClientId::fromText("31:31:31");
     OptionPtr clientid = (OptionPtr(new Option(Option::V4,
                                                DHO_DHCP_CLIENT_IDENTIFIER,
@@ -1977,7 +1976,7 @@ TEST_F(ClassifyTest, vivsoOptionClassTagTest) {
     query1->setIface("eth1");
     query1->setIndex(ETH1_INDEX);
 
-    // Create and add a PRL option.
+    // Add a PRL option requesting the VIVSO sub-option.
     OptionUint8ArrayPtr prl(new OptionUint8Array(Option::V4,
                                                  DHO_DHCP_PARAMETER_REQUEST_LIST));
     prl->addValue(DHO_VIVSO_SUBOPTIONS);
@@ -1992,11 +1991,11 @@ TEST_F(ClassifyTest, vivsoOptionClassTagTest) {
     // Process query
     Pkt4Ptr response1 = srv.processDiscover(query1);
 
-    // Check if there is a vendor option response
+    // Verify the reponse contains the VIVSO sub-option
     OptionPtr tmp = response1->getOption(DHO_VIVSO_SUBOPTIONS);
     EXPECT_TRUE(tmp);
 
-    // Try again with a different client id.
+    // Try again with a client id that does not match "melon".
     id = ClientId::fromText("31:31:32");
     clientid = (OptionPtr(new Option(Option::V4, DHO_DHCP_CLIENT_IDENTIFIER,
                                                  id->getClientId())));
@@ -2016,13 +2015,13 @@ TEST_F(ClassifyTest, vivsoOptionClassTagTest) {
     // Process query
     response1 = srv.processDiscover(query1);
 
-    // VIVCO suboption should not be present.
+    // VIVSO suboption should not be present.
     tmp = response1->getOption(DHO_VIVSO_SUBOPTIONS);
     ASSERT_FALSE(tmp);
 }
 
-// Verifies that "basic" options can be gated by 
-// option class tagging.
+// Verifies that the "basic" options (routers, domain-name, domain-name-servers,
+// and dhcp-server-id) can be gated by option class tagging.
 TEST_F(ClassifyTest, basicOptionClassTagTest) {
     IfaceMgrTestConfig test_config(true);
     IfaceMgr::instance().openSockets4();
@@ -2074,7 +2073,7 @@ TEST_F(ClassifyTest, basicOptionClassTagTest) {
     // Configure DHCP server.
     configure(config, srv);
 
-    // Create packet with enough to select the subnet
+    // Create a DISCOVER that matches class "melon".
     auto id = ClientId::fromText("31:31:31");
     OptionPtr clientid = (OptionPtr(new Option(Option::V4,
                                                DHO_DHCP_CLIENT_IDENTIFIER,
@@ -2109,7 +2108,7 @@ TEST_F(ClassifyTest, basicOptionClassTagTest) {
     // Verify that server id is present and is the configured value. 
     checkServerIdentifier(response1, "192.0.2.0");
 
-    // Try again with a different client id.
+    // Try again with a client id that does not match "melon".
     id = ClientId::fromText("31:31:32");
     clientid = (OptionPtr(new Option(Option::V4, DHO_DHCP_CLIENT_IDENTIFIER,
                                                  id->getClientId())));
@@ -2136,9 +2135,8 @@ TEST_F(ClassifyTest, basicOptionClassTagTest) {
     tmp = response1->getOption(DHO_DOMAIN_NAME_SERVERS);
     EXPECT_FALSE(tmp);
 
-    // Verify that server id is present and is the generated value. 
+    // Verify that server id is present but it is the generated value.
     checkServerIdentifier(response1, "0.0.0.0");
 }
 
-
 } // end of anonymous namespace
index 913701795b852cdc1a8e70b725a02a5f62386020..392ca47133fde459d9790a76999bb840e32b9696 100644 (file)
@@ -1623,6 +1623,7 @@ Dhcpv6Srv::appendRequestedOptions(const Pkt6Ptr& question, Pkt6Ptr& answer,
         }
     }
 
+    const auto& cclasses = question->getClasses();
     // For each requested option code get the first instance of the option
     // to be returned to the client.
     for (uint16_t opt : requested_opts) {
@@ -1639,8 +1640,8 @@ Dhcpv6Srv::appendRequestedOptions(const Pkt6Ptr& question, Pkt6Ptr& answer,
             // Iterate on the configured option list
             for (auto const& copts : co_list) {
                 OptionDescriptor desc = copts->get(DHCP6_OPTION_SPACE, opt);
-                // Got it: add it and jump to the outer loop
-                if (desc.option_) {
+                // Got it: if allowed add it and jump to the outer loop.
+                if (desc.option_ && desc.allowedForClientClasses(cclasses)) {
                     answer->addOption(desc.option_);
                     break;
                 }
@@ -1667,7 +1668,8 @@ Dhcpv6Srv::appendRequestedOptions(const Pkt6Ptr& question, Pkt6Ptr& answer,
         // Iterate on the configured option list.
         for (auto const& copts : co_list) {
             for (auto const& desc : copts->getList(DHCP6_OPTION_SPACE, D6O_VENDOR_CLASS)) {
-                if (!desc.option_) {
+                // Empty or not allowed, skip i.
+                if (!desc.option_ || !desc.allowedForClientClasses(cclasses)) {
                     continue;
                 }
                 OptionVendorClassPtr vendor_class =
@@ -1704,7 +1706,8 @@ Dhcpv6Srv::appendRequestedOptions(const Pkt6Ptr& question, Pkt6Ptr& answer,
         // Iterate on the configured option list
         for (auto const& copts : co_list) {
             for (auto const& desc : copts->getList(DHCP6_OPTION_SPACE, D6O_VENDOR_OPTS)) {
-                if (!desc.option_) {
+                // Empty or not allowed, skip i.
+                if (!desc.option_ || !desc.allowedForClientClasses(cclasses)) {
                     continue;
                 }
                 OptionVendorPtr vendor_opts =
@@ -1815,6 +1818,7 @@ Dhcpv6Srv::appendRequestedVendorOptions(const Pkt6Ptr& question,
     }
 
     map<uint32_t, set<uint16_t> > cancelled_opts;
+    const auto& cclasses = question->getClasses();
 
     // Iterate on the configured option list to add persistent and
     // cancelled options.
@@ -1876,7 +1880,8 @@ Dhcpv6Srv::appendRequestedVendorOptions(const Pkt6Ptr& question,
             if (!vendor_rsp->getOption(opt)) {
                 for (auto const& copts : co_list) {
                     OptionDescriptor desc = copts->get(vendor_id, opt);
-                    if (desc.option_) {
+                    // Got it: if allowed add it and jump to outer loop.
+                    if (desc.option_ && desc.allowedForClientClasses(cclasses)) {
                         vendor_rsp->addOption(desc.option_);
                         added = true;
                         break;
index 04556809b8bb9fe71ebac3ee8be52242f4890075..cd757ff6ff76bd3c504748e57d5bda01ba00fcf2 100644 (file)
 #include <dhcp/testutils/iface_mgr_test_config.h>
 #include <dhcp/opaque_data_tuple.h>
 #include <dhcp/option_string.h>
+#include <dhcp/option_vendor.h>
 #include <dhcp/option_vendor_class.h>
 #include <dhcp/option6_addrlst.h>
 #include <dhcp/testutils/pkt_captures.h>
 #include <dhcpsrv/cfgmgr.h>
 #include <dhcp6/tests/dhcp6_test_utils.h>
 #include <dhcp6/tests/dhcp6_client.h>
+#include <dhcp/docsis3_option_defs.h>
+
 #include <asiolink/io_address.h>
 #include <stats/stats_mgr.h>
 #include <boost/pointer_cast.hpp>
@@ -527,6 +530,17 @@ public:
             return (query);
     }
 
+    void processQuery(NakedDhcpv6Srv& srv, Pkt6Ptr query, Pkt6Ptr& response) {
+        AllocEngine::ClientContext6 ctx;
+        bool drop = !srv.earlyGHRLookup(query, ctx);
+        ASSERT_FALSE(drop);
+        ctx.subnet_ = srv_.selectSubnet(query, drop);
+        ASSERT_FALSE(drop);
+        srv.initContext(ctx, drop);
+        ASSERT_FALSE(drop);
+        response = srv.processSolicit(ctx);
+    }
+
     /// @brief Interface Manager's fake configuration control.
     IfaceMgrTestConfig iface_mgr_test_config_;
 };
@@ -2867,15 +2881,9 @@ TEST_F(ClassifyTest, subClassPrecedence) {
     EXPECT_TRUE(query1->inClass("template-client-id"));
     EXPECT_TRUE(query1->inClass("SPAWN_template-client-id_def"));
 
-    // Process queries
-    AllocEngine::ClientContext6 ctx1;
-    bool drop = !srv.earlyGHRLookup(query1, ctx1);
-    ASSERT_FALSE(drop);
-    ctx1.subnet_ = srv_.selectSubnet(query1, drop);
-    ASSERT_FALSE(drop);
-    srv.initContext(ctx1, drop);
-    ASSERT_FALSE(drop);
-    Pkt6Ptr response1 = srv.processSolicit(ctx1);
+    // Process the query
+    Pkt6Ptr response1;
+    processQuery(srv, query1, response1);
 
     // Verify that opt1 is inherited from the template.
     OptionPtr opt = response1->getOption(1249);
@@ -2888,4 +2896,387 @@ TEST_F(ClassifyTest, subClassPrecedence) {
     EXPECT_EQ(opt->toString(), "spawn two");
 }
 
+// Verifies that (non-vendor) requested options can be gated
+// by option class tagging.
+TEST_F(ClassifyTest, requestedOptionClassTag) {
+    IfaceMgrTestConfig test_config(true);
+
+    NakedDhcpv6Srv srv(0);
+
+    string config = R"^(
+    {
+        "interfaces-config": {
+            "interfaces": [ "*" ]
+        },
+        "preferred-lifetime": 3000,
+        "rebind-timer": 2000,
+        "renew-timer": 1000,
+        "valid-lifetime": 4000,
+        "subnet6": [{
+            "pools": [{
+                "pool": "2001:db8:1::/64"
+            }],
+            "subnet": "2001:db8:1::/48",
+            "id": 1,
+            "interface": "eth1",
+        }],
+        "option-def": [{
+            "name": "no_classes",
+            "code": 1249,
+            "type": "string"
+        },
+        {
+            "name": "wrong_class",
+            "code": 1250,
+            "type": "string"
+        },
+        {
+            "name": "right_class",
+            "code": 1251,
+            "type": "string"
+        }],
+        "option-data": [{
+            "name": "no_classes",
+            "data": "oompa"
+        },
+        {
+            "name": "wrong_class",
+            "data": "loompa",
+            "client-classes": [ "wrong" ]
+        },
+        {
+            "name": "right_class",
+            "data": "doompadee",
+            "client-classes": [ "right" ]
+        }],
+        "client-classes": [{
+            "name": "right",
+            "test": "substring(option[1].hex,0,3) == '111'"
+        }]
+    }
+    )^";
+
+    ASSERT_NO_THROW(configure(config));
+
+    // Create a SOLICIT that matches class "right".
+    Pkt6Ptr query = createSolicit();
+    query->delOption(D6O_CLIENTID);
+
+    uint8_t duid[] = { 0x31, 0x31, 0x31 };
+    OptionBuffer buf(duid, duid + sizeof(duid));
+    OptionPtr clientid2(new Option(Option::V6, D6O_CLIENTID, buf));
+    query->addOption(clientid2);
+
+    // Add an ORO option requesting all three options.
+    OptionUint16ArrayPtr oro(new OptionUint16Array(Option::V6, D6O_ORO));
+    ASSERT_TRUE(oro);
+    oro->addValue(1249);
+    oro->addValue(1250);
+    oro->addValue(1251);
+    query->addOption(oro);
+
+    // Classify the query. 
+    srv.classifyPacket(query);
+
+    // Verify query is in class "right".
+    ASSERT_TRUE(query->inClass("right"));
+
+    // Process the solicit.
+    Pkt6Ptr response;
+    processQuery(srv, query, response);
+
+    // Option without class tags should be included.
+    OptionPtr opt = response->getOption(1249);
+    EXPECT_TRUE(opt);
+
+    // Option with class tag that doesn't match should not included.
+    opt = response->getOption(1250);
+    EXPECT_FALSE(opt);
+
+    // Option with class tag that matches should be included.
+    opt = response->getOption(1251);
+    EXPECT_TRUE(opt);
+}
+
+// Verifies that D6O_VENDOR_CLASS options can be gated
+// by option class tagging.
+TEST_F(ClassifyTest, vendorClassOptionClassTag) {
+    IfaceMgrTestConfig test_config(true);
+
+    NakedDhcpv6Srv srv(0);
+
+    string config = R"^(
+    {
+        "interfaces-config": {
+            "interfaces": [ "*" ]
+        },
+        "preferred-lifetime": 3000,
+        "rebind-timer": 2000,
+        "renew-timer": 1000,
+        "valid-lifetime": 4000,
+        "subnet6": [{
+            "pools": [{
+                "pool": "2001:db8:1::/64"
+            }],
+            "subnet": "2001:db8:1::/48",
+            "id": 1,
+            "interface": "eth1",
+        }],
+        "option-def": [
+        ],
+        "option-data": [{
+            "name": "vendor-class",
+            "always-send": true,
+            "data": "1234, 0003666f6f",
+            "client-classes": [ "melon" ]
+        }],
+        "client-classes": [{
+            "name": "melon",
+            "test": "substring(option[1].hex,0,3) == '111'"
+        }]
+    }
+    )^";
+
+    ASSERT_NO_THROW(configure(config));
+
+    // Create a SOLICIT that matches class "right".
+    Pkt6Ptr query = createSolicit();
+    query->delOption(D6O_CLIENTID);
+
+    uint8_t duid[] = { 0x31, 0x31, 0x31 };
+    OptionBuffer buf(duid, duid + sizeof(duid));
+    OptionPtr clientid2(new Option(Option::V6, D6O_CLIENTID, buf));
+    query->addOption(clientid2);
+
+    // Classify the query. 
+    srv.classifyPacket(query);
+
+    // Verify query is in class "melon".
+    ASSERT_TRUE(query->inClass("melon"));
+
+    // Process the solicit.
+    Pkt6Ptr response;
+    processQuery(srv, query, response);
+
+    const auto& vendor_classes = response->getOptions(D6O_VENDOR_CLASS);
+    ASSERT_EQ(1, vendor_classes.size());
+
+    // Create a SOLICIT that does not match class "right".
+    query = createSolicit();
+
+    // Classify the query. 
+    srv.classifyPacket(query);
+
+    // Verify query is in not class "melon".
+    ASSERT_FALSE(query->inClass("melon"));
+
+    // Process the SOLICIT.
+    processQuery(srv, query, response);
+
+    // Should not have any vendor class options.
+    const auto& vendor_classes2 = response->getOptions(D6O_VENDOR_CLASS);
+    ASSERT_EQ(0, vendor_classes2.size());
+}
+
+// Verifies that D6O_VENDOR_OPTS option can be gated by option class
+// tagging. Note that class-tagging only suppresses this option when 
+// none of the vendor's sub-options are being returned.
+TEST_F(ClassifyTest, persistedVendorOptsOptionClassTag) {
+    IfaceMgrTestConfig test_config(true);
+
+    NakedDhcpv6Srv srv(0);
+
+    string config = R"^(
+    {
+        "interfaces-config": {
+            "interfaces": [ "*" ]
+        },
+        "preferred-lifetime": 3000,
+        "rebind-timer": 2000,
+        "renew-timer": 1000,
+        "valid-lifetime": 4000,
+        "subnet6": [{
+            "pools": [{
+                "pool": "2001:db8:1::/64"
+            }],
+            "subnet": "2001:db8:1::/48",
+            "id": 1,
+            "interface": "eth1",
+        }],
+        "option-data": [{
+            "always-send": true,
+            "name": "vendor-opts",
+            "data": "4491",
+            "space": "dhcp6",
+            "client-classes": [ "melon" ]
+        }],
+        "client-classes": [{
+            "name": "melon",
+            "test": "substring(option[1].hex,0,3) == '111'"
+        }]
+    }
+    )^";
+
+    ASSERT_NO_THROW(configure(config));
+
+    // Create a SOLICIT that does not match class.
+    Pkt6Ptr query = createSolicit();
+
+    // Classify the query. 
+    srv.classifyPacket(query);
+
+    // Verify query is not in class "melon".
+    ASSERT_FALSE(query->inClass("melon"));
+
+    // Process the solicit.
+    Pkt6Ptr response;
+    processQuery(srv, query, response);
+
+    // The response should not have the D6O_VENDOR_OPTS option.
+    const auto& vendor_opts = response->getOptions(D6O_VENDOR_OPTS);
+    ASSERT_EQ(0, vendor_opts.size());
+
+    // Create a SOLICIT that matches class "melon".
+    query = createSolicit();
+    query->delOption(D6O_CLIENTID);
+
+    uint8_t duid[] = { 0x31, 0x31, 0x31 };
+    OptionBuffer buf(duid, duid + sizeof(duid));
+    OptionPtr clientid2(new Option(Option::V6, D6O_CLIENTID, buf));
+    query->addOption(clientid2);
+
+    // Classify the query. 
+    srv.classifyPacket(query);
+
+    // Verify query is in class "melon".
+    ASSERT_TRUE(query->inClass("melon"));
+
+    // Process the solicit.
+    processQuery(srv, query, response);
+
+    // The response should have the D6O_VENDOR_OPTS option.
+    const auto& vendor_opts2 = response->getOptions(D6O_VENDOR_OPTS);
+    ASSERT_EQ(1, vendor_opts2.size());
+}
+
+// Verifies that vendor options can be gated by option class tagging.
+TEST_F(ClassifyTest, requestedVendorOptionsClassTag) {
+    IfaceMgrTestConfig test_config(true);
+
+    NakedDhcpv6Srv srv(0);
+
+    string config = R"^(
+    {
+        "interfaces-config": {
+            "interfaces": [ "*" ]
+        },
+        "preferred-lifetime": 3000,
+        "rebind-timer": 2000,
+        "renew-timer": 1000,
+        "valid-lifetime": 4000,
+        "subnet6": [{
+            "pools": [{
+                "pool": "2001:db8:1::/64"
+            }],
+            "subnet": "2001:db8:1::/48",
+            "id": 1,
+            "interface": "eth1",
+        }],
+        "option-def": [{
+            "space": "vendor-4491",
+            "name": "one",
+            "code": 101,
+            "type": "string"
+        },
+        {
+            "space": "vendor-4491",
+            "name": "two",
+            "code": 102,
+            "type": "string"
+        },
+        {
+            "space": "vendor-4491",
+            "name": "three",
+            "code": 103,
+            "type": "string"
+        }],
+        "option-data": [{ 
+            "space": "vendor-4491",
+            "code": 101,
+            "csv-format": true,
+            "data": "zippy-ah",
+            "always-send": true,
+            "client-classes": [ "melon" ]
+        },
+        {
+            "space": "vendor-4491",
+            "code": 102,
+            "csv-format": true,
+            "data": "dee",
+            "always-send": true,
+            "client-classes": [ "ball" ]
+        },
+        {
+            "space": "vendor-4491",
+            "code": 103,
+            "csv-format": true,
+            "always-send": true,
+            "data": "doo-dah"
+        }],
+        "client-classes": [{
+            "name": "melon",
+            "test": "substring(option[1].hex,0,3) == '111'"
+        }]
+    }
+    )^";
+
+    ASSERT_NO_THROW(configure(config));
+
+    // Create a SOLICIT that matches class "right".
+    Pkt6Ptr query = createSolicit();
+    query->delOption(D6O_CLIENTID);
+
+    uint8_t duid[] = { 0x31, 0x31, 0x31 };
+    OptionBuffer buf(duid, duid + sizeof(duid));
+    OptionPtr clientid2(new Option(Option::V6, D6O_CLIENTID, buf));
+    query->addOption(clientid2);
+
+    // Add a vendor-id option with a vendor ORO requesting all three vendor options.
+    OptionVendorPtr vendor(new OptionVendor(Option::V6, 4491));
+    query->addOption(vendor);
+
+    OptionUint16ArrayPtr vendor_oro(new OptionUint16Array(Option::V6, DOCSIS3_V6_ORO));
+    vendor_oro->addValue(101);
+    vendor_oro->addValue(102);
+    vendor_oro->addValue(103);
+    vendor->addOption(vendor_oro);
+
+    // Classify the query. 
+    srv.classifyPacket(query);
+
+    // Verify query is in class "melon".
+    ASSERT_TRUE(query->inClass("melon"));
+
+    // Process the solicit.
+    Pkt6Ptr response;
+    processQuery(srv, query, response);
+
+    // Should have 1 vendor response option.
+    const auto& vendor_opts = response->getOptions(D6O_VENDOR_OPTS);
+    ASSERT_EQ(1, vendor_opts.size());
+
+    auto const& opt = (*vendor_opts.begin()).second;
+    OptionVendorPtr vendor_resp = boost::dynamic_pointer_cast<OptionVendor>(opt);
+    ASSERT_TRUE(vendor_resp);
+
+    ASSERT_EQ(4491, vendor_resp->getVendorId());
+    // It should vendor otions 101 and 103, but not 102.
+    OptionPtr custom = vendor_resp->getOption(101);
+    EXPECT_TRUE(custom);
+    custom = vendor_resp->getOption(102);
+    EXPECT_FALSE(custom);
+    custom = vendor_resp->getOption(103);
+    EXPECT_TRUE(custom);
+}
+
 } // end of anonymous namespace