From 20902bec03f5cc63a62198636f7f02f8f646edcc Mon Sep 17 00:00:00 2001 From: Alexandre Fiset Date: Tue, 9 Jun 2026 17:14:57 +0200 Subject: [PATCH] ci: cram: add dhcpv4-client-options test for TR-181 Client.Option Add two dhcpv4 client tests. first one check if option provided by a client is correctly provided in the DHCPv4.Server DM. Second one check that if multiple client provide the same option with different value, the value are not mixed up. References: PCF-2262 Signed-off-by: Alexandre Fiset --- .../017-dhcpv4-force-option.t | 3 + .../018-dhcpv4-client-options.t | 118 ++++++++++++++++++ .../README.md | 0 .../dhcpv4_option_probe.py | 85 +++++++++---- 4 files changed, 182 insertions(+), 24 deletions(-) rename .gitlab/tests/cram/generic/acceleration-plan-components/{dhcpv4-force-option => dhcpv4-client-options}/017-dhcpv4-force-option.t (95%) create mode 100644 .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/018-dhcpv4-client-options.t rename .gitlab/tests/cram/generic/acceleration-plan-components/{dhcpv4-force-option => dhcpv4-client-options}/README.md (100%) rename .gitlab/tests/cram/generic/acceleration-plan-components/{dhcpv4-force-option => dhcpv4-client-options}/dhcpv4_option_probe.py (63%) diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/017-dhcpv4-force-option.t b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t similarity index 95% rename from .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/017-dhcpv4-force-option.t rename to .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t index f8cb2537fc..f2c05a2d83 100644 --- a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/017-dhcpv4-force-option.t +++ b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/017-dhcpv4-force-option.t @@ -28,6 +28,7 @@ Create pool option with Force=true: Force=true, tag 42 absent from DHCPREQUEST PRL -> tag must be present in final DHCPACK: $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITHOUT_TAG" --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:ff RECEIVED_TAGS=.*(=|,)42(,|$).* (re) Set Force=false: @@ -40,11 +41,13 @@ Set Force=false: Force=false, tag 42 absent from DHCPREQUEST PRL -> tag must be absent from final DHCPACK: $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITHOUT_TAG" --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:ff RECEIVED_TAGS=(?!.*(=|,)42(,|$)).* (re) Force=false, tag 42 present in DHCPREQUEST PRL -> tag must be present in final DHCPACK: $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --request-options "$REQ_WITH_TAG" --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:ff RECEIVED_TAGS=.*(=|,)42(,|$).* (re) diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/018-dhcpv4-client-options.t b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/018-dhcpv4-client-options.t new file mode 100644 index 0000000000..06b81d4cd5 --- /dev/null +++ b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/018-dhcpv4-client-options.t @@ -0,0 +1,118 @@ +Create R alias: + + $ alias R="${CRAM_REMOTE_COMMAND:-}" + +Install scapy with pip: + + $ pip install scapy >/dev/null 2>&1 + +Verify python can import scapy: + + $ python3 -c "import scapy.all" >/dev/null 2>&1 + +Pick test constants: + + $ PROBE_TIMEOUT=12 + $ MAC_A="aa:bb:cc:dd:ee:01" + $ MAC_B="aa:bb:cc:dd:ee:02" + $ HOSTNAME_A="cramhostA" + $ HOSTNAME_B="cramhostB" + $ HOSTNAME_A_HEX="6372616d686f737441" + $ HOSTNAME_B_HEX="6372616d686f737442" + +Test 1 - basic client options (option 12 hostname): + + $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --chaddr "$MAC_A" --hostname "$HOSTNAME_A" --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:01 + SENT_HOSTNAME=cramhostA + RECEIVED_TAGS=.* (re) + + $ sleep 3 + +Verify client A appears in DHCPv4.Server.Pool.Client: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Chaddr?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Chaddr=.* (re) + +Verify option 12 (hostname) is recorded under client A: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Option.[Tag==12].Tag?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Tag=12 (re) + +Verify option 12 value matches hostname A hex encoding: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Option.[Tag==12].Value?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Value="6372616d686f737441" (re) + +Verify option 53 (DHCP Message Type) is recorded under client A: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Option.[Tag==53].Tag?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Tag=53 (re) + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Option.[Tag==53].Value?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Value="03" (re) + +Test 2 - multiple clients each with their own options (C-2 per FEAT-13 spec): + + $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --chaddr "$MAC_B" --hostname "$HOSTNAME_B" --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:02 + SENT_HOSTNAME=cramhostB + RECEIVED_TAGS=.* (re) + + $ sleep 3 + +Verify client B appears in DHCPv4.Server.Pool.Client: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_B\"].Chaddr?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Chaddr=.* (re) + +Both clients must appear simultaneously: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Chaddr?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Chaddr=.* (re) + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_B\"].Chaddr?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Chaddr=.* (re) + +Client A must carry hostname A hex (option 12), not hostname B hex: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Option.[Tag==12].Value?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Value="6372616d686f737441" (re) + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Option.[Value==\"${HOSTNAME_B_HEX}\"].Value?'" | grep -Ev '^(>|$)' + No data found + +Client B must carry hostname B hex (option 12), not hostname A hex: + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_B\"].Option.[Tag==12].Value?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Value="6372616d686f737442" (re) + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_B\"].Option.[Value==\"${HOSTNAME_A_HEX}\"].Value?'" | grep -Ev '^(>|$)' + No data found + +Verify option 53 (DHCP Message Type) is recorded under client B (C-1 per FEAT-13 spec): + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_B\"].Option.[Tag==53].Tag?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Option\.[0-9]+\.Tag=53 (re) + +Cleanup - release both leases and verify clients are marked inactive in DM: + + $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --chaddr "$MAC_A" --hostname "$HOSTNAME_A" --release --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:01 + SENT_HOSTNAME=cramhostA + RECEIVED_TAGS=.* (re) + RELEASED=.* (re) + + $ sudo -E python3 "$TESTDIR/dhcpv4_option_probe.py" --iface "$TESTBED_LAN_INTERFACE" --chaddr "$MAC_B" --hostname "$HOSTNAME_B" --release --timeout "$PROBE_TIMEOUT" 2>/dev/null + CLIENT_MAC=aa:bb:cc:dd:ee:02 + SENT_HOSTNAME=cramhostB + RECEIVED_TAGS=.* (re) + RELEASED=.* (re) + + $ sleep 3 + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_A\"].Active?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Active=0 (re) + + $ R "ba-cli 'Device.DHCPv4.Server.Pool.lan.Client.[Chaddr==\"$MAC_B\"].Active?'" | grep -Ev '^(>|$)' + Device\.DHCPv4\.Server\.Pool\.[0-9]+\.Client\.[0-9]+\.Active=0 (re) diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/README.md b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/README.md similarity index 100% rename from .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/README.md rename to .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/README.md diff --git a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/dhcpv4_option_probe.py b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py similarity index 63% rename from .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/dhcpv4_option_probe.py rename to .gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py index baa9e14a5a..db0a58b40d 100644 --- a/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-force-option/dhcpv4_option_probe.py +++ b/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py @@ -3,16 +3,15 @@ Minimal DHCPv4 probe for Cram: - Sends DHCPDISCOVER with configurable Parameter Request List (PRL) - Sends DHCPREQUEST using the offered address and server identifier -- Prints received option tags from final DHCPACK as: - RECEIVED_TAGS=1,3,6,... -- Optional debug output can print the PRL sent in DHCPREQUEST +- Prints CLIENT_MAC= then RECEIVED_TAGS=1,3,6,... +- Optional: custom MAC (--chaddr) and hostname option 12 (--hostname) """ import argparse import random import sys -from scapy.all import BOOTP, DHCP, Ether, IP, UDP, conf, srp1 # type: ignore +from scapy.all import BOOTP, DHCP, Ether, IP, UDP, conf, sendp, srp1 # type: ignore from scapy.layers.dhcp import DHCPRevOptions # type: ignore @@ -22,6 +21,9 @@ def parse_args(): parser.add_argument("--request-options", default="", help="CSV option tags in PRL, e.g. 1,3,6,15,42") parser.add_argument("--timeout", type=int, default=8) parser.add_argument("--debug", action="store_true") + parser.add_argument("--chaddr", default="", help="MAC as aa:bb:cc:dd:ee:ff (default: aa:bb:cc:dd:ee:ff)") + parser.add_argument("--hostname", default="", help="Hostname string for DHCP option 12") + parser.add_argument("--release", action="store_true", help="Send DHCPRELEASE after DHCPACK") return parser.parse_args() @@ -31,25 +33,38 @@ def parse_prl(csv_text): return [int(value.strip()) for value in csv_text.split(",") if value.strip()] +def parse_chaddr(mac_str): + if not mac_str.strip(): + return b"\xaa\xbb\xcc\xdd\xee\xff" + try: + octets = [int(b, 16) for b in mac_str.strip().split(":")] + except ValueError: + print("ERROR: invalid --chaddr value: " + mac_str, file=sys.stderr) + sys.exit(2) + if len(octets) != 6: + print("ERROR: --chaddr must have exactly 6 octets, got " + str(len(octets)), file=sys.stderr) + sys.exit(2) + if any(o < 0 or o > 255 for o in octets): + print("ERROR: --chaddr octet out of range 0-255: " + mac_str, file=sys.stderr) + sys.exit(2) + return bytes(octets) + + def extract_option_tags(dhcp_options): tags = [] for opt in dhcp_options: if not (isinstance(opt, tuple) and len(opt) >= 1): continue - name = opt[0] - if isinstance(name, int): tags.append(name) continue - if isinstance(name, str): code = DHCPRevOptions.get(name) if isinstance(code, int): tags.append(code) elif isinstance(code, tuple) and len(code) >= 1 and isinstance(code[0], int): tags.append(code[0]) - return sorted(set(tags)) @@ -66,14 +81,8 @@ def get_message_type(dhcp_options): return value if isinstance(value, str): value_map = { - "discover": 1, - "offer": 2, - "request": 3, - "decline": 4, - "ack": 5, - "nak": 6, - "release": 7, - "inform": 8, + "discover": 1, "offer": 2, "request": 3, "decline": 4, + "ack": 5, "nak": 6, "release": 7, "inform": 8, } return value_map.get(value.lower()) return None @@ -85,17 +94,24 @@ def main(): xid = random.randint(1, 0xFFFFFFFF) prl = parse_prl(args.request_options) + chaddr = parse_chaddr(args.chaddr) + mac_str = args.chaddr.strip() if args.chaddr.strip() else "aa:bb:cc:dd:ee:ff" + + print("CLIENT_MAC=" + mac_str) + if args.hostname: + print("SENT_HOSTNAME=" + args.hostname) + + discover_options = [("message-type", "discover"), ("param_req_list", prl)] + if args.hostname: + discover_options.append(("hostname", args.hostname.encode())) + discover_options.append("end") discover_packet = ( Ether(dst="ff:ff:ff:ff:ff:ff") / IP(src="0.0.0.0", dst="255.255.255.255") / UDP(sport=68, dport=67) - / BOOTP(chaddr=b"\xaa\xbb\xcc\xdd\xee\xff", xid=xid, flags=0x8000) - / DHCP(options=[ - ("message-type", "discover"), - ("param_req_list", prl), - "end", - ]) + / BOOTP(chaddr=chaddr, xid=xid, flags=0x8000) + / DHCP(options=discover_options) ) if args.debug: @@ -123,8 +139,10 @@ def main(): ("requested_addr", offered_ip), ("param_req_list", prl), ] + if args.hostname: + request_options.append(("hostname", args.hostname.encode())) if args.debug: - print("DEBUG_DHCPREQUEST_PRL=" + ",".join(str(tag) for tag in prl)) + print("DEBUG_DHCPREQUEST_PRL=" + ",".join(str(tag) for tag in prl), file=sys.stderr) if server_identifier is not None: request_options.append(("server_id", server_identifier)) request_options.append("end") @@ -133,7 +151,7 @@ def main(): Ether(dst="ff:ff:ff:ff:ff:ff") / IP(src="0.0.0.0", dst="255.255.255.255") / UDP(sport=68, dport=67) - / BOOTP(chaddr=b"\xaa\xbb\xcc\xdd\xee\xff", xid=xid, flags=0x8000) + / BOOTP(chaddr=chaddr, xid=xid, flags=0x8000) / DHCP(options=request_options) ) @@ -160,6 +178,25 @@ def main(): tags = extract_option_tags(final_reply[DHCP].options) print("RECEIVED_TAGS=" + ",".join(str(tag) for tag in tags)) + + if args.release: + release_packet = ( + Ether(dst="ff:ff:ff:ff:ff:ff") + / IP(src=offered_ip, dst=server_identifier or "255.255.255.255") + / UDP(sport=68, dport=67) + / BOOTP(chaddr=chaddr, xid=xid, ciaddr=offered_ip) + / DHCP(options=[ + ("message-type", "release"), + ("server_id", server_identifier), + "end", + ]) + ) + if args.debug: + print("--- DHCPRELEASE sent ---", file=sys.stderr) + print(release_packet.show(dump=True), file=sys.stderr) + sendp(release_packet, iface=args.iface, verbose=False) + print("RELEASED=" + offered_ip) + return 0