prplos/.gitlab/tests/cram/generic/acceleration-plan-components/dhcpv4-client-options/dhcpv4_option_probe.py
Alexandre Fiset 49c0eca805 ci: cram: dhcpv4 force option: retransmit and report probe failures
Problem: 017-dhcpv4-force-option.t intermittently fails with a bare [2] on
the two probes that follow the Force=0 write, while the Force=1 probe just
before them passes.

Writing a Device.DHCPv4.Server. parameter makes dhcpv4-manager rewrite the
dnsmasq configuration and restart dnsmasq. The test only waited one second
afterwards and dhcpv4_option_probe.py sent its DHCPDISCOVER exactly once,
so a request issued during that restart window was lost and the probe gave
up after the timeout instead of reporting the option state under test.

The reason was invisible: all five probe error paths wrote to stderr, which
the test discards with 2>/dev/null, so every one of them looked identical in
the .t.err artifact.

Solution:
- retransmit an unanswered DHCPDISCOVER/DHCPREQUEST, --retries (default 2)
- print the ERROR line on stdout so it stays visible in the Cram diff
- release the lease on the last probe so no lease is left behind

A passing run is unaffected: retransmission stops as soon as the reply
arrives and nothing is written to stderr on success.

References: PPW-2188
Signed-off-by: Alexandre Fiset <alexandre.fiset@softathome.com>
2026-08-27 10:34:13 +00:00

210 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""
Minimal DHCPv4 probe for Cram:
- Sends DHCPDISCOVER with configurable Parameter Request List (PRL)
- Sends DHCPREQUEST using the offered address and server identifier
- Retransmits unanswered DHCPDISCOVER/DHCPREQUEST (--retries)
- Prints CLIENT_MAC=<mac> then RECEIVED_TAGS=1,3,6,...
- Optional: custom MAC (--chaddr) and hostname option 12 (--hostname)
- Errors are reported on stdout, packet dumps (--debug) on stderr
"""
import argparse
import random
import sys
from scapy.all import BOOTP, DHCP, Ether, IP, UDP, conf, sendp, srp1 # type: ignore
from scapy.layers.dhcp import DHCPRevOptions # type: ignore
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--iface", required=True, help="Interface used to send/receive DHCP")
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, help="Seconds waited for a reply per attempt")
parser.add_argument("--retries", type=int, default=2, help="Retransmissions of an unanswered request")
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()
def parse_prl(csv_text):
if not csv_text.strip():
return []
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)
sys.exit(2)
if len(octets) != 6:
print("ERROR: --chaddr must have exactly 6 octets, got " + str(len(octets)))
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)
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))
def get_option_value(dhcp_options, option_name):
for opt in dhcp_options:
if isinstance(opt, tuple) and len(opt) >= 2 and opt[0] == option_name:
return opt[1]
return None
def get_message_type(dhcp_options):
value = get_option_value(dhcp_options, "message-type")
if isinstance(value, int):
return value
if isinstance(value, str):
value_map = {
"discover": 1, "offer": 2, "request": 3, "decline": 4,
"ack": 5, "nak": 6, "release": 7, "inform": 8,
}
return value_map.get(value.lower())
return None
def main():
args = parse_args()
conf.checkIPaddr = False
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=chaddr, xid=xid, flags=0x8000)
/ DHCP(options=discover_options)
)
if args.debug:
print("--- DHCPDISCOVER sent ---", file=sys.stderr)
print(discover_packet.show(dump=True), file=sys.stderr)
offer = srp1(discover_packet, iface=args.iface, timeout=args.timeout,
retry=args.retries, verbose=False)
if offer is None or not offer.haslayer(DHCP):
print("ERROR: no DHCPOFFER received")
return 2
if args.debug:
print("--- DHCPOFFER received ---", file=sys.stderr)
print(offer.show(dump=True), file=sys.stderr)
if get_message_type(offer[DHCP].options) != 2:
print("ERROR: received DHCP response is not DHCPOFFER")
return 2
offered_ip = offer[BOOTP].yiaddr
server_identifier = get_option_value(offer[DHCP].options, "server_id")
request_options = [
("message-type", "request"),
("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), file=sys.stderr)
if server_identifier is not None:
request_options.append(("server_id", server_identifier))
request_options.append("end")
request_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=chaddr, xid=xid, flags=0x8000)
/ DHCP(options=request_options)
)
if args.debug:
print("--- DHCPREQUEST sent ---", file=sys.stderr)
print(request_packet.show(dump=True), file=sys.stderr)
final_reply = srp1(request_packet, iface=args.iface, timeout=args.timeout,
retry=args.retries, verbose=False)
if final_reply is None or not final_reply.haslayer(DHCP):
print("ERROR: no DHCPACK received")
return 2
if args.debug:
print("--- DHCPACK received ---", file=sys.stderr)
print(final_reply.show(dump=True), file=sys.stderr)
message_type = get_message_type(final_reply[DHCP].options)
if message_type == 6:
print("ERROR: DHCPNAK received")
return 2
if message_type != 5:
print("ERROR: final DHCP response is not DHCPACK")
return 2
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
if __name__ == "__main__":
sys.exit(main())