From 4b340a38b3fdcf915c46a62c8944c2533a540178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 1 Jul 2026 21:53:41 +0200 Subject: [PATCH 1/7] statd: wifi: Limit bands to the supported ones Infix only supports 2.4/5/6 Ghz, ignore all else. --- board/common/rootfs/usr/libexec/infix/iw.py | 30 ++++++++++++--------- src/statd/python/yanger/ietf_hardware.py | 3 ++- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index 78f9a8b23..ed6883d1c 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -176,19 +176,25 @@ def parse_phy_info(phy_name): if current_band and current_band.get('frequencies'): result['bands'].append(current_band) - # Determine band names and assign band numbers + # Keep only the bands Infix supports (2.4/5/6 GHz), naming them by their + # first frequency. Hardware may expose others (e.g. S1G on hwsim) that we + # neither configure nor report. + bands = [] for band in result['bands']: - if band['frequencies']: - freq = band['frequencies'][0] - if 2400 <= freq <= 2500: - band['name'] = '2.4GHz' - band['band'] = 1 - elif 5150 <= freq <= 5900: - band['name'] = '5GHz' - band['band'] = 2 - elif 5955 <= freq <= 7115: - band['name'] = '6GHz' - band['band'] = 3 + freq = band['frequencies'][0] + if 2400 <= freq <= 2500: + band['name'] = '2.4GHz' + band['band'] = 1 + elif 5150 <= freq <= 5900: + band['name'] = '5GHz' + band['band'] = 2 + elif 5955 <= freq <= 7115: + band['name'] = '6GHz' + band['band'] = 3 + else: + continue + bands.append(band) + result['bands'] = bands # Set max TX power if max_power is not None: diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 934b2f6d7..e29f53e37 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -591,8 +591,9 @@ def convert_iw_phy_info_for_yanger(phy_info): for band in phy_info.get("bands", []): band_data = { "band": str(band.get("band", 0)), - "name": band.get("name", "Unknown") } + if band.get("name"): + band_data["name"] = band["name"] # Add capability flags (iw.py uses snake_case: ht_capable, vht_capable, he_capable) if band.get("ht_capable"): From ce1892a60b168a0b6877c662c26a3cd569e679e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Thu, 2 Jul 2026 08:38:36 +0200 Subject: [PATCH 2/7] test: factory_config: Update generated files --- test/case/system/factory_config/Readme.adoc | 35 +------------------- test/case/system/factory_config/test.adoc | 24 ++++++++++++++ test/case/system/factory_config/topology.svg | 3 +- 3 files changed, 26 insertions(+), 36 deletions(-) mode change 100644 => 120000 test/case/system/factory_config/Readme.adoc create mode 100644 test/case/system/factory_config/test.adoc diff --git a/test/case/system/factory_config/Readme.adoc b/test/case/system/factory_config/Readme.adoc deleted file mode 100644 index d6689e35f..000000000 --- a/test/case/system/factory_config/Readme.adoc +++ /dev/null @@ -1,34 +0,0 @@ -=== Boot From Factory Config - -ifdef::topdoc[:imagesdir: {topdoc}../../test/case/system/factory_config] - -==== Description - -Verify that the device's factory-default configuration boots cleanly and -that the device remains usable afterwards -- i.e. it does not fall back to -the fail-secure failure-config. - -This exercises the device's own first-boot bootstrap path: with no -startup-config present, confd initialises running from the factory-config. -That is exactly what a factory-fresh (or factory-reset) device does, and -it avoids applying a full config swap over the live management session. - -The test is image-generic: it uses whatever factory-config the running -image was built with, so it covers both the stock Infix factory config and -any spin factory config. - -A single-node topology is used on purpose: a factory config is not written -with a lab full of peers in mind, so applying it across a multi-node -topology could trigger broadcast storms or similar. - -==== Topology - -image::topology.svg[Boot From Factory Config topology, align=center, scaledwidth=75%] - -==== Sequence - -. Set up topology and attach to target DUT -. Determine factory-config hostname -. Clear startup-config so the device boots from factory -. Reboot onto the factory config -. Verify device is usable and not in failure-config diff --git a/test/case/system/factory_config/Readme.adoc b/test/case/system/factory_config/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/system/factory_config/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/system/factory_config/test.adoc b/test/case/system/factory_config/test.adoc new file mode 100644 index 000000000..59b99a4b0 --- /dev/null +++ b/test/case/system/factory_config/test.adoc @@ -0,0 +1,24 @@ +=== Boot From Factory Config + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/system/factory_config] + +==== Description + +Verify that the device boots cleanly from its factory-config and stays +usable, i.e. it does not fall back to the fail-secure failure-config. +Clearing the startup-config makes confd bootstrap running from the +factory-config on the next boot, as on a factory-fresh device. + +==== Topology + +image::topology.svg[Boot From Factory Config topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to target DUT +. Determine factory-config hostname +. Clear startup-config so the device boots from factory +. Reboot onto the factory config +. Verify device is usable and not in failure-config + + diff --git a/test/case/system/factory_config/topology.svg b/test/case/system/factory_config/topology.svg index 1e10fc28e..6fc6f47a8 100644 --- a/test/case/system/factory_config/topology.svg +++ b/test/case/system/factory_config/topology.svg @@ -1,8 +1,7 @@ - + From 1422a0ebddf02769bcdf8e8c25e53544f0802087 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 24 Jun 2026 16:18:18 +0200 Subject: [PATCH 3/7] test: Add Wi-Fi tests Should map on physical as well, but only tested on virtual. --- .../rootfs/usr/libexec/infix/init.d/00-probe | 11 +- configs/x86_64_defconfig | 1 + doc/test-arch.md | 30 ++ package/feature-wifi/00-hwsim | 47 ++ package/feature-wifi/Config.in | 12 + package/feature-wifi/feature-wifi.mk | 24 ++ package/feature-wifi/wifimedium | 401 ++++++++++++++++++ package/feature-wifi/wifimedium.conf | 14 + src/confd/src/core.c | 13 +- test/case/interfaces/all.yaml | 3 + test/case/interfaces/wifi.yaml | 12 + .../wifi_ap_multi_station/Readme.adoc | 1 + .../wifi_ap_multi_station/test.adoc | 46 ++ .../interfaces/wifi_ap_multi_station/test.py | 181 ++++++++ .../wifi_ap_multi_station/topology.dot | 61 +++ .../wifi_ap_multi_station/topology.svg | 112 +++++ .../wifi_ap_station_2dut/Readme.adoc | 1 + .../interfaces/wifi_ap_station_2dut/test.adoc | 43 ++ .../interfaces/wifi_ap_station_2dut/test.py | 153 +++++++ .../wifi_ap_station_2dut/topology.dot | 44 ++ .../wifi_ap_station_2dut/topology.svg | 68 +++ .../interfaces/wifi_band_steering/Readme.adoc | 1 + .../interfaces/wifi_band_steering/test.adoc | 50 +++ .../interfaces/wifi_band_steering/test.py | 201 +++++++++ .../wifi_band_steering/topology.dot | 54 +++ .../wifi_band_steering/topology.svg | 76 ++++ .../interfaces/wifi_mesh_roaming/Readme.adoc | 1 + .../interfaces/wifi_mesh_roaming/test.adoc | 62 +++ .../case/interfaces/wifi_mesh_roaming/test.py | 247 +++++++++++ .../interfaces/wifi_mesh_roaming/topology.dot | 79 ++++ .../interfaces/wifi_mesh_roaming/topology.svg | 148 +++++++ test/infamy/topology.py | 10 +- test/qeneth | 2 +- test/templates/infix-bios-x86_64.mustache | 10 + test/templates/infix-kernel-x86_64.mustache | 10 + test/templates/infix-x86_64.mustache | 10 + test/test.mk | 2 +- test/virt/quad/topology.dot.in | 69 ++- test/virt/quad/topology.svg | 364 ++++++++++------ 39 files changed, 2516 insertions(+), 158 deletions(-) create mode 100755 package/feature-wifi/00-hwsim create mode 100755 package/feature-wifi/wifimedium create mode 100644 package/feature-wifi/wifimedium.conf create mode 100644 test/case/interfaces/wifi.yaml create mode 120000 test/case/interfaces/wifi_ap_multi_station/Readme.adoc create mode 100644 test/case/interfaces/wifi_ap_multi_station/test.adoc create mode 100755 test/case/interfaces/wifi_ap_multi_station/test.py create mode 100644 test/case/interfaces/wifi_ap_multi_station/topology.dot create mode 100644 test/case/interfaces/wifi_ap_multi_station/topology.svg create mode 120000 test/case/interfaces/wifi_ap_station_2dut/Readme.adoc create mode 100644 test/case/interfaces/wifi_ap_station_2dut/test.adoc create mode 100755 test/case/interfaces/wifi_ap_station_2dut/test.py create mode 100644 test/case/interfaces/wifi_ap_station_2dut/topology.dot create mode 100644 test/case/interfaces/wifi_ap_station_2dut/topology.svg create mode 120000 test/case/interfaces/wifi_band_steering/Readme.adoc create mode 100644 test/case/interfaces/wifi_band_steering/test.adoc create mode 100755 test/case/interfaces/wifi_band_steering/test.py create mode 100644 test/case/interfaces/wifi_band_steering/topology.dot create mode 100644 test/case/interfaces/wifi_band_steering/topology.svg create mode 120000 test/case/interfaces/wifi_mesh_roaming/Readme.adoc create mode 100644 test/case/interfaces/wifi_mesh_roaming/test.adoc create mode 100755 test/case/interfaces/wifi_mesh_roaming/test.py create mode 100644 test/case/interfaces/wifi_mesh_roaming/topology.dot create mode 100644 test/case/interfaces/wifi_mesh_roaming/topology.svg diff --git a/board/common/rootfs/usr/libexec/infix/init.d/00-probe b/board/common/rootfs/usr/libexec/infix/init.d/00-probe index e2054cefb..5033e3eb3 100755 --- a/board/common/rootfs/usr/libexec/infix/init.d/00-probe +++ b/board/common/rootfs/usr/libexec/infix/init.d/00-probe @@ -654,10 +654,17 @@ def main(): err = 1 os.umask(0o337) + # Write atomically: confd reads system.json as soon as ixinit signals + # done, so a truncated in-place write could be read half-written. Write + # to a temp file in the same dir, then rename (atomic on the same fs). # pylint: disable=invalid-name - with open(SYSTEM_JSON, "w", encoding="ascii") as f: + tmp = SYSTEM_JSON + ".tmp" + with open(tmp, "w", encoding="ascii") as f: json.dump(out, f) - shutil.chown(SYSTEM_JSON, user="root", group="wheel") + f.flush() + os.fsync(f.fileno()) + shutil.chown(tmp, user="root", group="wheel") + os.replace(tmp, SYSTEM_JSON) return err diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig index 91bb7ce75..53792763a 100644 --- a/configs/x86_64_defconfig +++ b/configs/x86_64_defconfig @@ -146,6 +146,7 @@ INFIX_DOC="https://www.kernelkit.org/infix/" INFIX_SUPPORT="mailto:kernelkit@googlegroups.com" BR2_PACKAGE_FEATURE_GPS=y BR2_PACKAGE_FEATURE_WIFI=y +BR2_PACKAGE_FEATURE_WIFI_HWSIM=y BR2_PACKAGE_FEATURE_WIFI_MEDIATEK=y BR2_PACKAGE_FEATURE_WIFI_QUALCOMM=y BR2_PACKAGE_FEATURE_WIFI_REALTEK=y diff --git a/doc/test-arch.md b/doc/test-arch.md index 04f63c5d5..546f42cb1 100644 --- a/doc/test-arch.md +++ b/doc/test-arch.md @@ -297,6 +297,36 @@ image. The device will boot into test mode on first power-on. > unless `startup_override()` is called first (or the marker is removed > and a normal startup config is saved). +Simulated Wi-Fi +--------------- + +The virtual topology has no real radios, so Wi-Fi tests run against +`mac80211_hwsim` devices and the "air" between guests is faked. A small +relay, `wifimedium` (`package/feature-wifi/wifimedium`), takes every frame +a radio transmits, forwards it to the other guests, and injects what +arrives back into the local radios. + +Each radio (`radio0`, `radio1`, ...) is paired with a carrier NIC of the +same name, and that carrier joins the multicast group (a *cell*) the +topology wires it to. Two radios hear each other only when their carriers +share a cell. Radios on the same cell that tune to different channels are +still kept apart, as on real hardware. + +By convention the two general cells carry `radio0` and `radio1` across all +DUTs, so most tests put communicating radios on the same index: a station +joins an AP on the same `radioN`. The mesh-roaming test, for instance, +uses `radio1` for both the gateway APs and the client. That is only a +convention, though. A cell can carry any set of radios, including two of +the *same* DUT. The band-steering test relies on that: it wires one DUT's +`radio2` (2.4GHz) and `radio3` (5GHz) onto a dedicated cell so a single +client radio hears the same SSID on both bands. + +How many radios each DUT gets depends on the topology. The number of +`radioN` ports wired to a node is passed in via the `opt/wifi` fw_cfg and +loaded at boot by the `00-hwsim` script, so a node with no wifi links has +no radios at all. The wiring for the standard suite lives in +`test/virt/quad`. + [9PM]: https://github.com/rical/9pm [Qeneth]: https://github.com/wkz/qeneth [TAP]: https://testanything.org/ diff --git a/package/feature-wifi/00-hwsim b/package/feature-wifi/00-hwsim new file mode 100755 index 000000000..15701d027 --- /dev/null +++ b/package/feature-wifi/00-hwsim @@ -0,0 +1,47 @@ +#!/bin/sh +# Load mac80211_hwsim virtual radios sized to this DUT's topology, then wait +# for udev to rename the phys (phyN -> radioN) before returning. +# +# The radio count comes from a dedicated fw_cfg file (opt/wifi) that qeneth +# writes per node from the number of radioN carrier ports the topology wired +# to it. A node with no wifi links gets 0 (or no file), so hwsim is not loaded +# at all -- no phys, nothing clutters its interface view. +# +# Runs early in ixinit, before 00-probe, so the system probe records the radios +# by their final radioN names. ixinit is past the / udev-settle +# barrier, so 60-rename-wifi-phy.rules (phyN -> radioN) and +# 70-remove-virtual-wifi-interfaces.rules (drop the stray wlanN) are loaded and +# fire on the phy add events. On success it sets the condition so +# wifimedium starts only where radios exist. Installed only with +# BR2_PACKAGE_FEATURE_WIFI_HWSIM. + +wifi=/sys/firmware/qemu_fw_cfg/by_name/opt/wifi/raw +ieee=/sys/class/ieee80211 + +# Number of radios to create, from the topology (via fw_cfg). +radios=0 +[ -r "$wifi" ] && radios=$(cat "$wifi" 2>/dev/null) + +# No radios wanted -> don't load hwsim, keep the view wifi-free. +[ "$radios" -gt 0 ] 2>/dev/null || exit 0 + +modprobe mac80211_hwsim radios="$radios" || exit 0 + +# Wait (bounded ~10s) for the phyN -> radioN rename to finish: the class has +# entries and none are still named phyN. The rename is a udev RUN, so it lags +# modprobe's return. On timeout the radios never settled -- fail (and log) so +# it is visible why 00-probe may record stale phyN names. +TIMEOUT=100 +i=0 +while [ "$i" -lt "$TIMEOUT" ]; do + names=$(ls "$ieee" 2>/dev/null) + if [ -n "$names" ] && ! echo "$names" | grep -q '^phy'; then + initctl -bq cond set hwsim + exit 0 + fi + i=$((i + 1)) + sleep 0.1 +done + +logger -p user.err -t 00-hwsim "timed out waiting for hwsim phys to be renamed radioN" +exit 1 diff --git a/package/feature-wifi/Config.in b/package/feature-wifi/Config.in index 16fe42945..6693b446e 100644 --- a/package/feature-wifi/Config.in +++ b/package/feature-wifi/Config.in @@ -18,6 +18,18 @@ config BR2_PACKAGE_FEATURE_WIFI help Enables WiFi in Infix. Enables all requried applications. +config BR2_PACKAGE_FEATURE_WIFI_HWSIM + bool "Virtual Wi-Fi radios (mac80211_hwsim)" + depends on BR2_PACKAGE_FEATURE_WIFI + help + Build the mac80211_hwsim virtual radio driver (as a module) for the + QEMU test target, to exercise AP, station, and mesh modes without + physical WiFi adapters. The module is autoloaded at boot (see the + x86_64 /etc/modules-load.d) and frames are bridged between guests by + the wifimedium relay. + + Leave disabled on real hardware; it only provides simulated radios. + config BR2_PACKAGE_FEATURE_WIFI_MEDIATEK bool "Mediatek WiFi Devices" depends on BR2_PACKAGE_FEATURE_WIFI diff --git a/package/feature-wifi/feature-wifi.mk b/package/feature-wifi/feature-wifi.mk index 822e96204..162641d4c 100644 --- a/package/feature-wifi/feature-wifi.mk +++ b/package/feature-wifi/feature-wifi.mk @@ -14,6 +14,15 @@ define FEATURE_WIFI_LINUX_CONFIG_FIXUPS $(call KCONFIG_SET_OPT,CONFIG_CFG80211,m) $(call KCONFIG_ENABLE_OPT,CONFIG_MAC80211_MESH) + # Virtual radio for automated testing (mac80211_hwsim), built as a + # module and gated behind BR2_PACKAGE_FEATURE_WIFI_HWSIM so it is not + # built on real boards. When selected, the module is loaded early in + # ixinit by 00-hwsim -- sized to the DUT's topology, so radio-less nodes + # load nothing -- and bridged between QEMU guests by the wifimedium relay + # (both installed by FEATURE_WIFI_INSTALL_HWSIM below). + $(if $(BR2_PACKAGE_FEATURE_WIFI_HWSIM), + $(call KCONFIG_SET_OPT,CONFIG_MAC80211_HWSIM,m)) + $(if $(filter y,$(BR2_PACKAGE_FEATURE_WIFI_MEDIATEK)), $(call KCONFIG_ENABLE_OPT,CONFIG_MT7601U) $(call KCONFIG_ENABLE_OPT,CONFIG_MT76x0U) @@ -76,5 +85,20 @@ define FEATURE_WIFI_INSTALL_IN_ROMFS endef FEATURE_WIFI_POST_INSTALL_TARGET_HOOKS += FEATURE_WIFI_INSTALL_IN_ROMFS +# Virtual-radio (mac80211_hwsim) runtime, installed only when the option is +# selected: the 00-hwsim ixinit script loads the module sized to the opt/wifi +# fw_cfg qeneth writes per node (no radios -> not loaded), and the wifimedium +# relay + its Finit service bridge hwsim frames between QEMU guests so they can +# associate "wirelessly". +ifeq ($(BR2_PACKAGE_FEATURE_WIFI_HWSIM),y) +define FEATURE_WIFI_INSTALL_HWSIM + $(INSTALL) -D -m 0755 $(FEATURE_WIFI_PKGDIR)/00-hwsim $(TARGET_DIR)/usr/libexec/infix/init.d/00-hwsim + $(INSTALL) -D -m 0755 $(FEATURE_WIFI_PKGDIR)/wifimedium $(TARGET_DIR)/usr/libexec/infix/wifimedium + $(INSTALL) -D -m 0644 $(FEATURE_WIFI_PKGDIR)/wifimedium.conf $(TARGET_DIR)/etc/finit.d/available/wifimedium.conf + ln -sf ../available/wifimedium.conf $(TARGET_DIR)/etc/finit.d/enabled/wifimedium.conf +endef +FEATURE_WIFI_POST_INSTALL_TARGET_HOOKS += FEATURE_WIFI_INSTALL_HWSIM +endif + $(eval $(generic-package)) diff --git a/package/feature-wifi/wifimedium b/package/feature-wifi/wifimedium new file mode 100755 index 000000000..58aeb4d84 --- /dev/null +++ b/package/feature-wifi/wifimedium @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""wifimedium - a virtual WiFi medium for mac80211_hwsim, extended over Ethernet. + +mac80211_hwsim simulates 802.11 radios in software. By default all radios in +one kernel share an in-kernel medium; radios in *different* kernels (i.e. +different QEMU guests) cannot hear each other. The moment a userspace process +registers on the hwsim generic-netlink family, the kernel stops delivering +frames itself and hands every transmitted frame to that process, which becomes +responsible for delivery. + +This daemon is that process. On each DUT it: + + * registers on the "mac80211_hwsim" genl family and receives every frame the + local radios transmit (HWSIM_CMD_FRAME), + * acknowledges each transmit back to the kernel (HWSIM_CMD_TX_INFO_FRAME) so + mac80211's TX path completes, and + * relays the frame per radio: each radio (phy radioN) is paired by name with a + carrier NIC (netdev radioN) that joins one multicast "cell" -- a QEMU socket + multicast group shared by every DUT's radioN (see test/virt/quad and + test/qeneth). A radio's frame is sent only onto its own carrier, and a + carrier's frames are injected only into its own radio + (HWSIM_CMD_FRAME with HWSIM_ATTR_ADDR_RECEIVER). + +Each cell is a shared medium: every DUT's radioN is "in range" of every other +radioN. Radios on different indices are on different cells, so two radios +communicate only if they share an index -- the convention the test topologies +follow (a station uses the same radio index as the AP it joins). Channel +filtering in mac80211 still separates radios that share a cell but tune to +different frequencies. + +This is intended for the QEMU/qeneth test topology only. On real hardware the +radios use the air, there are no radioN carrier netdevs, and this daemon idles. +See doc/wifi.md. +""" +import os +import socket +import struct +import time +import subprocess +import sys +import select + +# ---- generic netlink / hwsim ABI (drivers/net/wireless/virtual/mac80211_hwsim.h) +NETLINK_GENERIC = 16 +GENL_ID_CTRL = 16 +CTRL_CMD_GETFAMILY = 3 +CTRL_ATTR_FAMILY_ID = 1 +CTRL_ATTR_FAMILY_NAME = 2 +CTRL_ATTR_MCAST_GROUPS = 7 +CTRL_ATTR_MCAST_GRP_NAME = 1 +CTRL_ATTR_MCAST_GRP_ID = 2 + +SOL_NETLINK = 270 +NETLINK_ADD_MEMBERSHIP = 1 + +HWSIM_CMD_REGISTER = 1 +HWSIM_CMD_FRAME = 2 +HWSIM_CMD_TX_INFO_FRAME = 3 + +HWSIM_ATTR_ADDR_RECEIVER = 1 +HWSIM_ATTR_ADDR_TRANSMITTER = 2 +HWSIM_ATTR_FRAME = 3 +HWSIM_ATTR_FLAGS = 4 +HWSIM_ATTR_RX_RATE = 5 +HWSIM_ATTR_SIGNAL = 6 +HWSIM_ATTR_TX_INFO = 7 +HWSIM_ATTR_COOKIE = 8 +HWSIM_ATTR_FREQ = 19 +HWSIM_ATTR_TX_INFO_FLAGS = 21 + +HWSIM_TX_STAT_ACK = 1 << 2 + +# Signal/rate reported for injected (received) frames. A strong, fixed signal +# keeps mac80211 happy; the exact value is not significant for a virtual cell. +RX_SIGNAL = -30 +RX_RATE = 0 + +# The medium is carried as raw Ethernet frames with a custom EtherType on the +# configured interface -- no IP addressing needed (the carrier link has none). +# A broadcast destination means every DUT on the segment is "in range". +ETH_P_WIFIMEDIUM = 0x88B5 # local-experimental EtherType +ETH_BROADCAST = b"\xff\xff\xff\xff\xff\xff" +ETYPE = struct.pack("!H", ETH_P_WIFIMEDIUM) +SOL_PACKET = 263 +PACKET_IGNORE_OUTGOING = 23 # don't deliver our own TX back to the socket + +NLMSG_HDR = struct.Struct("=IHHII") # len, type, flags, seq, pid +GENL_HDR = struct.Struct("=BBH") # cmd, version, reserved + + +DEBUG = bool(os.environ.get("WIFIMEDIUM_DEBUG")) + + +def log(msg): + sys.stderr.write(f"wifimedium: {msg}\n") + sys.stderr.flush() + + +def dbg(msg): + if DEBUG: + log(msg) + + +def nla_align(n): + return (n + 3) & ~3 + + +def put_attr(atype, payload): + hdr = struct.pack("=HH", len(payload) + 4, atype) + return hdr + payload + b"\0" * (nla_align(len(payload) + 4) - (len(payload) + 4)) + + +def parse_attrs(buf): + attrs = {} + off = 0 + while off + 4 <= len(buf): + alen, atype = struct.unpack_from("=HH", buf, off) + if alen < 4: + break + attrs[atype & 0x7fff] = buf[off + 4:off + alen] + off += nla_align(alen) + return attrs + + +class Netlink: + """Minimal generic-netlink client for the hwsim family.""" + + def __init__(self): + self.sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_GENERIC) + self.sock.bind((0, 0)) + self.seq = 0 + # NB: the genl family is "MAC80211_HWSIM" (uppercase); the lowercase + # "mac80211_hwsim" is the platform driver name, not the family. + self.family_id, self.mcast_id = self._resolve("MAC80211_HWSIM", "config") + + def fileno(self): + return self.sock.fileno() + + def _send(self, family_id, cmd, attrs=b"", flags=0): + self.seq += 1 + payload = GENL_HDR.pack(cmd, 1, 0) + attrs + msg = NLMSG_HDR.pack(NLMSG_HDR.size + len(payload), family_id, + flags | 1, self.seq, 0) + payload # NLM_F_REQUEST + self.sock.send(msg) + + def _resolve(self, name, grpname): + self._send(GENL_ID_CTRL, CTRL_CMD_GETFAMILY, + put_attr(CTRL_ATTR_FAMILY_NAME, name.encode() + b"\0")) + data = self.sock.recv(65536) + _, mtype, _, _, _ = NLMSG_HDR.unpack_from(data, 0) + if mtype == 2: # NLMSG_ERROR (also used for ACKs; err<0 is a failure) + err = struct.unpack_from("=i", data, NLMSG_HDR.size)[0] + raise RuntimeError(f"genl GETFAMILY({name}) failed: " + f"{os.strerror(-err) if err else 'empty reply'}") + attrs = parse_attrs(data[NLMSG_HDR.size + GENL_HDR.size:]) + family_id = struct.unpack("=H", attrs[CTRL_ATTR_FAMILY_ID][:2])[0] + + mcast_id = None + for _, grp in _parse_nested(attrs.get(CTRL_ATTR_MCAST_GROUPS, b"")): + gattrs = parse_attrs(grp) + gname = gattrs.get(CTRL_ATTR_MCAST_GRP_NAME, b"").rstrip(b"\0").decode() + if gname == grpname: + mcast_id = struct.unpack("=I", gattrs[CTRL_ATTR_MCAST_GRP_ID])[0] + if mcast_id is None: + raise RuntimeError(f"hwsim multicast group '{grpname}' not found") + return family_id, mcast_id + + def register(self): + self.sock.setsockopt(SOL_NETLINK, NETLINK_ADD_MEMBERSHIP, self.mcast_id) + self._send(self.family_id, HWSIM_CMD_REGISTER) + + def recv(self): + return self.sock.recv(65536) + + def inject(self, receiver, frame, freq): + attrs = put_attr(HWSIM_ATTR_ADDR_RECEIVER, receiver) + attrs += put_attr(HWSIM_ATTR_FRAME, frame) + attrs += put_attr(HWSIM_ATTR_RX_RATE, struct.pack("=I", RX_RATE)) + attrs += put_attr(HWSIM_ATTR_SIGNAL, struct.pack("=i", RX_SIGNAL)) + if freq: + attrs += put_attr(HWSIM_ATTR_FREQ, struct.pack("=I", freq)) + self._send(self.family_id, HWSIM_CMD_FRAME, attrs) + + def tx_ack(self, transmitter, flags, tx_info, tx_info_flags, cookie): + attrs = put_attr(HWSIM_ATTR_ADDR_TRANSMITTER, transmitter) + attrs += put_attr(HWSIM_ATTR_FLAGS, struct.pack("=I", flags | HWSIM_TX_STAT_ACK)) + attrs += put_attr(HWSIM_ATTR_SIGNAL, struct.pack("=i", RX_SIGNAL)) + if tx_info: + attrs += put_attr(HWSIM_ATTR_TX_INFO, tx_info) + if tx_info_flags: + attrs += put_attr(HWSIM_ATTR_TX_INFO_FLAGS, tx_info_flags) + if cookie: + attrs += put_attr(HWSIM_ATTR_COOKIE, cookie) + self._send(self.family_id, HWSIM_CMD_TX_INFO_FRAME, attrs) + + +def _parse_nested(buf): + off = 0 + while off + 4 <= len(buf): + alen, atype = struct.unpack_from("=HH", buf, off) + if alen < 4: + break + yield atype & 0x7fff, buf[off + 4:off + alen] + off += nla_align(alen) + + +def radio_addr1(phy): + """The address mac80211_hwsim identifies a radio by on the medium. + + A hwsim radio has three addresses (kernel new_radio()). sysfs 'macaddress' + is addresses[0]; the address the kernel stamps as HWSIM_ATTR_ADDR_TRANSMITTER + and matches HWSIM_ATTR_ADDR_RECEIVER against (its rhashtable key) is + addresses[1] -- addresses[0] with bit 0x40 set in the first octet, for the + default no-perm_addr radios we use. So both the transmitter we observe and + the receiver we must inject to are this 0x40 variant, not the sysfs value. + (Note custom-phys-address changes the vif/netdev MAC, not these.) + """ + with open(f"/sys/class/ieee80211/{phy}/macaddress") as fh: + addr = bytearray.fromhex(fh.read().strip().replace(":", "")) + addr[0] |= 0x40 + return bytes(addr) + + +def open_medium(ifname): + """Raw-Ethernet socket on the medium interface, or (None, None). + + Returns (sock, mac); mac is the interface's own (unique) address, used as + the Ethernet source and to drop frames we transmitted ourselves -- the + multicast cell loops our own frames back to us.""" + if not ifname: + return None, None + # The carrier NIC is unconfigured (it is not a real DUT port), so Infix + # leaves it administratively down -- a raw packet socket on a down link + # carries no frames. Bring it up; wifimedium owns the medium, like hwsim0. + ifup(ifname) + sock = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, + socket.htons(ETH_P_WIFIMEDIUM)) + sock.bind((ifname, ETH_P_WIFIMEDIUM)) + # Don't receive our own transmitted frames (avoids a self-feedback path + # and any reliance on the source MAC, which can collide between guests). + try: + sock.setsockopt(SOL_PACKET, PACKET_IGNORE_OUTGOING, 1) + except OSError: + pass # pre-4.20 kernels; the length check below still applies + with open(f"/sys/class/net/{ifname}/address") as fh: + mac = bytes.fromhex(fh.read().strip().replace(":", "")) + return sock, mac + + +# Wire format on the medium: 6-byte transmitter MAC, 4-byte LE freq, then the +# raw 802.11 frame. +WIRE = struct.Struct("=6sI") + + +def wait_radios_renamed(timeout=20): + """Wait until the hwsim phys have been renamed phyN -> radioN. + + 60-rename-wifi-phy.rules renames each phy *asynchronously* (a udev RUN), + and the carrier NICs are already named radioN (mactab/nameif, early boot). + We pair phy and carrier by name, so discovering while a phy is still 'phyN' + would miss it -- and we discover only once. This raced our start (gated + merely on the module load) and left some boots relaying nothing until a + restart. Block until every phy is renamed (no 'phyN' left) and the set has + settled, so discovery sees radioN phys that match the radioN carriers. + """ + base = "/sys/class/ieee80211" + prev, stable = None, 0 + for _ in range(timeout * 4): + phys = sorted(os.listdir(base)) if os.path.isdir(base) else [] + if phys and not any(p.startswith("phy") for p in phys): + if phys == prev: + stable += 1 + if stable >= 2: # unchanged for ~0.5s + return + else: + stable = 0 + prev = phys + time.sleep(0.25) + + +def discover_radios(): + """Pair each local hwsim radio with its multicast carrier interface. + + A radio's phy is named radioN (60-rename-wifi-phy.rules) under + /sys/class/ieee80211/. In the qeneth test topology each radio also has a + carrier NIC of the SAME name under /sys/class/net/ -- the topology names the + port radioN, so the mactab/nameif rename the carrier to match -- joined to + one multicast cell shared by every DUT's radioN. Pairing them by name gives + a per-radio relay: a radio's frames go onto its own carrier (its cell) and a + carrier's frames are injected only into its own radio. + + Returns a list of {name, addr1, sock, mac}. A phy with no matching carrier + netdev (e.g. real hardware) is skipped, so the daemon simply idles there. + """ + wait_radios_renamed() + radios = [] + base = "/sys/class/ieee80211" + if not os.path.isdir(base): + return radios + for phy in sorted(os.listdir(base)): + if not os.path.isdir(f"/sys/class/net/{phy}"): + continue # no carrier of this name -> not a relayed radio + sock, mac = open_medium(phy) + if sock: + radios.append({"name": phy, "addr1": radio_addr1(phy), + "sock": sock, "mac": mac}) + return radios + + +def ifup(ifname): + """Bring an interface up (best effort).""" + subprocess.run(["ip", "link", "set", ifname, "up"], check=False) + + +def main(): + # The hwsim monitor interface is down after boot; bring it up so the + # simulated medium is observable (tcpdump -i hwsim0) and active. + ifup("hwsim0") + + # Discover carriers first. With none -- e.g. 'make run', which creates + # radios but no inter-guest medium -- registering on the hwsim family would + # make the kernel hand us every frame with nowhere to relay it, breaking + # delivery between this guest's own radios. So leave the medium to the + # kernel and idle instead of registering. + radios = discover_radios() + if not radios: + log("no radio carriers found; leaving local hwsim medium to the kernel") + while True: + time.sleep(3600) + + nl = Netlink() + nl.register() + log(f"registered on hwsim family {nl.family_id}; " + f"radios={' '.join(r['name'] for r in radios)}") + + # TX routing: a frame's transmitter (addresses[1]) -> that radio's carrier. + by_addr1 = {r["addr1"]: r for r in radios} + dbg(f"netlink portid={nl.sock.getsockname()[0]}; " + + "; ".join(f"{r['name']} addr1={r['addr1'].hex()} " + f"carrier={r['mac'].hex()}" for r in radios)) + + socks = [nl] + [r["sock"] for r in radios] + while True: + ready, _, _ = select.select(socks, [], []) + + if nl in ready: + data = nl.recv() + off = 0 + while off + NLMSG_HDR.size <= len(data): + mlen, mtype, _, _, _ = NLMSG_HDR.unpack_from(data, off) + if mlen < NLMSG_HDR.size: + break + if mtype == nl.family_id and \ + data[off + NLMSG_HDR.size] == HWSIM_CMD_FRAME: + a = parse_attrs(data[off + NLMSG_HDR.size + GENL_HDR.size: + off + mlen]) + tx = a.get(HWSIM_ATTR_ADDR_TRANSMITTER) + frame = a.get(HWSIM_ATTR_FRAME) + if tx and frame: + freq = struct.unpack("=I", a[HWSIM_ATTR_FREQ])[0] \ + if HWSIM_ATTR_FREQ in a else 0 + flags = struct.unpack("=I", a[HWSIM_ATTR_FLAGS])[0] \ + if HWSIM_ATTR_FLAGS in a else 0 + # Complete the kernel TX path regardless of delivery. + nl.tx_ack(tx, flags, a.get(HWSIM_ATTR_TX_INFO), + a.get(HWSIM_ATTR_TX_INFO_FLAGS), + a.get(HWSIM_ATTR_COOKIE)) + # Send only onto the transmitting radio's own carrier. + r = by_addr1.get(tx) + if r: + r["sock"].send(ETH_BROADCAST + r["mac"] + ETYPE + + WIRE.pack(tx, freq) + frame) + dbg(f"tx {r['name']} freq={freq} len={len(frame)}") + else: + dbg(f"tx from unknown radio {tx.hex()} -- dropped") + off += nla_align(mlen) + + for r in radios: + if r["sock"] not in ready: + continue + pkt = r["sock"].recv(65536) + if len(pkt) < 14 + WIRE.size: + continue + # The cell multicasts our own frames back to us; PACKET_IGNORE_- + # OUTGOING does not catch that looped copy, so drop anything whose + # Ethernet source is our own carrier (carrier MACs are unique per + # DUT; transmitter addrs are not, so we must key on the carrier). + if pkt[6:12] == r["mac"]: + continue + tx, freq = WIRE.unpack_from(pkt, 14) + frame = pkt[14 + WIRE.size:] + # Inject into THIS radio only -- its carrier is its cell. + nl.inject(r["addr1"], frame, freq) + dbg(f"rx {r['name']} tx={tx.hex()} freq={freq} len={len(frame)}") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + pass diff --git a/package/feature-wifi/wifimedium.conf b/package/feature-wifi/wifimedium.conf new file mode 100644 index 000000000..9154fd294 --- /dev/null +++ b/package/feature-wifi/wifimedium.conf @@ -0,0 +1,14 @@ +# Virtual WiFi medium for mac80211_hwsim (QEMU test target). +# +# Installed only when BR2_PACKAGE_FEATURE_WIFI_HWSIM is set. Bridges hwsim +# frames between the local radios and -- when a medium interface is configured +# (WIFIMEDIUM_IFACE env or 'wifimedium.iface=' on the kernel cmdline) -- to +# peer DUTs over that Ethernet segment, so two QEMU guests can associate +# "wirelessly". hwsim is loaded by the 00-hwsim ixinit script, which sets the +# condition once radios exist and are renamed -- so this service +# starts only on nodes that actually have radios (registering on the hwsim genl +# family would fail otherwise). On real hardware neither is shipped and the +# radios use the air. +service env:-/etc/default/wifimedium name:wifimedium \ + [2345] \ + /usr/libexec/infix/wifimedium -- Virtual WiFi medium (hwsim) diff --git a/src/confd/src/core.c b/src/confd/src/core.c index a0569b688..ef5f929ed 100644 --- a/src/confd/src/core.c +++ b/src/confd/src/core.c @@ -571,14 +571,6 @@ static int change_cb(sr_session_ctx_t *session, uint32_t sub_id, const char *mod } max_dep--; } -#if 0 - /* Debug: print diff to file */ - FILE *f = fopen("/tmp/confd-diff.json", "w"); - if (f) { - lyd_print_file(f, diff, LYD_JSON, LYD_PRINT_SIBLINGS); - fclose(f); - } -#endif } /* ietf-hardware */ @@ -728,8 +720,11 @@ int sr_plugin_init_cb(sr_session_ctx_t *session, void **priv) goto err; confd.root = json_load_file("/run/system.json", 0, NULL); - if (!confd.root) + if (!confd.root) { + ERROR("failed loading /run/system.json, system probe incomplete"); + rc = SR_ERR_SYS; goto err; + } /* An optional file that contains hardware specific quirks for * the network interfaces on running board. diff --git a/test/case/interfaces/all.yaml b/test/case/interfaces/all.yaml index e5db2e85d..d6584c710 100644 --- a/test/case/interfaces/all.yaml +++ b/test/case/interfaces/all.yaml @@ -52,3 +52,6 @@ - name: Layer-2/3 Tunnel Tests suite: tunnels.yaml + +- name: WiFi Tests + suite: wifi.yaml diff --git a/test/case/interfaces/wifi.yaml b/test/case/interfaces/wifi.yaml new file mode 100644 index 000000000..974e35e2b --- /dev/null +++ b/test/case/interfaces/wifi.yaml @@ -0,0 +1,12 @@ +--- +- name: WiFi AP and Station Association across two DUTs + case: wifi_ap_station_2dut/test.py + +- name: WiFi Access Point serving three Stations + case: wifi_ap_multi_station/test.py + +- name: WiFi Mesh backhaul with roaming Access Points + case: wifi_mesh_roaming/test.py + +- name: WiFi Band Steering across a dual-band Access Point + case: wifi_band_steering/test.py diff --git a/test/case/interfaces/wifi_ap_multi_station/Readme.adoc b/test/case/interfaces/wifi_ap_multi_station/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_ap_multi_station/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_ap_multi_station/test.adoc b/test/case/interfaces/wifi_ap_multi_station/test.adoc new file mode 100644 index 000000000..5c0f1706e --- /dev/null +++ b/test/case/interfaces/wifi_ap_multi_station/test.adoc @@ -0,0 +1,46 @@ +=== WiFi Access Point serving three Stations + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_ap_multi_station] + +==== Description + +One AP and three Stations, each on its own DUT: the ap runs a +WPA2/WPA3-personal Access Point on radio0, and station1, station2 and +station3 each associate to it. The radios are in separate kernels, so the +"air" between them is realised differently per environment: + + * On real hardware the links are real RF -- the stations just associate. + * On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on each + DUT bridges hwsim frames over the Ethernet segments between the guests + (the topology's wifi-links). See doc/wifi.md. + +This is the multi-client case: a single AP cell must serve more than one +station at once. Each station associating with WPA2/WPA3-personal is a +strong end-to-end check -- authentication, association and the WPA 4-way +handshake all require frames to cross the medium in *both* directions -- so +three successful associations prove the AP keeps several stations +associated simultaneously over the (relayed) radio links. It then confirms +data-plane reach: the ap serves DHCP and each station leases an address +from the pool over the radio link. + +Topology: +.... + ((( station1 ==(mgmt)== host + host ==(mgmt)== ap ((( station2 ==(mgmt)== host + ((( station3 ==(mgmt)== host +.... + +==== Topology + +image::topology.svg[WiFi Access Point serving three Stations topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to the ap and three stations +. Configure the ap as an Access Point on radio0 +. Configure the station on radio0 +. Verify the station associates to the ap over the wifi link +. Verify the station's wifi0 operational status is up +. Verify the station leases an address from the ap over wifi + + diff --git a/test/case/interfaces/wifi_ap_multi_station/test.py b/test/case/interfaces/wifi_ap_multi_station/test.py new file mode 100755 index 000000000..3cb9fa79e --- /dev/null +++ b/test/case/interfaces/wifi_ap_multi_station/test.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +r""" +WiFi Access Point serving multiple Stations + +One AP and three Stations, each on its own DUT: the ap runs a +WPA2/WPA3-personal Access Point on radio0, and station1, station2 and +station3 each associate to it. The radios are in separate kernels, so the +"air" between them is realised differently per environment: + + * On real hardware the links are real RF -- the stations just associate. + * On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on each + DUT bridges hwsim frames over the Ethernet segments between the guests + (the topology's wifi-links). See doc/wifi.md. + +This is the multi-client case: a single AP cell must serve more than one +station at once. Each station associating with WPA2/WPA3-personal is a +strong end-to-end check -- authentication, association and the WPA 4-way +handshake all require frames to cross the medium in *both* directions -- so +three successful associations prove the AP keeps several stations +associated simultaneously over the (relayed) radio links. It then confirms +data-plane reach: the ap serves DHCP and each station leases an address +from the pool over the radio link. + +Topology: +.... + ((( station1 ==(mgmt)== host + host ==(mgmt)== ap ((( station2 ==(mgmt)== host + ((( station3 ==(mgmt)== host +.... +""" +import base64 + +import infamy +import infamy.iface as iface +from infamy.util import until, parallel + +SSID = "infix-test" +PSK = "infixinfix" # 8-63 printable ASCII, see doc/wifi.md +PSK_B64 = base64.b64encode(PSK.encode()).decode() +COUNTRY = "SE" + +# hwsim defaults every radio0 to 02:00:00:00:00:00, so the AP and all the +# stations would otherwise share one MAC -- give each DUT a unique address. +STATIONS = [ + ("station1", "02:00:00:00:00:02"), + ("station2", "02:00:00:00:00:03"), + ("station3", "02:00:00:00:00:04"), +] +AP_MAC = "02:00:00:00:00:01" + +# The ap runs a DHCP server on wifi0; every station leases an address from +# the pool. A completed lease is real bidirectional IP traffic over the +# radio link, so a lease per station proves all three reach the ap at L3. +SUBNET = "192.168.20.0/24" +AP_IP = "192.168.20.1" +POOL_START = "192.168.20.100" +POOL_END = "192.168.20.102" # three addresses, one per station + + +def radio(name, band=None, channel=None): + """ietf-hardware component for a radio.""" + wifi_radio = {"country-code": COUNTRY} + if band: + wifi_radio["band"] = band + if channel is not None: + wifi_radio["channel"] = channel + return { + "name": name, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": wifi_radio, + } + + +def keystore(): + return { + "keystore": { + "symmetric-keys": { + "symmetric-key": [{ + "name": "wifi", + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": PSK_B64, + }] + } + } + } + + +def wifi_iface(mac, wifi, ipv4=None): + ifc = { + "name": "wifi0", + "type": "infix-if-type:wifi", + "infix-interfaces:custom-phys-address": {"static": mac}, + "infix-interfaces:wifi": wifi, + } + if ipv4: + ifc["ietf-ip:ipv4"] = ipv4 + return {"interfaces": {"interface": [ifc]}} + + +def leased(dut): + """True once wifi0 holds an address handed out by the ap's DHCP server.""" + for addr in iface.get_ipv4_address(dut, "wifi0") or []: + if addr.get("origin") == "dhcp" and \ + POOL_START <= addr.get("ip", "") <= POOL_END: + return True + return False + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the ap and three stations"): + env = infamy.Env() + # Connect to the AP and all stations concurrently to cut setup time. + ap, *station_duts = parallel( + lambda: env.attach("ap", "mgmt"), + *((lambda n=name: env.attach(n, "mgmt")) for name, _ in STATIONS), + ) + stations = [(name, mac, dut) + for (name, mac), dut in zip(STATIONS, station_duts)] + + for dut in [ap] + [s for _, _, s in stations]: + if not dut.has_feature("infix-interfaces", "wifi"): + print("DUT does not advertise the 'wifi' feature -- skipping") + test.skip() + + with test.step("Configure the ap as an Access Point on radio0"): + ap.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [ + radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": wifi_iface(AP_MAC, { + "radio": "radio0", + "access-point": { + "ssid": SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"}, + }, + }, ipv4={"address": [{"ip": AP_IP, "prefix-length": 24}]}), + "infix-dhcp-server": {"dhcp-server": {"subnet": [{ + "subnet": SUBNET, + "pool": {"start-address": POOL_START, "end-address": POOL_END}, + }]}}, + }) + + for name, mac, dut in stations: + with test.step("Configure the station on radio0"): + print(f"Configuring {name}") + dut.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [radio("radio0")]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": wifi_iface(mac, { + "radio": "radio0", + "station": { + "ssid": SSID, + "security": {"mode": "auto", "secret": "wifi"}, + }, + }, ipv4={"infix-dhcp-client:dhcp": {}}), + }) + + for name, _mac, dut in stations: + with test.step("Verify the station associates to the ap over the wifi link"): + print(f"Verifying {name}") + def associated(dut=dut): + ifc = dut.get_iface("wifi0") + if not ifc: + return False + wifi = ifc.get("infix-interfaces:wifi") or ifc.get("wifi") or {} + station = wifi.get("station", {}) + return station.get("ssid") == SSID and \ + station.get("signal-strength") is not None + until(associated, attempts=60, interval=2) + + for name, _mac, dut in stations: + with test.step("Verify the station's wifi0 operational status is up"): + print(f"Verifying {name}") + until(lambda dut=dut: iface.is_oper_up(dut, "wifi0"), attempts=30) + + for name, _mac, dut in stations: + with test.step("Verify the station leases an address from the ap over wifi"): + print(f"Verifying {name}") + until(lambda dut=dut: leased(dut), attempts=60, interval=2) + + test.succeed() diff --git a/test/case/interfaces/wifi_ap_multi_station/topology.dot b/test/case/interfaces/wifi_ap_multi_station/topology.dot new file mode 100644 index 000000000..1d6cfb175 --- /dev/null +++ b/test/case/interfaces/wifi_ap_multi_station/topology.dot @@ -0,0 +1,61 @@ +graph "wifi-ap-multi-station" { + layout="neato"; + overlap="false"; + esep="+40"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | mgmt2 | mgmt3 | mgmt4 }", + pos="0,0!", + requires="controller", + ]; + + ap [ + label="{ mgmt | wifi } | ap", + pos="6,0!", + requires="infix", + ]; + + station1 [ + label="{ mgmt | wifi } | station1", + pos="11,3!", + requires="infix", + ]; + + station2 [ + label="{ mgmt | wifi } | station2", + pos="11,0!", + requires="infix", + ]; + + station3 [ + label="{ mgmt | wifi } | station3", + pos="11,-3!", + requires="infix", + ]; + + // One AP cell shared by the ap and all three stations: a single medium + // node every radio joins (same index -> same cell). The ap's one radio + // must be in range of every station, which the mapper guarantees by + // placing all four on this shared medium: + // * physical: maps onto one over-the-air RF cell; + // * virtual (qeneth): maps onto one multicast cell the wifimedium + // relay bridges mac80211_hwsim frames over. + cell [ + label="cell", + pos="8.5,0!", + requires="wifi-radio", + ]; + + host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- station1:mgmt [requires="mgmt", color="lightgray"] + host:mgmt3 -- station2:mgmt [requires="mgmt", color="lightgray"] + host:mgmt4 -- station3:mgmt [requires="mgmt", color="lightgray"] + + ap:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + station1:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + station2:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + station3:wifi -- cell [requires="wifi2.4GHz", style="dashed"] +} diff --git a/test/case/interfaces/wifi_ap_multi_station/topology.svg b/test/case/interfaces/wifi_ap_multi_station/topology.svg new file mode 100644 index 000000000..278be14e8 --- /dev/null +++ b/test/case/interfaces/wifi_ap_multi_station/topology.svg @@ -0,0 +1,112 @@ + + + + + + +wifi-ap-multi-station + + + +host + +host + +mgmt1 + +mgmt2 + +mgmt3 + +mgmt4 + + + +ap + +mgmt + +wifi + +ap + + + +host:mgmt1--ap:mgmt + + + + +station1 + +mgmt + +wifi + +station1 + + + +host:mgmt2--station1:mgmt + + + + +station2 + +mgmt + +wifi + +station2 + + + +host:mgmt3--station2:mgmt + + + + +station3 + +mgmt + +wifi + +station3 + + + +host:mgmt4--station3:mgmt + + + + +cell + +cell + + + +ap:wifi--cell + + + + +station1:wifi--cell + + + + +station2:wifi--cell + + + + +station3:wifi--cell + + + + diff --git a/test/case/interfaces/wifi_ap_station_2dut/Readme.adoc b/test/case/interfaces/wifi_ap_station_2dut/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_ap_station_2dut/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_ap_station_2dut/test.adoc b/test/case/interfaces/wifi_ap_station_2dut/test.adoc new file mode 100644 index 000000000..85e77f88b --- /dev/null +++ b/test/case/interfaces/wifi_ap_station_2dut/test.adoc @@ -0,0 +1,43 @@ +=== WiFi AP and Station Association across two DUTs + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_ap_station_2dut] + +==== Description + +Two DUTs, each with its own radio: the ap runs a WPA2/WPA3-personal Access +Point, the station associates to it. The radios are in +separate kernels, so the "air" between them is realised differently per +environment: + + * On real hardware the link is real RF -- the radios just associate. + * On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on + each DUT bridges hwsim frames over the Ethernet segment between the + guests (the topology's wifi-link). See doc/wifi.md. + +The test asserts that the station associates with WPA2/WPA3-personal. That +is a strong end-to-end check: authentication, association and the WPA +4-way handshake all require frames to cross the medium in *both* +directions, so a successful association proves real bidirectional +communication over the (relayed) radio link -- not just that an interface +came up. It then confirms data-plane reach: the ap serves DHCP and the +station leases its address over the radio link. + +Topology: +.... + host ==(mgmt)== ap ))) ~ wifi-link ~ ((( station ==(mgmt)== host +.... + +==== Topology + +image::topology.svg[WiFi AP and Station Association across two DUTs topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to the ap and the station +. Configure the ap as an Access Point on radio0 +. Configure the station on radio0 +. Verify the station associates to the ap over the wifi link +. Verify the station's wifi0 operational status is up +. Verify the station leases its address from the ap over wifi + + diff --git a/test/case/interfaces/wifi_ap_station_2dut/test.py b/test/case/interfaces/wifi_ap_station_2dut/test.py new file mode 100755 index 000000000..c20343652 --- /dev/null +++ b/test/case/interfaces/wifi_ap_station_2dut/test.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +r""" +WiFi Access Point and Station association across two DUTs + +Two DUTs, each with its own radio: the ap runs a WPA2/WPA3-personal Access +Point, the station associates to it. The radios are in +separate kernels, so the "air" between them is realised differently per +environment: + + * On real hardware the link is real RF -- the radios just associate. + * On QEMU the radios are mac80211_hwsim, and the `wifimedium` relay on + each DUT bridges hwsim frames over the Ethernet segment between the + guests (the topology's wifi-link). See doc/wifi.md. + +The test asserts that the station associates with WPA2/WPA3-personal. That +is a strong end-to-end check: authentication, association and the WPA +4-way handshake all require frames to cross the medium in *both* +directions, so a successful association proves real bidirectional +communication over the (relayed) radio link -- not just that an interface +came up. It then confirms data-plane reach: the ap serves DHCP and the +station leases its address over the radio link. + +Topology: +.... + host ==(mgmt)== ap ))) ~ wifi-link ~ ((( station ==(mgmt)== host +.... +""" +import base64 + +import infamy +import infamy.iface as iface +from infamy.util import until, parallel + +SSID = "infix-test" +PSK = "infixinfix" # 8-63 printable ASCII, see doc/wifi.md +PSK_B64 = base64.b64encode(PSK.encode()).decode() +COUNTRY = "SE" + +# The ap runs a DHCP server on wifi0; the station leases its address. A +# completed lease is real bidirectional IP traffic over the radio link. +SUBNET = "192.168.20.0/24" +AP_IP = "192.168.20.1" +LEASE = "192.168.20.100" # single-address pool -> deterministic lease + + +def radio(name, band=None, channel=None): + """ietf-hardware component for a radio.""" + wifi_radio = {"country-code": COUNTRY} + if band: + wifi_radio["band"] = band + if channel is not None: + wifi_radio["channel"] = channel + return { + "name": name, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": wifi_radio, + } + + +def keystore(): + return { + "keystore": { + "symmetric-keys": { + "symmetric-key": [{ + "name": "wifi", + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": PSK_B64, + }] + } + } + } + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the ap and the station"): + env = infamy.Env() + # Connect to both nodes concurrently to cut setup time. + ap, station = parallel( + lambda: env.attach("ap", "mgmt"), + lambda: env.attach("station", "mgmt"), + ) + + for dut in (ap, station): + if not dut.has_feature("infix-interfaces", "wifi"): + print("DUT does not advertise the 'wifi' feature -- skipping") + test.skip() + + with test.step("Configure the ap as an Access Point on radio0"): + ap.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [ + radio("radio0", band="2.4GHz", channel=1)]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": {"interfaces": {"interface": [{ + "name": "wifi0", + "type": "infix-if-type:wifi", + # hwsim defaults every radio0 to 02:00:00:00:00:00, so the AP + # and the station would otherwise share a MAC -- give each a + # unique address. + "infix-interfaces:custom-phys-address": {"static": "02:00:00:00:00:01"}, + "infix-interfaces:wifi": { + "radio": "radio0", + "access-point": { + "ssid": SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"}, + }, + }, + "ietf-ip:ipv4": {"address": [ + {"ip": AP_IP, "prefix-length": 24}]}, + }]}}, + "infix-dhcp-server": {"dhcp-server": {"subnet": [{ + "subnet": SUBNET, + "pool": {"start-address": LEASE, "end-address": LEASE}, + }]}}, + }) + + with test.step("Configure the station on radio0"): + station.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [radio("radio0")]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": {"interfaces": {"interface": [{ + "name": "wifi0", + "type": "infix-if-type:wifi", + "infix-interfaces:custom-phys-address": {"static": "02:00:00:00:00:02"}, + "infix-interfaces:wifi": { + "radio": "radio0", + "station": { + "ssid": SSID, + "security": {"mode": "auto", "secret": "wifi"}, + }, + }, + "ietf-ip:ipv4": {"infix-dhcp-client:dhcp": {}}, + }]}}, + }) + + with test.step("Verify the station associates to the ap over the wifi link"): + def associated(): + ifc = station.get_iface("wifi0") + if not ifc: + return False + wifi = ifc.get("infix-interfaces:wifi") or ifc.get("wifi") or {} + sta = wifi.get("station", {}) + return sta.get("ssid") == SSID and \ + sta.get("signal-strength") is not None + until(associated, attempts=60, interval=2) + + with test.step("Verify the station's wifi0 operational status is up"): + until(lambda: iface.is_oper_up(station, "wifi0"), attempts=30) + + with test.step("Verify the station leases its address from the ap over wifi"): + until(lambda: iface.address_exist(station, "wifi0", LEASE), + attempts=60, interval=2) + + test.succeed() diff --git a/test/case/interfaces/wifi_ap_station_2dut/topology.dot b/test/case/interfaces/wifi_ap_station_2dut/topology.dot new file mode 100644 index 000000000..ff7c5327c --- /dev/null +++ b/test/case/interfaces/wifi_ap_station_2dut/topology.dot @@ -0,0 +1,44 @@ +graph "wifi-ap-station-2dut" { + layout="neato"; + overlap="false"; + esep="+40"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | mgmt2 }", + pos="0,0!", + requires="controller", + ]; + + ap [ + label="{ mgmt | wifi } | ap", + pos="6,2!", + requires="infix", + ]; + + station [ + label="{ mgmt | wifi } | station", + pos="6,-2!", + requires="infix", + ]; + + // The wireless cell the ap and the station share, modelled as a medium + // node both join (one radio each, same index -> same cell): + // * physical: maps onto a real over-the-air RF cell; + // * virtual (qeneth): maps onto one of the multicast cells over which + // the wifimedium relay bridges mac80211_hwsim frames. + // The mapper guarantees the two DUTs actually share this medium. + cell [ + label="cell", + pos="9,0!", + requires="wifi-radio", + ]; + + host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- station:mgmt [requires="mgmt", color="lightgray"] + + ap:wifi -- cell [requires="wifi2.4GHz", style="dashed"] + station:wifi -- cell [requires="wifi2.4GHz", style="dashed"] +} diff --git a/test/case/interfaces/wifi_ap_station_2dut/topology.svg b/test/case/interfaces/wifi_ap_station_2dut/topology.svg new file mode 100644 index 000000000..b0c424108 --- /dev/null +++ b/test/case/interfaces/wifi_ap_station_2dut/topology.svg @@ -0,0 +1,68 @@ + + + + + + +wifi-ap-station-2dut + + + +host + +host + +mgmt1 + +mgmt2 + + + +ap + +mgmt + +wifi + +ap + + + +host:mgmt1--ap:mgmt + + + + +station + +mgmt + +wifi + +station + + + +host:mgmt2--station:mgmt + + + + +cell + +cell + + + +ap:wifi--cell + + + + +station:wifi--cell + + + + diff --git a/test/case/interfaces/wifi_band_steering/Readme.adoc b/test/case/interfaces/wifi_band_steering/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_band_steering/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_band_steering/test.adoc b/test/case/interfaces/wifi_band_steering/test.adoc new file mode 100644 index 000000000..20d45f0b5 --- /dev/null +++ b/test/case/interfaces/wifi_band_steering/test.adoc @@ -0,0 +1,50 @@ +=== WiFi Band Steering across a dual-band Access Point + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_band_steering] + +==== Description + +One DUT is a dual-band Access Point whose two radios sit on the *same* +wireless cell: one radio serves the SSID "campus" on 2.4GHz and the other +serves the same SSID on 5GHz. Both BSSes are bridged into br0 with a single DHCP +server, so the two bands are one network. Infix runs all AP radios in a +single hostapd process, which is what lets the cross-radio band-steering +directive resolve (see src/confd/src/hardware.c). + +The other DUT is a client with a *single* dual-band radio. Because both +BSSes share one cell, that one radio hears "campus" on both bands -- the +device band steering acts on. + +Enabling 802.11v (`roaming dot11v`) on both BSSes turns on MBO band steering +by default: hostapd tracks which band it has seen a client on and, on the +2.4GHz BSS, suppresses probe responses to a client it has seen on the 5GHz +BSS (`no_probe_resp_if_seen_on`). A dual-band client is therefore answered +only on 5GHz and associates there; a 2.4-only client, never seen on 5GHz, is +still answered and works normally. See doc/wifi.md. + +The test asserts the client associates to "campus", that band steering lands +it on the 5GHz BSS (not the 2.4GHz one), and that it leases an address -- a +completed association plus DHCP lease is real bidirectional traffic over the +(relayed) radio link. + +Topology: +.... + (( 2.4GHz )) + host ==(mgmt)== ap ~ one cell ~ client ==(mgmt)== host + (( 5GHz )) (single dual-band radio) +.... + +==== Topology + +image::topology.svg[WiFi Band Steering across a dual-band Access Point topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to the ap and the client +. Configure the dual-band AP: one BSS on 2.4GHz, one on 5GHz +. Configure the client with a single dual-band station radio +. Verify the client associates to the 'campus' SSID +. Verify band steering put the client on the 5GHz BSS +. Verify the client leases an address over 5GHz + + diff --git a/test/case/interfaces/wifi_band_steering/test.py b/test/case/interfaces/wifi_band_steering/test.py new file mode 100755 index 000000000..fd5820d8c --- /dev/null +++ b/test/case/interfaces/wifi_band_steering/test.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +r""" +WiFi Band Steering: a dual-band AP nudges a client onto 5GHz + +One DUT is a dual-band Access Point whose two radios sit on the *same* +wireless cell: one radio serves the SSID "campus" on 2.4GHz and the other +serves the same SSID on 5GHz. Both BSSes are bridged into br0 with a single DHCP +server, so the two bands are one network. Infix runs all AP radios in a +single hostapd process, which is what lets the cross-radio band-steering +directive resolve (see src/confd/src/hardware.c). + +The other DUT is a client with a *single* dual-band radio. Because both +BSSes share one cell, that one radio hears "campus" on both bands -- the +device band steering acts on. + +Enabling 802.11v (`roaming dot11v`) on both BSSes turns on MBO band steering +by default: hostapd tracks which band it has seen a client on and, on the +2.4GHz BSS, suppresses probe responses to a client it has seen on the 5GHz +BSS (`no_probe_resp_if_seen_on`). A dual-band client is therefore answered +only on 5GHz and associates there; a 2.4-only client, never seen on 5GHz, is +still answered and works normally. See doc/wifi.md. + +The test asserts the client associates to "campus", that band steering lands +it on the 5GHz BSS (not the 2.4GHz one), and that it leases an address -- a +completed association plus DHCP lease is real bidirectional traffic over the +(relayed) radio link. + +Topology: +.... + (( 2.4GHz )) + host ==(mgmt)== ap ~ one cell ~ client ==(mgmt)== host + (( 5GHz )) (single dual-band radio) +.... +""" +import base64 + +import infamy +import infamy.iface as iface +from infamy.util import until, parallel + +SSID = "campus" +PSK = "infixinfix" # 8-63 printable ASCII, see doc/wifi.md +PSK_B64 = base64.b64encode(PSK.encode()).decode() +COUNTRY = "SE" + +# Both BSSes are bridged into br0 behind one DHCP server: the two bands are +# a single subnet, so a lease proves the client reached the network. A +# two-address pool keeps the lease deterministic. +SUBNET = "192.168.30.0/24" +AP_IP = "192.168.30.1" +POOL_START = "192.168.30.100" +POOL_END = "192.168.30.101" + +# hwsim defaults every radioN to the same address across guests, so the AP +# BSSIDs and the client's radio would collide without explicit addresses. +AP_BSSID_24 = "02:00:00:00:0a:01" +AP_BSSID_5 = "02:00:00:00:0b:01" +CLIENT_MAC = "02:00:00:00:00:0c" + + +def radio(name, band=None, channel=None): + """ietf-hardware component for a radio.""" + wifi_radio = {"country-code": COUNTRY} + if band: + wifi_radio["band"] = band + if channel is not None: + wifi_radio["channel"] = channel + return { + "name": name, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": wifi_radio, + } + + +def keystore(): + return { + "keystore": { + "symmetric-keys": { + "symmetric-key": [{ + "name": "wifi", + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": PSK_B64, + }] + } + } + } + + +def ap_bss(name, radio_name, bssid): + """A band-steering AP BSS on the given radio, bridged into br0.""" + return { + "name": name, + "type": "infix-if-type:wifi", + "enabled": True, + "infix-interfaces:custom-phys-address": {"static": bssid}, + "infix-interfaces:wifi": { + "radio": radio_name, + "access-point": { + "ssid": SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi"}, + # dot11v enables MBO band steering by default; the two BSSes + # share the SSID so band steering has another band to point at. + "roaming": {"dot11v": {}}, + }, + }, + "infix-interfaces:bridge-port": {"bridge": "br0"}, + } + + +def wifi_of(ifc): + return (ifc or {}).get("infix-interfaces:wifi") or (ifc or {}).get("wifi") or {} + + +def associated(dut, ifname): + sta = wifi_of(dut.get_iface(ifname)).get("station", {}) + return sta.get("ssid") == SSID and sta.get("signal-strength") is not None + + +def ap_clients(dut, ifname): + """MACs of the stations currently associated to this BSS.""" + ap = wifi_of(dut.get_iface(ifname)).get("access-point") or {} + stations = (ap.get("stations") or {}).get("station") or [] + return {s.get("mac-address", "").lower() for s in stations} + + +with infamy.Test() as test: + with test.step("Set up topology and attach to the ap and the client"): + env = infamy.Env() + ap, client = parallel( + lambda: env.attach("ap", "mgmt"), + lambda: env.attach("client", "mgmt"), + ) + + for dut in (ap, client): + if not dut.has_feature("infix-interfaces", "wifi"): + print("DUT does not advertise the 'wifi' feature -- skipping") + test.skip() + + with test.step("Configure the dual-band AP: one BSS on 2.4GHz, one on 5GHz"): + # radio2 and radio3 are dut1's two extra radios, both wired to the + # dedicated band-steering cell (cell2) in test/virt/quad. + ap.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [ + radio("radio2", band="2.4GHz", channel=1), + radio("radio3", band="5GHz", channel=36), + ]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": {"interfaces": {"interface": [ + {"name": "br0", "type": "infix-if-type:bridge", "enabled": True, + "ietf-ip:ipv4": {"address": [ + {"ip": AP_IP, "prefix-length": 24}]}}, + ap_bss("wifi0", "radio2", AP_BSSID_24), + ap_bss("wifi1", "radio3", AP_BSSID_5), + ]}}, + "infix-dhcp-server": {"dhcp-server": {"subnet": [{ + "subnet": SUBNET, + "pool": {"start-address": POOL_START, "end-address": POOL_END}, + }]}}, + }) + + with test.step("Configure the client with a single dual-band station radio"): + # radio2 is the client DUT's extra radio on the band-steering cell + # (cell2). No band/channel pinned: the one radio scans both bands and + # lets band steering decide where it lands. + client.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [radio("radio2")]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": {"interfaces": {"interface": [{ + "name": "wifi0", + "type": "infix-if-type:wifi", + "enabled": True, + "infix-interfaces:custom-phys-address": {"static": CLIENT_MAC}, + "infix-interfaces:wifi": { + "radio": "radio2", + "station": { + "ssid": SSID, + "security": {"mode": "auto", "secret": "wifi"}, + }, + }, + "ietf-ip:ipv4": {"infix-dhcp-client:dhcp": {}}, + }]}}, + }) + + with test.step("Verify the client associates to the 'campus' SSID"): + until(lambda: associated(client, "wifi0"), attempts=60, interval=2) + + with test.step("Verify band steering put the client on the 5GHz BSS"): + # Steered: the client shows up on the 5GHz BSS (wifi1) and not on the + # 2.4GHz BSS (wifi0) -- the 2.4GHz BSS suppressed its probe responses + # once it had been seen on 5GHz. + until(lambda: CLIENT_MAC in ap_clients(ap, "wifi1"), attempts=90, interval=2) + assert CLIENT_MAC not in ap_clients(ap, "wifi0"), \ + "client associated on 2.4GHz; band steering did not steer it to 5GHz" + + with test.step("Verify the client leases an address over 5GHz"): + until(lambda: iface.is_oper_up(client, "wifi0"), attempts=30) + until(lambda: iface.address_exist(client, "wifi0", POOL_START) or + iface.address_exist(client, "wifi0", POOL_END), + attempts=60, interval=2) + + test.succeed() diff --git a/test/case/interfaces/wifi_band_steering/topology.dot b/test/case/interfaces/wifi_band_steering/topology.dot new file mode 100644 index 000000000..a81d6eb14 --- /dev/null +++ b/test/case/interfaces/wifi_band_steering/topology.dot @@ -0,0 +1,54 @@ +graph "wifi-band-steering" { + layout="neato"; + overlap="false"; + esep="+40"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | mgmt2 }", + pos="0,0!", + requires="controller", + ]; + + // One dual-band Access Point with two radios on the *same* cell: one + // serves the SSID on 2.4GHz, the other serves the same SSID on 5GHz. + // "wifi-dualband" pins it to the quad DUT that wires two extra radios to + // the dedicated band-steering cell, the only node where both BSSes share + // one cell (see test/virt/quad). + ap [ + label="{ mgmt | lo | hi } | ap", + pos="6,0!", + requires="infix wifi-dualband", + ]; + + // One client with a single dual-band radio. Because both AP BSSes live + // on the one cell, this single radio hears the SSID on both bands and is + // the device band steering nudges onto 5GHz. + client [ + label="{ mgmt | wifi } | client", + pos="12,0!", + requires="infix", + ]; + + // A single wireless cell carrying both bands. The medium is + // frequency-aware (the wifimedium relay preserves each frame's frequency + // and mac80211 channel-filters), so 2.4GHz and 5GHz coexist on one cell: + // * physical: one over-the-air neighbourhood the client scans; + // * virtual (qeneth): one multicast cell the AP's two radios and the + // client's radio all join. + cell [ + label="cell\n(2.4GHz + 5GHz)", + pos="9,0!", + requires="wifi-radio", + ]; + + host:mgmt1 -- ap:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- client:mgmt [requires="mgmt", color="lightgray"] + + // Both AP radios and the client's single radio share the one cell. + ap:lo -- cell [requires="wifi2.4GHz", style="dashed"] + ap:hi -- cell [requires="wifi5GHz", style="dashed"] + client:wifi -- cell [requires="wifi2.4GHz wifi5GHz", style="dashed"] +} diff --git a/test/case/interfaces/wifi_band_steering/topology.svg b/test/case/interfaces/wifi_band_steering/topology.svg new file mode 100644 index 000000000..a8dfaf32b --- /dev/null +++ b/test/case/interfaces/wifi_band_steering/topology.svg @@ -0,0 +1,76 @@ + + + + + + +wifi-band-steering + + + +host + +host + +mgmt1 + +mgmt2 + + + +ap + +mgmt + +lo + +hi + +ap + + + +host:mgmt1--ap:mgmt + + + + +client + +mgmt + +wifi + +client + + + +host:mgmt2--client:mgmt + + + + +cell + +cell +(2.4GHz + 5GHz) + + + +ap:lo--cell + + + + +ap:hi--cell + + + + +client:wifi--cell + + + + diff --git a/test/case/interfaces/wifi_mesh_roaming/Readme.adoc b/test/case/interfaces/wifi_mesh_roaming/Readme.adoc new file mode 120000 index 000000000..ae32c8412 --- /dev/null +++ b/test/case/interfaces/wifi_mesh_roaming/Readme.adoc @@ -0,0 +1 @@ +test.adoc \ No newline at end of file diff --git a/test/case/interfaces/wifi_mesh_roaming/test.adoc b/test/case/interfaces/wifi_mesh_roaming/test.adoc new file mode 100644 index 000000000..b99e3c5f0 --- /dev/null +++ b/test/case/interfaces/wifi_mesh_roaming/test.adoc @@ -0,0 +1,62 @@ +=== WiFi Mesh backhaul with roaming Access Points + +ifdef::topdoc[:imagesdir: {topdoc}../../test/case/interfaces/wifi_mesh_roaming] + +==== Description + +The worked example from doc/whitepaper-wifi-mesh-roaming.md, as a test. + +Three gateway nodes (gw1, gw2, gw3) each do two jobs on two radios: + + * radio0 -- an 802.11s mesh point. The three join one mesh ("backhaul") + on 5GHz; this carries traffic between the nodes so only gw1 needs a wire + to the rest of the LAN. + * radio1 -- a WPA2/WPA3-personal Access Point on 2.4GHz. All three share + the same SSID ("campus") and 802.11r mobility domain, so a client roams + between them as one network. + +Each node bridges its mesh and AP (and, on gw1, the wired uplink) into br0, +so the mesh is a transparent layer-2 backhaul. A fourth node is the client. + +The test checks the claims the whitepaper makes: + + 1. the three nodes form a mesh (each sees its two peers); + 2. the client associates to the "campus" SSID; + 3. traffic reaches the client across the mesh backhaul (host behind gw1 + pings the client, which is attached to some gw's AP); + 4. roaming: the client reports the BSSID it is connected to, which is one + of the three gw APs; when that AP is taken down the client moves to + another node -- same SSID, same mobility domain -- so the reported + BSSID changes to a different gw, and traffic recovers. + +In simulation every radio hears every other at one fixed strength, so there +is no signal gradient to drift the client between APs. Step 4 forces the +move instead, which is a stronger check: it proves a second AP accepts the +client and the backhaul re-converges. + +Topology: +.... + host ==(mgmt)== gw1 (mesh+AP) )) ~ mesh ~ (( gw2 (mesh+AP) + \\__(lan, wired)__/ \ / + ) ~ mesh ~ (( gw3 (mesh+AP) + client (((roams between the gw APs))) +.... + +==== Topology + +image::topology.svg[WiFi Mesh backhaul with roaming Access Points topology, align=center, scaledwidth=75%] + +==== Sequence + +. Set up topology and attach to gw1, gw2, gw3 and the client +. Configure gw1, gw2, gw3 as mesh nodes with a roaming AP +. Configure the client as a station for the 'campus' SSID +. Verify the three nodes form the mesh backhaul +. Verify the client associates to the 'campus' SSID +. Verify the client is connected to one of the campus APs +. Verify the client is reachable across the mesh +. Take down the client's current AP to force a roam +. Verify the client roams to another node's AP +. Verify connectivity is restored after roaming + + diff --git a/test/case/interfaces/wifi_mesh_roaming/test.py b/test/case/interfaces/wifi_mesh_roaming/test.py new file mode 100755 index 000000000..7895c239b --- /dev/null +++ b/test/case/interfaces/wifi_mesh_roaming/test.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +r""" +WiFi Mesh backhaul with roaming Access Points + +The worked example from doc/whitepaper-wifi-mesh-roaming.md, as a test. + +Three gateway nodes (gw1, gw2, gw3) each do two jobs on two radios: + + * radio0 -- an 802.11s mesh point. The three join one mesh ("backhaul") + on 5GHz; this carries traffic between the nodes so only gw1 needs a wire + to the rest of the LAN. + * radio1 -- a WPA2/WPA3-personal Access Point on 2.4GHz. All three share + the same SSID ("campus") and 802.11r mobility domain, so a client roams + between them as one network. + +Each node bridges its mesh and AP (and, on gw1, the wired uplink) into br0, +so the mesh is a transparent layer-2 backhaul. A fourth node is the client. + +The test checks the claims the whitepaper makes: + + 1. the three nodes form a mesh (each sees its two peers); + 2. the client associates to the "campus" SSID; + 3. traffic reaches the client across the mesh backhaul (host behind gw1 + pings the client, which is attached to some gw's AP); + 4. roaming: the client reports the BSSID it is connected to, which is one + of the three gw APs; when that AP is taken down the client moves to + another node -- same SSID, same mobility domain -- so the reported + BSSID changes to a different gw, and traffic recovers. + +In simulation every radio hears every other at one fixed strength, so there +is no signal gradient to drift the client between APs. Step 4 forces the +move instead, which is a stronger check: it proves a second AP accepts the +client and the backhaul re-converges. + +Topology: +.... + host ==(mgmt)== gw1 (mesh+AP) )) ~ mesh ~ (( gw2 (mesh+AP) + \\__(lan, wired)__/ \ / + ) ~ mesh ~ (( gw3 (mesh+AP) + client (((roams between the gw APs))) +.... +""" +import base64 + +import infamy +from infamy.util import until, parallel + +SSID = "campus" +MESH_ID = "backhaul" +MESH_PSK = base64.b64encode(b"meshmeshmesh").decode() +WIFI_PSK = base64.b64encode(b"infixinfix").decode() +COUNTRY = "SE" + +CLIENT_IP = "10.0.0.9" +HOST_IP = "10.0.0.1" +CLIENT_MAC = "02:00:00:00:00:09" + +# Unique MACs: hwsim defaults every radioN to the same address across guests, +# so the mesh peers and the AP BSSIDs would collide without these. The AP MAC +# is the BSSID the client reports when associated to that node. +GWS = [ + # name, mesh radio0 MAC, AP radio1 MAC (BSSID) + ("gw1", "02:00:00:00:00:01", "02:00:00:00:0a:01"), + ("gw2", "02:00:00:00:00:02", "02:00:00:00:0a:02"), + ("gw3", "02:00:00:00:00:03", "02:00:00:00:0a:03"), +] + + +def radio(name, band, channel): + return { + "name": name, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": { + "country-code": COUNTRY, "band": band, "channel": channel, + }, + } + + +def keystore(): + return {"keystore": {"symmetric-keys": {"symmetric-key": [ + {"name": "mesh-secret", + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": MESH_PSK}, + {"name": "wifi-secret", + "key-format": "infix-crypto-types:passphrase-key-format", + "cleartext-symmetric-key": WIFI_PSK}, + ]}}} + + +def gw_config(mesh_mac, ap_mac, uplink=None): + interfaces = [ + {"name": "br0", "type": "infix-if-type:bridge", "enabled": True}, + { + "name": "wifi0", "type": "infix-if-type:wifi", "enabled": True, + "infix-interfaces:custom-phys-address": {"static": mesh_mac}, + "infix-interfaces:wifi": { + "radio": "radio0", + "mesh-point": { + "mesh-id": MESH_ID, + "security": {"secret": "mesh-secret"}, + }, + }, + "infix-interfaces:bridge-port": {"bridge": "br0"}, + }, + { + "name": "wifi1", "type": "infix-if-type:wifi", "enabled": True, + "infix-interfaces:custom-phys-address": {"static": ap_mac}, + "infix-interfaces:wifi": { + "radio": "radio1", + "access-point": { + "ssid": SSID, + "security": {"mode": "wpa2-wpa3-personal", "secret": "wifi-secret"}, + "roaming": { + "dot11r": {"mobility-domain": "hash"}, + "dot11k": {}, + "dot11v": {}, + }, + }, + }, + "infix-interfaces:bridge-port": {"bridge": "br0"}, + }, + ] + if uplink: + interfaces.append({ + "name": uplink, "enabled": True, + "infix-interfaces:bridge-port": {"bridge": "br0"}, + }) + return { + "ietf-hardware": {"hardware": {"component": [ + radio("radio0", "5GHz", 36), + radio("radio1", "2.4GHz", 1), + ]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": {"interfaces": {"interface": interfaces}}, + } + + +def wifi_of(ifc): + return (ifc or {}).get("infix-interfaces:wifi") or (ifc or {}).get("wifi") or {} + + +def mesh_peers(dut): + mp = wifi_of(dut.get_iface("wifi0")).get("mesh-point") or {} + return (mp.get("peers") or {}).get("peer") or [] + + +def station(dut, ifname="wifi0"): + return wifi_of(dut.get_iface(ifname)).get("station", {}) + + +def station_bssid(dut): + """The BSSID the client's station is currently associated to.""" + return (station(dut).get("bssid") or "").lower() + + +with infamy.Test() as test: + with test.step("Set up topology and attach to gw1, gw2, gw3 and the client"): + env = infamy.Env() + # Connect to all four nodes concurrently -- each attach probes the + # node and downloads its YANG models, so doing them in parallel cuts + # the setup time roughly four-fold. + gw1, gw2, gw3, client = parallel( + lambda: env.attach("gw1", "mgmt"), + lambda: env.attach("gw2", "mgmt"), + lambda: env.attach("gw3", "mgmt"), + lambda: env.attach("client", "mgmt"), + ) + gw_duts = [gw1, gw2, gw3] + gws = [(name, dut, mesh, ap) for (name, mesh, ap), dut in zip(GWS, gw_duts)] + + for dut in gw_duts + [client]: + if not dut.has_feature("infix-interfaces", "wifi"): + print("DUT does not advertise the 'wifi' feature -- skipping") + test.skip() + + with test.step("Configure gw1, gw2, gw3 as mesh nodes with a roaming AP"): + _, gw1_uplink = env.ltop.xlate("gw1", "uplink") + for name, dut, mesh_mac, ap_mac in gws: + dut.put_config_dicts(gw_config(mesh_mac, ap_mac, + uplink=gw1_uplink if name == "gw1" else None)) + + with test.step("Configure the client as a station for the 'campus' SSID"): + # The client joins the gw APs, which run on radio1 (2.4GHz). In the + # virtual topology a wireless cell is shared per radio index, so the + # client's station must use radio1 too -- a station and the AP it + # associates to live in the same cell only when they share an index. + # See doc/wifi.md and test/virt/quad. + client.put_config_dicts({ + "ietf-hardware": {"hardware": {"component": [radio("radio1", "2.4GHz", 1)]}}, + "ietf-keystore": keystore(), + "ietf-interfaces": {"interfaces": {"interface": [{ + "name": "wifi0", "type": "infix-if-type:wifi", "enabled": True, + "infix-interfaces:custom-phys-address": {"static": CLIENT_MAC}, + "infix-interfaces:wifi": { + "radio": "radio1", + "station": { + "ssid": SSID, + "security": {"mode": "auto", "secret": "wifi-secret"}, + }, + }, + "ietf-ip:ipv4": {"address": [{"ip": CLIENT_IP, "prefix-length": 24}]}, + }]}}, + }) + + with test.step("Verify the three nodes form the mesh backhaul"): + for name, dut, _, _ in gws: + until(lambda dut=dut: len(mesh_peers(dut)) >= 2, attempts=60, interval=2) + + with test.step("Verify the client associates to the 'campus' SSID"): + def associated(): + sta = station(client) + return sta.get("ssid") == SSID and sta.get("signal-strength") is not None + until(associated, attempts=60, interval=2) + + # The client reports the BSSID it is on; with all three APs sharing the + # SSID, that BSSID is what tells them apart. + aps = {ap_mac.lower(): (name, dut) for name, dut, _, ap_mac in gws} + + with test.step("Verify the client is connected to one of the campus APs"): + until(lambda: station_bssid(client) in aps, attempts=60, interval=2) + first_bssid = station_bssid(client) + first_ap, first_dut = aps[first_bssid] + print(f"client is on {first_ap} ({first_bssid})") + + # The host sits on the wired LAN behind gw1; reaching the client proves + # the frames cross the mesh backhaul (the client may be on any gw's AP). + _, hlan = env.ltop.xlate("host", "lan") + with infamy.IsolatedMacVlan(hlan) as ns: + ns.addip(HOST_IP) + + with test.step("Verify the client is reachable across the mesh"): + ns.must_reach(CLIENT_IP) + + with test.step("Take down the client's current AP to force a roam"): + first_dut.put_config_dicts({"ietf-interfaces": {"interfaces": { + "interface": [{"name": "wifi1", "enabled": False}]}}}) + + with test.step("Verify the client roams to another node's AP"): + until(lambda: station_bssid(client) in aps and station_bssid(client) != first_bssid, + attempts=90, interval=2) + new_ap, _ = aps[station_bssid(client)] + print(f"client roamed from {first_ap} to {new_ap}") + + with test.step("Verify connectivity is restored after roaming"): + ns.must_reach(CLIENT_IP) + + test.succeed() diff --git a/test/case/interfaces/wifi_mesh_roaming/topology.dot b/test/case/interfaces/wifi_mesh_roaming/topology.dot new file mode 100644 index 000000000..a77b28b95 --- /dev/null +++ b/test/case/interfaces/wifi_mesh_roaming/topology.dot @@ -0,0 +1,79 @@ +graph "wifi-mesh-roaming" { + layout="neato"; + overlap="false"; + esep="+40"; + + node [shape=record, fontname="DejaVu Sans Mono, Book"]; + edge [color="cornflowerblue", penwidth="2", fontname="DejaVu Serif, Book"]; + + host [ + label="host | { mgmt1 | mgmt2 | mgmt3 | mgmt4 | lan }", + pos="0,0!", + requires="controller", + ]; + + gw1 [ + label="{ mgmt | uplink | mesh | ap } | gw1", + pos="6,4!", + requires="infix", + ]; + + gw2 [ + label="{ mgmt | mesh | ap } | gw2", + pos="9,0!", + requires="infix", + ]; + + gw3 [ + label="{ mgmt | mesh | ap } | gw3", + pos="6,-4!", + requires="infix", + ]; + + client [ + label="{ mgmt | wifi } | client", + pos="13,0!", + requires="infix", + ]; + + // Two wireless cells, each a medium node the relevant radios join. The + // three gw nodes form the 802.11s mesh backhaul on their radio0 (the + // "mesh" cell); their roaming AP and the client all live on radio1 (the + // "ap" cell), so the client is in range of every gw's AP and can roam. + // Communicating radios share an index -> the same cell (see doc/wifi.md): + // * physical: maps onto two over-the-air RF cells; + // * virtual (qeneth): maps onto two multicast cells the wifimedium + // relay bridges mac80211_hwsim frames over. + mesh [ + label="mesh-cell", + pos="6,0!", + requires="wifi-radio", + ]; + + ap [ + label="ap-cell", + pos="11,0!", + requires="wifi-radio", + ]; + + host:mgmt1 -- gw1:mgmt [requires="mgmt", color="lightgray"] + host:mgmt2 -- gw2:mgmt [requires="mgmt", color="lightgray"] + host:mgmt3 -- gw3:mgmt [requires="mgmt", color="lightgray"] + host:mgmt4 -- client:mgmt [requires="mgmt", color="lightgray"] + + // Wired uplink: the rest of the LAN sits behind gw1. gw2/gw3 reach it + // only over the mesh backhaul, so traffic to a client on their APs must + // cross the mesh. + host:lan -- gw1:uplink [color="black"] + + // Mesh backhaul (radio0, 5GHz) + gw1:mesh -- mesh [requires="wifi5GHz", style="dashed"] + gw2:mesh -- mesh [requires="wifi5GHz", style="dashed"] + gw3:mesh -- mesh [requires="wifi5GHz", style="dashed"] + + // Roaming AP cell (radio1, 2.4GHz): the gw APs and the client + gw1:ap -- ap [requires="wifi2.4GHz", style="dashed"] + gw2:ap -- ap [requires="wifi2.4GHz", style="dashed"] + gw3:ap -- ap [requires="wifi2.4GHz", style="dashed"] + client:wifi -- ap [requires="wifi2.4GHz", style="dashed"] +} diff --git a/test/case/interfaces/wifi_mesh_roaming/topology.svg b/test/case/interfaces/wifi_mesh_roaming/topology.svg new file mode 100644 index 000000000..7d5663dc0 --- /dev/null +++ b/test/case/interfaces/wifi_mesh_roaming/topology.svg @@ -0,0 +1,148 @@ + + + + + + +wifi-mesh-roaming + + + +host + +host + +mgmt1 + +mgmt2 + +mgmt3 + +mgmt4 + +lan + + + +gw1 + +mgmt + +uplink + +mesh + +ap + +gw1 + + + +host:mgmt1--gw1:mgmt + + + + +host:lan--gw1:uplink + + + + +gw2 + +mgmt + +mesh + +ap + +gw2 + + + +host:mgmt2--gw2:mgmt + + + + +gw3 + +mgmt + +mesh + +ap + +gw3 + + + +host:mgmt3--gw3:mgmt + + + + +client + +mgmt + +wifi + +client + + + +host:mgmt4--client:mgmt + + + + +mesh + +mesh-cell + + + +gw1:mesh--mesh + + + + +ap + +ap-cell + + + +gw1:ap--ap + + + + +gw2:mesh--mesh + + + + +gw2:ap--ap + + + + +gw3:mesh--mesh + + + + +gw3:ap--ap + + + + +client:wifi--ap + + + + diff --git a/test/infamy/topology.py b/test/infamy/topology.py index 2332945ea..37a81e254 100644 --- a/test/infamy/topology.py +++ b/test/infamy/topology.py @@ -44,10 +44,14 @@ def __init__(self, dotg): self.g.add_node(name, **attrs) for e in self.dotg.get_edges(): - sn, sp = e.get_source().split(":") - dn, dp = e.get_destination().split(":") - attrs = {_qstrip(k): _qstrip(v) for k, v in e.get_attributes().items()} + + # An endpoint may be port-less, e.g. a medium node that models a + # shared wireless cell ("dut1:radio0 -- cell0"): the cell is just a + # node every radio joins, it carries no port of its own. + sn, _, sp = e.get_source().partition(":") + dn, _, dp = e.get_destination().partition(":") + attrs[sn] = sp attrs[dn] = dp diff --git a/test/qeneth b/test/qeneth index 332a376bc..e501ffedc 160000 --- a/test/qeneth +++ b/test/qeneth @@ -1 +1 @@ -Subproject commit 332a376bc000fbd9a1eccf6ecd5e54307421e7ab +Subproject commit e501ffedccc69e7b0854778c0aaf41c25324da38 diff --git a/test/templates/infix-bios-x86_64.mustache b/test/templates/infix-bios-x86_64.mustache index 033023bfe..8a70f67be 100644 --- a/test/templates/infix-bios-x86_64.mustache +++ b/test/templates/infix-bios-x86_64.mustache @@ -15,6 +15,15 @@ truncate -s 0 $imgdir/{{name}}.mactab echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab {{/links}} +# Number of mac80211_hwsim radios for this node = radioN carrier ports the +# topology wired to it. Read at boot by 00-hwsim (fw_cfg opt/wifi); 0 means +# no radios, so the node stays wifi-free. +radios=0 +{{#links}} +case "{{qn_name}}" in radio[0-9]*) radios=$((radios + 1));; esac +{{/links}} +echo "$radios" > $imgdir/{{name}}.wifi + {{#qn_gps}} mkfifo /tmp/{{name}}-gps.in /tmp/{{name}}-gps.out 2>/dev/null mkfifo /tmp/{{name}}-gps1.in /tmp/{{name}}-gps1.out 2>/dev/null @@ -24,6 +33,7 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}256M{{/qn_mem}} \ {{> ../qeneth/templates/inc/qemu-links}} -fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \ + -fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \ -bios $bios \ -drive file={{name}}.qcow2,if=virtio,format=qcow2,bus=0,unit=1 \ $usb_cmd \ diff --git a/test/templates/infix-kernel-x86_64.mustache b/test/templates/infix-kernel-x86_64.mustache index e64a79c3e..483ed7b8a 100644 --- a/test/templates/infix-kernel-x86_64.mustache +++ b/test/templates/infix-kernel-x86_64.mustache @@ -18,6 +18,15 @@ truncate -s 0 $imgdir/{{name}}.mactab echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab {{/links}} +# Number of mac80211_hwsim radios for this node = radioN carrier ports the +# topology wired to it. Read at boot by 00-hwsim (fw_cfg opt/wifi); 0 means +# no radios, so the node stays wifi-free. +radios=0 +{{#links}} +case "{{qn_name}}" in radio[0-9]*) radios=$((radios + 1));; esac +{{/links}} +echo "$radios" > $imgdir/{{name}}.wifi + exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}384M{{/qn_mem}} \ -kernel "bzImage" -initrd $img \ @@ -25,6 +34,7 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ -drive file={{name}}.disk,if=virtio,format=raw,bus=0,unit=1 \ {{> ../qeneth/templates/inc/qemu-links}} -fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \ + -fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \ $usb_cmd \ {{> ../qeneth/templates/inc/infix-common}} {{> ../qeneth/templates/inc/qemu-console}} diff --git a/test/templates/infix-x86_64.mustache b/test/templates/infix-x86_64.mustache index b7971a28b..509aaccb0 100644 --- a/test/templates/infix-x86_64.mustache +++ b/test/templates/infix-x86_64.mustache @@ -18,6 +18,15 @@ truncate -s 0 $imgdir/{{name}}.mactab echo "{{qn_name}} {{qn_mac}}" >>$imgdir/{{name}}.mactab {{/links}} +# Number of mac80211_hwsim radios for this node = radioN carrier ports the +# topology wired to it. Read at boot by 00-hwsim (fw_cfg opt/wifi); 0 means +# no radios, so the node stays wifi-free. +radios=0 +{{#links}} +case "{{qn_name}}" in radio[0-9]*) radios=$((radios + 1));; esac +{{/links}} +echo "$radios" > $imgdir/{{name}}.wifi + exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ -m {{#qn_mem}}{{qn_mem}}{{/qn_mem}}{{^qn_mem}}384M{{/qn_mem}} \ -kernel $imgdir/boot/bzImage -initrd $img \ @@ -25,5 +34,6 @@ exec qemu-system-x86_64 -M pc,accel=kvm:tcg -cpu max \ -drive file={{name}}.disk,if=virtio,format=raw,bus=0,unit=1 \ {{> inc/qemu-links}} -fw_cfg name=opt/mactab,file=$imgdir/{{name}}.mactab \ + -fw_cfg name=opt/wifi,file=$imgdir/{{name}}.wifi \ $usb_cmd \ {{> inc/infix-common}} diff --git a/test/test.mk b/test/test.mk index 36683090e..e5f6f97b1 100644 --- a/test/test.mk +++ b/test/test.mk @@ -18,7 +18,7 @@ xpaths_all_csv := $(test-dir)/.log/xpaths_all.csv base := -b $(base-dir) TEST_MODE ?= qeneth -mode-qeneth := -q $(test-dir)/virt/quad +mode-qeneth := -q $(or $(QTOPOLOGY),$(test-dir)/virt/quad) mode-host := -t $(or $(TOPOLOGY),/etc/infamy.dot) mode-run := -t $(BINARIES_DIR)/qemu.dot mode := $(mode-$(TEST_MODE)) diff --git a/test/virt/quad/topology.dot.in b/test/virt/quad/topology.dot.in index a0c363faa..152a24b0d 100644 --- a/test/virt/quad/topology.dot.in +++ b/test/virt/quad/topology.dot.in @@ -21,28 +21,34 @@ graph "quad" { ]; dut1 [ - label="{ e1 | e2 | e3 | e4 } | dut1 | { e5 | e6 | e7 | e8}", + label="{ e1 | e2 | e3 | e4 } | dut1 | { e5 | e6 | e7 | e8 } | { radio0 | radio1 | radio2 | radio3 }", pos="10,30!", - provides="infix watchdog gps", + provides="infix watchdog gps wifi wifi-dualband", expected_boot="primary", qn_console=9001, qn_mem="384M", qn_usb="dut1.usb", qn_gps="true" + // NB: dut1 has two extra radios, radio2 and radio3, wired to the + // dedicated cell2 (below). With both on one cell this DUT runs a + // dual-band AP whose 2.4GHz and 5GHz BSSes share a single cell -- the + // only way a single-radio client can hear both bands and be band- + // steered (wifi_band_steering). radio0/radio1 stay on the general + // cells. The hwsim radio count is raised in package/feature-wifi. ]; dut2 [ - label="{ e1 | e2 | e3 | e4 } | dut2 | { e5 | e6 | e7 | e8}", + label="{ e1 | e2 | e3 | e4 } | dut2 | { e5 | e6 | e7 | e8 } | { radio0 | radio1 | radio2 }", pos="0,20!", - provides="infix watchdog", + provides="infix watchdog wifi", expected_boot="primary", qn_console=9002, qn_mem="384M", qn_usb="dut2.usb" ]; dut3 [ - label="{ e1 | e2 | e3 | e4 } | dut3 | { e5 | e6 | e7 | e8}", + label="{ e1 | e2 | e3 | e4 } | dut3 | { e5 | e6 | e7 | e8 } | { radio0 | radio1 }", pos="0,10!", - provides="infix watchdog", + provides="infix watchdog wifi", expected_boot="primary", qn_console=9003, qn_mem="384M", @@ -50,15 +56,30 @@ graph "quad" { ]; dut4 [ - label="{ e1 | e2 | e3 | e4 } | dut4 | { e5 | e6 | e7 | e8}", + label="{ e1 | e2 | e3 | e4 } | dut4 | { e5 | e6 | e7 | e8 } | { radio0 | radio1 }", pos="10,0!", - provides="infix watchdog", + provides="infix watchdog wifi", expected_boot="primary", qn_console=9004, qn_mem="384M", qn_usb="dut4.usb" ]; + // Wireless cells. A node with qn_hub="true" is a shared multicast + // medium (one QEMU socket multicast group), not a guest: every radio + // wired to it is "in range" of every other, like a real RF cell. + + cell0 [label="cell0\n(radio0)", provides="wifi-radio", qn_hub="true", + pos="-6,22!", color="cornflowerblue", fontcolor="cornflowerblue", style="dashed"]; + cell1 [label="cell1\n(radio1)", provides="wifi-radio", qn_hub="true", + pos="-6,8!", color="cornflowerblue", fontcolor="cornflowerblue", style="dashed"]; + // Dedicated band-steering cell, separate from the general cells above so + // the AP's shared SSID never leaks onto cell0/cell1. dut1 wires two + // radios here for a dual-band AP (radio2=2.4GHz, radio3=5GHz) and dut2 + // joins with a single dual-band radio (radio2) as the client. + cell2 [label="cell2\n(band steering)", provides="wifi-radio", qn_hub="true", + pos="-6,15!", color="mediumseagreen", fontcolor="mediumseagreen", style="dashed"]; + host:d1a -- dut1:e1 [provides="mgmt", color="lightgray"] host:d1b -- dut1:e2 [provides="ieee-mc"] host:d1c -- dut1:e3 [provides="ieee-mc"] @@ -79,7 +100,10 @@ graph "quad" { host:d4c -- dut4:e3 [provides="ieee-mc"] host:d4d -- dut4:e4 [provides="ieee-mc"] - // Lag + // Wired inter-DUT fabric (full mesh): ring + cross-links + a two-link + // lag between dut2 and dut3. General-purpose links the non-wifi + // multi-DUT tests map onto; kept plain (wifi rides the cells above). + // Lag dut2:e7 -- dut3:e6 [color="black", penwidth="3"] dut2:e8 -- dut3:e5 [color="black", penwidth="3"] @@ -91,4 +115,31 @@ graph "quad" { // Cross-links dut1:e6 -- dut3:e7 [color="red"] dut2:e6 -- dut4:e7 [color="red"] + + // Wireless adjacency: every radio joins its cell, so all radios on the + // same index hear each other (full mesh per cell). The carrier port is + // named after the radio, so it becomes the Linux netdev radioN that the + // wifimedium relay pairs with phy radioN. + // provides lists the bands each radio supports, one wifi token per + // band (mac80211_hwsim is tri-band). On real hardware a band-specific + // radio advertises only its own bands, so a test requiring e.g. wifi5GHz + // maps only onto a 5GHz-capable radio. + dut1:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut2:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut3:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut4:radio0 -- cell0 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + + dut1:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut2:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut3:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut4:radio1 -- cell1 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + + // Band-steering cell: dut1's two extra radios form a dual-band AP + // (radio2=2.4GHz, radio3=5GHz) whose BSSes share this one cell, and + // dut2's extra radio (radio2) is the single-radio client that hears + // both bands. Only dut1 has two radios here, so it is the sole node the + // dual-band AP can map onto (wifi_band_steering). + dut1:radio2 -- cell2 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut1:radio3 -- cell2 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] + dut2:radio2 -- cell2 [provides="wifi2.4GHz wifi5GHz wifi6GHz"] } diff --git a/test/virt/quad/topology.svg b/test/virt/quad/topology.svg index ff29e34f3..757d2aad9 100644 --- a/test/virt/quad/topology.svg +++ b/test/virt/quad/topology.svg @@ -4,251 +4,349 @@ - - + + quad - + host - -host - -d1a - -d1b - -d1c - -d1d - -d2a - -d2b - -d2c - -d2d - -d3a - -d3b - -d3c - -d3d - -d4a - -d4b - -d4c - -d4d + +host + +d1a + +d1b + +d1c + +d1d + +d2a + +d2b + +d2c + +d2d + +d3a + +d3b + +d3c + +d3d + +d4a + +d4b + +d4c + +d4d dut1 - -e1 - -e2 - -e3 - -e4 - -dut1 - -e5 - -e6 - -e7 - -e8 + +e1 + +e2 + +e3 + +e4 + +dut1 + +e5 + +e6 + +e7 + +e8 + +radio0 + +radio1 + +radio2 + +radio3 host:d1a--dut1:e1 - + host:d1b--dut1:e2 - + host:d1c--dut1:e3 - + host:d1d--dut1:e4 - + dut2 - -e1 - -e2 - -e3 - -e4 - -dut2 - -e5 - -e6 - -e7 - -e8 + +e1 + +e2 + +e3 + +e4 + +dut2 + +e5 + +e6 + +e7 + +e8 + +radio0 + +radio1 + +radio2 host:d2a--dut2:e1 - + host:d2b--dut2:e2 - + host:d2c--dut2:e3 - + host:d2d--dut2:e4 - + dut3 - -e1 - -e2 - -e3 - -e4 - -dut3 - -e5 - -e6 - -e7 - -e8 + +e1 + +e2 + +e3 + +e4 + +dut3 + +e5 + +e6 + +e7 + +e8 + +radio0 + +radio1 host:d3a--dut3:e1 - + host:d3b--dut3:e2 - + host:d3c--dut3:e3 - + host:d3d--dut3:e4 - + dut4 - -e1 - -e2 - -e3 - -e4 - -dut4 - -e5 - -e6 - -e7 - -e8 + +e1 + +e2 + +e3 + +e4 + +dut4 + +e5 + +e6 + +e7 + +e8 + +radio0 + +radio1 host:d4a--dut4:e1 - + host:d4b--dut4:e2 - + host:d4c--dut4:e3 - + host:d4d--dut4:e4 - + dut1:e8--dut2:e5 - + dut1:e6--dut3:e7 - + + + + +cell0 + +cell0 +(radio0) + + + +dut1:radio0--cell0 + + + + +cell1 + +cell1 +(radio1) + + + +dut1:radio1--cell1 + + + + +cell2 + +cell2 +(band steering) + + + +dut1:radio2--cell2 + + + + +dut1:radio3--cell2 + dut2:e7--dut3:e6 - + dut2:e8--dut3:e5 - + dut2:e6--dut4:e7 - + + + + +dut2:radio0--cell0 + + + + +dut2:radio1--cell1 + + + + +dut2:radio2--cell2 + dut3:e8--dut4:e5 - + + + + +dut3:radio0--cell0 + + + + +dut3:radio1--cell1 + dut4:e8--dut1:e5 - + + + + +dut4:radio0--cell0 + + + + +dut4:radio1--cell1 + From f25a3189edd5a1dd20800413e2ad5369de3e5932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 1 Jul 2026 11:29:04 +0200 Subject: [PATCH 4/7] Wi-Fi: For station add connected BSSID to operational --- doc/wifi.md | 7 +++++++ src/confd/yang/confd/infix-if-wifi.yang | 19 +++++++++++++++++++ ...-17.yang => infix-if-wifi@2026-07-01.yang} | 0 src/statd/python/cli_pretty/cli_pretty.py | 3 +++ .../python/yanger/ietf_interfaces/wifi.py | 2 ++ 5 files changed, 31 insertions(+) rename src/confd/yang/confd/{infix-if-wifi@2025-12-17.yang => infix-if-wifi@2026-07-01.yang} (100%) diff --git a/doc/wifi.md b/doc/wifi.md index 01fad606b..62b9929e0 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -304,6 +304,7 @@ in-octets : 148388 out-octets : 24555 mode : station ssid : MyNetwork +bssid : b4:fb:e4:17:b6:a7 signal : -45 dBm (good) rx bitrate : 72.2 Mbps tx bitrate : 86.6 Mbps @@ -319,6 +320,11 @@ In the CLI, signal strength is reported as: excellent, good, fair or bad. For precise signal strength values in dBm, use NETCONF or RESTCONF to access the `signal-strength` leaf in the operational datastore. +When connected, `bssid` shows the MAC address of the access point the +station is associated to. It appears only while connected. When several +access points share one SSID (a roaming network), the `bssid` is what +tells them apart, and it changes as the station roams between them. + ## Passphrase Requirements To ensure your connection is secure and compatible with all network @@ -378,6 +384,7 @@ operational status : up physical address : f0:09:0d:36:5f:86 mode : station ssid : MyHomeNetwork +bssid : b4:fb:e4:17:b6:a7 signal : -52 dBm (good) diff --git a/src/confd/yang/confd/infix-if-wifi.yang b/src/confd/yang/confd/infix-if-wifi.yang index 781745f47..4f037e54f 100644 --- a/src/confd/yang/confd/infix-if-wifi.yang +++ b/src/confd/yang/confd/infix-if-wifi.yang @@ -48,6 +48,13 @@ submodule infix-if-wifi { - Security: WPA2/WPA3 with keystore integration - Operational state: Connection status, RSSI, client lists"; + revision 2026-07-01 { + description + "Add station 'bssid' operational leaf: the BSSID the station is + currently associated to, to tell roaming-ESS access points apart."; + reference "internal"; + } + revision 2026-03-06 { description "Adding mesh support and roaming. @@ -239,6 +246,18 @@ submodule infix-if-wifi { /* Operational state */ + leaf bssid { + config false; + type yang:mac-address; + description + "BSSID (MAC address) of the access point the station is + currently associated to. + + Only present while connected. With several access points + sharing one SSID (a roaming ESS), this identifies which one + the station is on."; + } + leaf signal-strength { config false; type int16; diff --git a/src/confd/yang/confd/infix-if-wifi@2025-12-17.yang b/src/confd/yang/confd/infix-if-wifi@2026-07-01.yang similarity index 100% rename from src/confd/yang/confd/infix-if-wifi@2025-12-17.yang rename to src/confd/yang/confd/infix-if-wifi@2026-07-01.yang diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index b4c5b34e4..0b820fb1c 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1762,8 +1762,11 @@ def _addr_lines(addrs): station = self.wifi.get('station', {}) signal = station.get('signal-strength') ssid = station.get('ssid', "----") + bssid = station.get('bssid') print(f"{'mode':<{19}}: {mode}") print(f"{'ssid':<{19}}: {ssid}") + if bssid is not None: + print(f"{'bssid':<{19}}: {bssid}") if signal is not None: signal_status = signal_to_status(signal) print(f"{'signal':<{19}}: {signal} dBm ({signal_status})") diff --git a/src/statd/python/yanger/ietf_interfaces/wifi.py b/src/statd/python/yanger/ietf_interfaces/wifi.py index e4d809422..5e1dee1d5 100644 --- a/src/statd/python/yanger/ietf_interfaces/wifi.py +++ b/src/statd/python/yanger/ietf_interfaces/wifi.py @@ -83,6 +83,8 @@ def wifi_station(ifname): if link.get('connected'): if link.get('ssid'): station_data['ssid'] = link['ssid'] + if link.get('bssid'): + station_data['bssid'] = link['bssid'] if link.get('signal-strength') is not None: station_data['signal-strength'] = link['signal-strength'] if link.get('rx-speed') is not None: From 2cfddb448cd1c2cb409ea1a2f0d4b8e9c946e5a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Wed, 1 Jul 2026 21:42:56 +0200 Subject: [PATCH 5/7] make run: Add support for virtual Wi-Fi interfaces --- board/common/qemu/Config.in.in | 16 ++++++++++++++++ board/common/qemu/run.sh | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/board/common/qemu/Config.in.in b/board/common/qemu/Config.in.in index 0d49ca1b2..ad335479a 100644 --- a/board/common/qemu/Config.in.in +++ b/board/common/qemu/Config.in.in @@ -253,3 +253,19 @@ config QEMU_NET_PORTS int "Number of Rocker switch ports" depends on QEMU_NET_ROCKER default 10 + +comment "Wireless" + +config QEMU_WIFI_RADIOS + int "Number of virtual WiFi radios (mac80211_hwsim)" + default 0 + help + Number of simulated 802.11 radios to create with mac80211_hwsim. + The count is passed to the guest via the opt/wifi fw_cfg and loaded + by the 00-hwsim boot script. + + 0 (default) means no radios, so nothing wireless clutters the guest. + Set a small number (e.g. 2) for local AP/station experiments. + + Note: 'make run' is a single guest with no inter-guest wireless + medium, so these radios only reach each other within this one guest. diff --git a/board/common/qemu/run.sh b/board/common/qemu/run.sh index 69c3d71e5..1bcd9c85b 100755 --- a/board/common/qemu/run.sh +++ b/board/common/qemu/run.sh @@ -310,6 +310,16 @@ EOF echo -n "-fw_cfg name=opt/vpd,file=$vpd_file" } +wifi_args() +{ + # Number of mac80211_hwsim radios for the guest, read at boot by 00-hwsim + # (fw_cfg opt/wifi). Default 0 -> no radios, no wifi clutter. + radios=${CONFIG_QEMU_WIFI_RADIOS:-0} + wifi="${qdir}/wifi" + echo "$radios" > "$wifi" + echo -n "-fw_cfg name=opt/wifi,file=$wifi " +} + wdt_args() { echo -n "-device i6300esb " @@ -387,6 +397,7 @@ run_qemu() $(usb_args) \ $(host_args) \ $(net_args) \ + $(wifi_args) \ $(wdt_args) \ $(rtc_args) \ $(vpd_args) \ From 1d1afecdc3f930b7311005561ef7b5c86633e1c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Thu, 2 Jul 2026 08:54:55 +0200 Subject: [PATCH 6/7] Keep the hwsim netdev as an internal interface It must exist, and will only handle frames when in state running, therefore we keep it, but mark it internal. --- board/common/rootfs/usr/libexec/infix/init.d/20-nameif | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/board/common/rootfs/usr/libexec/infix/init.d/20-nameif b/board/common/rootfs/usr/libexec/infix/init.d/20-nameif index 0356ce103..1357521a6 100755 --- a/board/common/rootfs/usr/libexec/infix/init.d/20-nameif +++ b/board/common/rootfs/usr/libexec/infix/init.d/20-nameif @@ -43,5 +43,12 @@ for iface in $ports; do ip link set "$iface" group port done +# The mac80211_hwsim monitor tap (hwsim test images only) is a debug +# observer, not a user-facing interface. It cannot be deleted (no rtnl +# link ops), so classify it as internal like the DSA CPU interfaces. +if [ -d /sys/class/net/hwsim0 ]; then + ip link set dev hwsim0 group internal +fi + # At least loopback in iface group ip link set lo group iface From b04b978d406c37f8d4909f060652ad1f7403dee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Thu, 2 Jul 2026 15:19:00 +0200 Subject: [PATCH 7/7] infix-hardware: fix operational with virtual radios * Show a vendor of the radio (hwsim) * Allow to show alot of supported AP interfaces, hwsim supports 2048 --- board/common/rootfs/usr/libexec/infix/iw.py | 4 +++- src/confd/yang/confd.inc | 2 +- src/confd/yang/confd/infix-hardware.yang | 10 ++++++++-- ...@2026-03-01.yang => infix-hardware@2026-07-02.yang} | 0 4 files changed, 12 insertions(+), 4 deletions(-) rename src/confd/yang/confd/{infix-hardware@2026-03-01.yang => infix-hardware@2026-07-02.yang} (100%) diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index ed6883d1c..87cb078e2 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -213,7 +213,9 @@ def parse_phy_info(phy_name): # Map driver to manufacturer driver_lower = driver_name.lower() - if 'mt' in driver_lower or 'mediatek' in driver_lower: + if 'hwsim' in driver_lower: + result['manufacturer'] = 'Virtual (hwsim)' + elif 'mt' in driver_lower or 'mediatek' in driver_lower: result['manufacturer'] = 'MediaTek Inc.' elif 'rtw' in driver_lower or 'realtek' in driver_lower: result['manufacturer'] = 'Realtek Semiconductor Corp.' diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index e740c6cd4..1e4d4f101 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -27,7 +27,7 @@ MODULES=( "infix-syslog@2025-11-17.yang" "iana-hardware@2018-03-13.yang" "ietf-hardware@2018-03-13.yang -e hardware-state -e hardware-sensor" - "infix-hardware@2026-03-01.yang" + "infix-hardware@2026-07-02.yang" "ieee802-dot1q-types@2022-10-29.yang" "infix-ip@2026-04-28.yang" "infix-if-type@2026-01-07.yang" diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index b73f9a683..630bf570e 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -21,6 +21,12 @@ module infix-hardware { contact "kernelkit@googlegroups.com"; description "Vital Product Data augmentation of ieee-hardware and deviations."; + revision 2026-07-02 { + description "Widen wifi max-interfaces ap/station to uint16, virtual + radios (mac80211_hwsim) report combinations up to 2048."; + reference "internal"; + } + revision 2026-03-01 { description "Add channel-width leaf for WiFi radio container."; reference "internal"; @@ -457,13 +463,13 @@ module infix-hardware { "Maximum number of virtual interfaces supported by this radio."; leaf ap { - type uint8; + type uint16; description "Maximum number of AP interfaces."; } leaf station { - type uint8; + type uint16; description "Maximum number of station interfaces."; } diff --git a/src/confd/yang/confd/infix-hardware@2026-03-01.yang b/src/confd/yang/confd/infix-hardware@2026-07-02.yang similarity index 100% rename from src/confd/yang/confd/infix-hardware@2026-03-01.yang rename to src/confd/yang/confd/infix-hardware@2026-07-02.yang