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 <alexandre.fiset@softathome.com>
This commit is contained in:
Alexandre Fiset 2026-06-09 17:14:57 +02:00 committed by Faouzi Yahya
parent bad40d104e
commit 20902bec03
4 changed files with 182 additions and 24 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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=<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