diff --git a/board/common/rootfs/etc/finit.d/available/hostapd@.conf b/board/common/rootfs/etc/finit.d/available/hostapd@.conf deleted file mode 100644 index 638c5c618..000000000 --- a/board/common/rootfs/etc/finit.d/available/hostapd@.conf +++ /dev/null @@ -1,3 +0,0 @@ -service name:hostapd :%i \ - [2345] hostapd -P/var/run/hostapd-%i.pid /etc/hostapd-%i.conf \ - -- Wi-Fi Access Point @%i diff --git a/board/common/rootfs/etc/finit.d/available/mesh@.conf b/board/common/rootfs/etc/finit.d/available/mesh@.conf new file mode 100644 index 000000000..ee523195f --- /dev/null +++ b/board/common/rootfs/etc/finit.d/available/mesh@.conf @@ -0,0 +1,3 @@ +service name:wpa_supplicant :%i \ + [2345] wpa_supplicant -s -i %i -c /etc/wpa_supplicant-%i.conf -P/var/run/wpa_supplicant-%i.pid \ + -- Wi-Fi Mesh @%i diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index 8dfac86e3..78f9a8b23 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -257,15 +257,15 @@ def parse_interface_info(ifname): for line in output.splitlines(): stripped = line.strip() - # Interface type + # Interface type (can be multi-word, e.g. "mesh point") if stripped.startswith('type '): - result['iftype'] = stripped.split()[1] + result['iftype'] = ' '.join(stripped.split()[1:]) # MAC address elif stripped.startswith('addr '): result['mac'] = stripped.split()[1] - # SSID + # SSID (AP mode) or mesh-id (mesh point mode) — kernel uses same attr elif stripped.startswith('ssid '): result['ssid'] = decode_iw_ssid(' '.join(stripped.split()[1:])) @@ -538,6 +538,43 @@ def parse_link(ifname): return result +def parse_phy_caps(phy_name): + """ + Parse 'iw phy info' for HT and VHT capability bitmasks. + Returns: {ht_cap: int, vht_cap: int} + + iw phy info output format: + Capabilities: 0x1ef + ... + VHT Capabilities (0x339071b2): + ... + """ + actual_phy = normalize_phy_name(phy_name) + output = run_iw('phy', actual_phy, 'info') + if not output: + output = run_iw(actual_phy, 'info') + if not output: + return {'ht_cap': 0, 'vht_cap': 0} + + ht_cap = 0 + vht_cap = 0 + + for line in output.splitlines(): + stripped = line.strip() + + # HT Capabilities: "Capabilities: 0x1ef" + ht_match = re.match(r'Capabilities:\s+(0x[0-9a-fA-F]+)', stripped) + if ht_match: + ht_cap = int(ht_match.group(1), 16) + + # VHT Capabilities: "VHT Capabilities (0x339071b2):" + vht_match = re.match(r'VHT Capabilities\s+\((0x[0-9a-fA-F]+)\)', stripped) + if vht_match: + vht_cap = int(vht_match.group(1), 16) + + return {'ht_cap': ht_cap, 'vht_cap': vht_cap} + + def main(): if len(sys.argv) < 2: print(json.dumps({ @@ -548,7 +585,8 @@ def main(): 'info': 'Get PHY or interface information (requires device)', 'survey': 'Get channel survey data (requires interface)', 'station': 'Get connected stations in AP mode (requires interface)', - 'link': 'Get link info in station mode (requires interface)' + 'link': 'Get link info in station mode (requires interface)', + 'caps': 'Get HT/VHT capability bitmasks (requires PHY/radio)' }, 'examples': [ 'iw.py list', @@ -557,7 +595,8 @@ def main(): 'iw.py info wlan0', 'iw.py station wifi0', 'iw.py link wlan0', - 'iw.py survey wlan0' + 'iw.py survey wlan0', + 'iw.py caps radio0' ] }, indent=2)) sys.exit(1) @@ -594,6 +633,11 @@ def main(): data = {'error': 'survey command requires interface argument'} else: data = parse_survey(sys.argv[2]) + elif command == 'caps': + if len(sys.argv) < 3: + data = {'error': 'caps command requires PHY/radio argument'} + else: + data = parse_phy_caps(sys.argv[2]) else: data = {'error': f'Unknown command: {command}'} diff --git a/buildroot b/buildroot index a40d69265..d758952b3 160000 --- a/buildroot +++ b/buildroot @@ -1 +1 @@ -Subproject commit a40d69265ea75afd4a1833e1f6400195b12e67a5 +Subproject commit d758952b3e9032975a0049f44724f80cb29d949b diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index fe3d51dc7..956c7935a 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -3,6 +3,23 @@ Change Log All notable changes to the project are documented in this file. +[v26.06.0][UNRELEASED] +-------------- + +### Changes + +- Add Wi-Fi roaming for fast, seamless handoff between access points that + share an SSID: 802.11k, 802.11v and 802.11r (over-the-air FT). See the + [Wi-Fi][wifi] guide for details +- Add Wi-Fi 802.11s mesh support, letting access points form a wireless + backhaul between each other without cabling +- Add band steering for dual-band access points, nudging dual-band + clients onto the faster 5/6 GHz band +- Add `legacy-rates` option to re-enable 802.11b rates on 2.4 GHz for + old IoT devices (disabled by default) + +[wifi]: wifi.md + [v26.05.0][] - 2026-05-29 ------------------------- diff --git a/doc/README.md b/doc/README.md index 7ac9c004c..8b7b74d8f 100644 --- a/doc/README.md +++ b/doc/README.md @@ -21,6 +21,7 @@ regression test system solely relies on NETCONF and RESTCONF. - [Introduction](introduction.md) - [System Configuration](system.md) - [Network Configuration](networking.md) + - [Wi-Fi](wifi.md) - [DHCP Server](dhcp.md) - [Syslog Support](syslog.md) - **Infix In-Depth** diff --git a/doc/img/wifi-mesh-roaming.svg b/doc/img/wifi-mesh-roaming.svg new file mode 100644 index 000000000..fc5704d26 --- /dev/null +++ b/doc/img/wifi-mesh-roaming.svg @@ -0,0 +1,396 @@ + + + + + + + + + + + + + Internet + + + + 802.11s mesh + 802.11s mesh + + + + Node A + (mesh + AP) + + bridge br0 + + clients + + + + Node B + (mesh + AP) + + bridge br0 + + clients + + + + Node C + (mesh + AP) + + bridge br0 + + clients + + clients roam between nodes (802.11r/k/v) + diff --git a/doc/wifi.md b/doc/wifi.md index b670803bb..01fad606b 100644 --- a/doc/wifi.md +++ b/doc/wifi.md @@ -156,6 +156,11 @@ admin@example:/config/hardware/component/radio0/wifi-radio/> leave - `channel-width`: AP channel bandwidth. Supported values are `auto`, `20MHz`, `40MHz`, `80MHz`, and `160MHz`. Wider channels require matching hardware, regulatory approval, and are only available on 5GHz/6GHz where supported. +- `legacy-rates`: Allow legacy 802.11b rates (1, 2, 5.5, 11 Mbps) on 2.4GHz + (default: disabled). Slow 802.11b clients consume excessive airtime and + degrade throughput for all stations, so the rates are normally suppressed. + Enable only when old 2.4GHz-only IoT devices need them to associate. No + effect on 5GHz/6GHz. - `probe-timeout`: Seconds to wait for PHY detection at boot (default: 0). Set to a non-zero value (e.g., 30) for USB WiFi dongles that are slow to initialize due to firmware loading @@ -165,6 +170,69 @@ admin@example:/config/hardware/component/radio0/wifi-radio/> leave > constraints and hardware capabilities. Channel width can now be set > explicitly for AP mode, or left at `auto` to let the driver choose. +### Bands and Channels + +Each band strikes a different balance between range and capacity. The +`country-code` decides which channels are legal in your location; the +lists below are the common allocations, and your regulatory domain may +allow fewer. + +**2.4 GHz** + +Channels 1-13 are available in most of the world, 1-11 in the US and +Canada, and 14 in Japan (802.11b only). At 20 MHz only three channels +avoid overlap: 1, 6, and 11. A 40 MHz channel takes up most of the +band, so it is seldom worth using here. + +Drawbacks: + +- This is the most crowded band. It is shared with Bluetooth, Zigbee, + cordless phones, microwave ovens, and most of the neighboring Wi-Fi. +- Narrow channels and constant contention hold real throughput well + below 5 and 6 GHz. +- The upside is range: 2.4 GHz reaches further and passes through walls + better, which keeps it useful for distant clients and 2.4 GHz-only + IoT devices. + +**5 GHz** + +UNII-1 (channels 36-48) and UNII-3 (149-165) need no radar checks. +UNII-2 (channels 52-64 and 100-144) shares spectrum with radar and +requires DFS. ETSI regions such as the EU do not include UNII-3, so the +only non-DFS 5 GHz channels there are 36-48. This band supports 20, 40, +80, and 160 MHz, so it is the one to use for wide, fast channels. + +Drawbacks: + +- Shorter range than 2.4 GHz, and a weaker signal through walls and + floors. +- A DFS channel must be monitored for radar for 60 seconds (up to 10 + minutes near some weather radars) before the AP may transmit, which + delays start-up. If radar appears later, the AP has to leave the + channel within 10 seconds and avoid it for 30 minutes, dropping + clients during the move. +- The widest 80 and 160 MHz channels almost always sit on DFS spectrum, + so the same radar rules apply to them. + +**6 GHz** + +The FCC regions open 59 channels (1, 5, 9 ... 233) across 5925-7125 MHz. +ETSI regions, including the EU, currently open only the lower part, +5945-6425 MHz (channels 1-93), for indoor use. Clients find networks on +the 15 Preferred Scanning Channels (5, 21, 37 ... 229) spaced every +80 MHz, and `auto` selects channel 37. There is no DFS in 6 GHz, so +there is no radar start-up delay. + +Drawbacks: + +- The shortest range and the weakest wall penetration of the three + bands. +- Only Wi-Fi 6E and newer clients can use it; older phones and IoT + devices cannot see the band at all. +- AP operation requires WPA3-Personal (SAE) with management frame + protection, so WPA2-only and open networks are rejected. +- Indoor power limits cap coverage further. + ### WiFi 6 Support WiFi 6 (802.11ax) is always enabled in AP mode on all bands, providing improved @@ -486,20 +554,253 @@ radio settings, and `show interface` to see all active AP interfaces. > AP and Station modes cannot be mixed on the same radio. All virtual interfaces > on a radio must be the same mode (all APs or all Stations). -### AP as Bridge Port +## Fast Roaming Between Access Points + +Fast roaming enables seamless client handoff between access points through +802.11k/r/v standards. These features can be enabled individually based on +your requirements. + +### 802.11r - Fast BSS Transition + +Enable 802.11r for fast handoff (<50ms) between APs with the same SSID: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r mobility-domain 4f57 +``` + +**Requirements:** +- All APs in roaming group must have **identical** SSID +- All APs must have **identical** passphrase (same keystore secret) +- All APs must use the **same mobility-domain** identifier + +**Mobility Domain Options:** +- Explicit 4-character hex value (e.g., `4f57`) - default if not specified +- `hash` - Automatically derive from SSID using MD5 (OpenWrt-compatible) + +Using `hash` allows multiple APs with the same SSID to automatically share +the same mobility domain without manual configuration: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r mobility-domain hash +``` + +The NAS-Identifier (Network Access Server Identifier) is a string that +uniquely identifies each AP within the 802.11r mobility domain. APs +exchange this identifier during fast BSS transition so they can look up +the correct PMK-R1 key for the roaming client. It must be unique per AP +BSS and stable across reboots. + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r nas-identifier auto +``` + +`auto` derives the identifier as: + +`-.` + +Or set an explicit string: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r nas-identifier ap01.wifi0.4f57 +``` + +### 802.11k - Radio Resource Management + +Enable 802.11k for client neighbor discovery and better roaming decisions: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11k +``` + +Enables neighbor reports and beacon reports, allowing clients to discover +nearby APs before roaming. + +### 802.11v - BSS Transition Management + +Enable 802.11v for network-assisted roaming: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v +``` + +Allows APs to suggest better APs to clients, improving roaming decisions. + +#### Band Steering (MBO) + +Enabling `dot11v` also turns on MBO (Multi-Band Operation), advertised in +beacons and association responses. MBO lets a dual-band client see that +the same SSID exists on another band and decide for itself when to move, +while 802.11v BSS Transition Management lets the AP suggest a better +target. + +On top of the client-cooperative hints, the AP applies active steering: +on a 2.4 GHz access-point it suppresses probe responses to clients that +were recently seen on the same SSID on the 5/6 GHz band, nudging +dual-band clients onto the higher band. MBO is **enabled by default** +whenever `dot11v` is enabled: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v +``` + +To turn it off while keeping BSS Transition Management: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v band-steering false +``` + +> [!NOTE] +> Band steering only matters when the same SSID is offered on two or more +> bands (one access-point per radio). On a single-band network there is +> no other band to move to, so it has no effect. + +### Opportunistic Key Caching (OKC) + +OKC reduces re-authentication time for roaming clients that do not +support 802.11r. The AP caches the PMK from previous associations and +shares it with other APs in the same mobility group. It is **enabled by +default** and only activates when both AP and client support it: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming okc false +``` + +### Recommended Configuration + +For optimal roaming experience, enable all three features: + +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11k +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r mobility-domain 4f57 +``` + +Or use `hash` for automatic mobility domain derivation from SSID: -WiFi AP interfaces can be added to bridges to integrate wireless devices -into your LAN: +``` +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11k +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11v +admin@example:/config/interface/wifi0/> set wifi access-point roaming dot11r mobility-domain hash +``` + +Repeat for all APs that should participate in the roaming group. + +> [!NOTE] +> Not all client devices support all roaming features. Modern devices typically +> support 802.11k/r/v, but older devices may only support basic roaming without +> fast transition. + +## 802.11s Mesh Point Mode + +IEEE 802.11s is a wireless mesh networking standard operating at Layer 2. +Mesh nodes form peer links directly with each other and route traffic +using HWMP (Hybrid Wireless Mesh Protocol), which is built into the +Linux mac80211 subsystem. There is no central controller; nodes +discover peers and find paths on their own. + +The standard defines two node roles: + +- **Mesh Point (MP)** - a basic mesh node that forwards traffic within + the mesh +- **Mesh Portal (MPP)** - a mesh node that bridges traffic between the + mesh and an external network (e.g., a wired LAN) + +In practice, a node bridging the mesh interface to a LAN acts as a mesh +portal. + +> [!NOTE] +> Not all WiFi hardware supports 802.11s mesh. The driver must implement +> mesh point mode in mac80211. Check your adapter's capabilities with +> `iw phy info` and look for "mesh point" under "Supported interface +> modes". + +### 802.11s vs EasyMesh + +| | **802.11s** | **EasyMesh** | +|-----------------------------|--------------------------|--------------------------------| +| **Standard** | IEEE (open, ratified) | Wi-Fi Alliance (certification) | +| **Topology** | Peer-to-peer, any-to-any | Controller-based tree | +| **Single point of failure** | None | Controller | +| **Multi-hop** | True N-hop | Limited (1-2 hops) | +| **Vendor lock-in** | None | Common | +| **Linux support** | Kernel-native (mac80211) | Requires proprietary firmware | + +Infix uses 802.11s because it runs entirely in the kernel with no +proprietary components. + +### Mesh configuration + +A mesh point requires the radio to have `band`, `channel`, and a valid +`country-code` configured. Mesh and AP modes cannot coexist on the same +radio. + +**Step 1: Configure the radio**
admin@example:/> configure
-admin@example:/config/> edit interface br0
-admin@example:/config/interface/br0/> set type bridge
+admin@example:/config/> edit hardware component radio1 wifi-radio
+admin@example:/config/hardware/component/radio1/wifi-radio/> set country-code DE
+admin@example:/config/hardware/component/radio1/wifi-radio/> set band 5GHz
+admin@example:/config/hardware/component/radio1/wifi-radio/> set channel 36
+admin@example:/config/hardware/component/radio1/wifi-radio/> leave
+
-admin@example:/config/> edit interface wifi0 -admin@example:/config/interface/wifi0/> set bridge-port bridge br0 -admin@example:/config/interface/wifi0/> leave +**Step 2: Create keystore entry for mesh security** + +All mesh links use WPA3-SAE encryption. All nodes in the same mesh +network must share the same passphrase: + +
admin@example:/> configure
+admin@example:/config/> edit keystore symmetric-key mesh-secret
+admin@example:/config/keystore/…/mesh-secret/> set key-format passphrase-key-format
+admin@example:/config/keystore/…/mesh-secret/> change cleartext-symmetric-key
+Passphrase: ************
+Retype passphrase: ************
+admin@example:/config/keystore/…/mesh-secret/> end
 
+**Step 3: Configure the mesh interface** + +
admin@example:/config/> edit interface wifi-mesh
+admin@example:/config/interface/wifi-mesh/> set type wifi
+admin@example:/config/interface/wifi-mesh/> set wifi radio radio1
+admin@example:/config/interface/wifi-mesh/> set wifi mesh-point mesh-id my-mesh
+admin@example:/config/interface/wifi-mesh/> set wifi mesh-point security secret mesh-secret
+admin@example:/config/interface/wifi-mesh/> leave
+
+ +**Mesh parameters:** + +- `mesh-id`: Network identifier, 1-32 characters. All nodes in the mesh + must use the same mesh ID +- `forwarding`: L2 mesh forwarding (default: true). When enabled, the + interface can be added to a bridge as a mesh portal +- `security secret`: Keystore reference for the WPA3-SAE passphrase + +### Mesh portal (bridge integration) + +To connect the wireless mesh to a wired LAN, add the mesh interface to +a bridge: + +
admin@example:/config/> edit interface wifi-mesh
+admin@example:/config/interface/wifi-mesh/> set bridge-port bridge br0
+admin@example:/config/interface/wifi-mesh/> leave
+
+ +### Mesh with roaming APs + +You can combine 802.11s mesh backhaul with roaming-enabled access +points. Each node has a mesh interface for backhaul on one radio and +AP interfaces for clients on another: + +![802.11s mesh backhaul with roaming-enabled access points](img/wifi-mesh-roaming.svg) + +With 802.11r/k/v roaming enabled on the APs (same SSID, same +passphrase, same mobility domain), clients hand off between nodes while +the mesh carries backhaul traffic. + ## Troubleshooting Use `show interface wifi0` to verify signal strength and connection status. diff --git a/package/feature-wifi/Config.in b/package/feature-wifi/Config.in index 1c802abe5..16fe42945 100644 --- a/package/feature-wifi/Config.in +++ b/package/feature-wifi/Config.in @@ -5,11 +5,15 @@ config BR2_PACKAGE_FEATURE_WIFI select BR2_PACKAGE_WPA_SUPPLICANT_DEBUG_SYSLOG select BR2_PACKAGE_WPA_SUPPLICANT_AUTOSCAN select BR2_PACKAGE_WPA_SUPPLICANT_CLI + select BR2_PACKAGE_WPA_SUPPLICANT_AP_SUPPORT + select BR2_PACKAGE_WPA_SUPPLICANT_MESH_NETWORKING select BR2_PACKAGE_WIRELESS_REGDB select BR2_PACKAGE_HOSTAPD select BR2_PACKAGE_HOSTAPD_DRIVER_NL80211 select BR2_PACKAGE_HOSTAPD_WPA3 select BR2_PACKAGE_HOSTAPD_WPS + select BR2_PACKAGE_HOSTAPD_WNM + select BR2_PACKAGE_HOSTAPD_MBO select BR2_PACKAGE_IW help Enables WiFi in Infix. Enables all requried applications. diff --git a/package/feature-wifi/feature-wifi.mk b/package/feature-wifi/feature-wifi.mk index 47059efe4..822e96204 100644 --- a/package/feature-wifi/feature-wifi.mk +++ b/package/feature-wifi/feature-wifi.mk @@ -69,7 +69,7 @@ define FEATURE_WIFI_LINUX_CONFIG_FIXUPS endef define FEATURE_WIFI_INSTALL_IN_ROMFS - mkdir -p $(TARGET_DIR)/etc/modprobe.d $(TARGET_DIR)/etc/udev/rules.d + mkdir -p $(TARGET_DIR)/etc/modprobe.d $(TARGET_DIR)/etc/udev/rules.d $(TARGET_DIR)/usr/libexec cp $(FEATURE_WIFI_PKGDIR)/mt7915e.conf $(TARGET_DIR)/etc/modprobe.d/mt7915e.conf cp $(FEATURE_WIFI_PKGDIR)/60-rename-wifi-phy.rules $(TARGET_DIR)/etc/udev/rules.d/60-rename-wifi-phy.rules cp $(FEATURE_WIFI_PKGDIR)/70-remove-virtual-wifi-interfaces.rules $(TARGET_DIR)/etc/udev/rules.d/70-remove-virtual-wifi-interfaces.rules diff --git a/patches/firewalld/2.3.1/0003-fix-functions-do-not-touch-global-ip_forward-sysctl.patch b/patches/firewalld/2.3.1/0003-fix-functions-do-not-touch-global-ip_forward-sysctl.patch new file mode 100644 index 000000000..1e0e2e252 --- /dev/null +++ b/patches/firewalld/2.3.1/0003-fix-functions-do-not-touch-global-ip_forward-sysctl.patch @@ -0,0 +1,54 @@ +From 9899169d6dcb07aaecdde09c77ef59b56f66e3e5 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= +Date: Fri, 5 Jun 2026 09:10:15 +0200 +Subject: [PATCH 3/3] fix(functions): do not touch global ip_forward sysctl +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Organization: Wires + +Infix manages IPv4/IPv6 forwarding per-interface via the sysctls +net.ipv4.conf..forwarding and net.ipv6.conf..forwarding. +firewalld's enable_ip_forwarding() instead writes the *global* +net.ipv4.ip_forward and net.ipv6.conf.all.forwarding knobs. The kernel +propagates those global values to every interface, which clobbers the +per-interface forwarding configuration Infix sets. + +Make enable_ip_forwarding() a no-op. The backends still install the +masquerade and forward-port nftables rules; the per-packet forwarding +decision is governed by the inbound interface's forwarding flag, so +routing keeps working on the interfaces where Infix enabled it while +the global knob is left untouched. + +Signed-off-by: Mattias Walström +--- + src/firewall/functions.py | 13 ++++++++----- + 1 file changed, 8 insertions(+), 5 deletions(-) + +diff --git a/src/firewall/functions.py b/src/firewall/functions.py +index 1b8a32c..cd87f5d 100644 +--- a/src/firewall/functions.py ++++ b/src/firewall/functions.py +@@ -495,11 +495,14 @@ def writefile(filename, line): + + + def enable_ip_forwarding(ipv): +- if ipv == "ipv4": +- return writefile("/proc/sys/net/ipv4/ip_forward", "1\n") +- elif ipv == "ipv6": +- return writefile("/proc/sys/net/ipv6/conf/all/forwarding", "1\n") +- return False ++ # Infix manages IP forwarding per-interface via the sysctls ++ # net.ipv4.conf..forwarding and net.ipv6.conf..forwarding. ++ # Writing the global net.ipv4.ip_forward / net.ipv6.conf.all.forwarding ++ # knobs would clobber those per-interface settings, since the kernel ++ # propagates the global value to every interface. Make this a no-op: ++ # firewalld still installs the masquerade and forward-port nft rules, and ++ # forwarding works on the interfaces where Infix has enabled it. ++ return True + + + def get_nf_conntrack_short_name(module): +-- +2.43.0 + diff --git a/src/confd/configure.ac b/src/confd/configure.ac index 7fdb47448..2306d5864 100644 --- a/src/confd/configure.ac +++ b/src/confd/configure.ac @@ -89,6 +89,7 @@ PKG_CHECK_MODULES([libite], [libite >= 2.6.1]) PKG_CHECK_MODULES([sysrepo], [sysrepo >= 4.2.10]) PKG_CHECK_MODULES([libyang], [libyang >= 4.2.2]) PKG_CHECK_MODULES([libsrx], [libsrx >= 1.0.0]) +PKG_CHECK_MODULES([libcrypto], [libcrypto]) AC_CHECK_HEADER([ev.h], [saved_LIBS="$LIBS" diff --git a/src/confd/src/Makefile.am b/src/confd/src/Makefile.am index 447117994..fc67edbbb 100644 --- a/src/confd/src/Makefile.am +++ b/src/confd/src/Makefile.am @@ -17,6 +17,7 @@ confd_plugin_la_CFLAGS = \ $(glib_CFLAGS) \ $(jansson_CFLAGS) \ $(libite_CFLAGS) \ + $(libcrypto_CFLAGS) \ $(sysrepo_CFLAGS) \ $(libsrx_CFLAGS) \ $(CFLAGS) @@ -26,6 +27,7 @@ confd_plugin_la_LIBADD = \ $(glib_LIBS) \ $(jansson_LIBS) \ $(libite_LIBS) \ + $(libcrypto_LIBS) \ $(sysrepo_LIBS) \ $(libsrx_LIBS) diff --git a/src/confd/src/hardware.c b/src/confd/src/hardware.c index 0a18394f9..06629d364 100644 --- a/src/confd/src/hardware.c +++ b/src/confd/src/hardware.c @@ -1,9 +1,12 @@ /* SPDX-License-Identifier: BSD-3-Clause */ #include #include +#include #include #include #include +#include +#include #include #include @@ -17,6 +20,7 @@ #define XPATH_BASE_ "/ietf-hardware:hardware" #define HOSTAPD_CONF "/etc/hostapd-%s.conf" #define HOSTAPD_CONF_NEXT HOSTAPD_CONF"+" +#define HOSTAPD_SERVICE "/etc/finit.d/available/hostapd.conf" #define GPSD_CONF "/etc/finit.d/available/gpsd.conf" #define GPSD_CONF_NEXT GPSD_CONF"+" #define GPSD_MAX_DEVICES 4 @@ -177,6 +181,89 @@ static int hardware_cand_infer_class(json_t *root, sr_session_ctx_t *session, co return err; } +/* Resolve mobility domain - if "hash", derive from SSID using MD5 (OpenWrt-compatible) */ +static const char *resolve_mobility_domain(const char *mobility_domain, const char *ssid) +{ + static char hash_result[5]; /* 4 hex chars + null */ + unsigned char md5_digest[EVP_MAX_MD_SIZE]; + unsigned int md5_len; + EVP_MD_CTX *ctx; + + if (strcmp(mobility_domain, "hash")) + return mobility_domain; + + if (!ssid) { + ERROR("Cannot derive mobility domain from NULL SSID"); + return "4f57"; /* Fallback to default */ + } + + /* Compute MD5 hash using EVP API (OpenSSL 3.0+) */ + ctx = EVP_MD_CTX_new(); + if (!ctx) { + ERROR("Failed to create EVP context"); + return "4f57"; + } + + if (EVP_DigestInit_ex(ctx, EVP_md5(), NULL) != 1 || + EVP_DigestUpdate(ctx, ssid, strlen(ssid)) != 1 || + EVP_DigestFinal_ex(ctx, md5_digest, &md5_len) != 1) { + ERROR("Failed to compute MD5 hash"); + EVP_MD_CTX_free(ctx); + return "4f57"; + } + + EVP_MD_CTX_free(ctx); + + /* Extract first 4 hex characters (first 2 bytes) */ + snprintf(hash_result, sizeof(hash_result), "%02x%02x", md5_digest[0], md5_digest[1]); + + DEBUG("Derived mobility domain '%s' from SSID '%s'", hash_result, ssid); + return hash_result; +} + +/* + * Find an AP interface on a higher-band radio (5/6 GHz) advertising the + * same SSID as the caller's 2.4 GHz BSS, for no_probe_resp_if_seen_on=. + * hostapd suppresses a 2.4 GHz probe response only once it has actually + * seen that MAC on the listed interface, so 2.4-only clients always get a + * response while dual-band clients are nudged to the higher band. Note: + * sta_track spans bands only when both radios run in one hostapd process. + * + * Returns a pointer into the config tree (do not free) or NULL. + */ +static const char *wifi_find_higher_band_twin(struct lyd_node *config, + const char *current_band, + const char *current_ssid) +{ + struct lyd_node *cifs, *cif; + + if (strcmp(current_band, "2.4GHz")) + return NULL; + + cifs = lydx_get_descendant(config, "interfaces", "interface", NULL); + LYX_LIST_FOR_EACH(cifs, cif, "interface") { + struct lyd_node *wifi, *ap, *radio_node; + const char *radio, *ssid, *band; + + wifi = lydx_get_child(cif, "wifi"); + if (!wifi) + continue; + ap = lydx_get_child(wifi, "access-point"); + if (!ap) + continue; + ssid = lydx_get_cattr(ap, "ssid"); + if (strcmp(ssid, current_ssid)) + continue; + radio = lydx_get_cattr(wifi, "radio"); + radio_node = lydx_get_xpathf(config, + "/hardware/component[name='%s']/wifi-radio", radio); + band = lydx_get_cattr(radio_node, "band"); + if (!strcmp(band, "5GHz") || !strcmp(band, "6GHz")) + return lydx_get_cattr(cif, "name"); + } + + return NULL; +} static int wifi_find_interfaces_on_radio(struct lyd_node *ifs, const char *radio_name, struct lyd_node ***iface_list, int *count) @@ -200,7 +287,7 @@ static int wifi_find_interfaces_on_radio(struct lyd_node *ifs, const char *radio if (lydx_get_op(iface) == LYDX_OP_DELETE) continue; - list = realloc(list, sizeof(struct lyd_node *) * n + 1); + list = realloc(list, sizeof(struct lyd_node *) * (n + 1)); list[n++] = iface; } @@ -230,8 +317,7 @@ static int wifi_find_radio_aps(struct lyd_node *cifs, const char *radio_name, ap = lydx_get_child(wifi, "access-point"); if (!ap) continue; - list = realloc(list, sizeof(char *) *n+1); - + list = realloc(list, sizeof(char *) * (n + 1)); ifname = lydx_get_cattr(cif, "name"); list[n++] = strdup(ifname); @@ -256,10 +342,16 @@ static int wifi_find_radio_aps(struct lyd_node *cifs, const char *radio_name, /* Helper: Write SSID and security configuration (shared between primary and BSS) */ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd_node *config, bool is_bss, const char *band) { + struct lyd_node *wifi, *ap, *security, *secret_node, *roaming; + struct lyd_node *dot11k, *dot11r, *dot11v; const char *ssid, *hidden, *security_mode, *secret_name; - struct lyd_node *wifi, *ap, *security, *secret_node; + const char *mobility_domain, *mobility_domain_raw; + const char *nas_identifier_cfg; + bool enable_80211k, enable_80211r, enable_80211v, enable_mbo; unsigned char *secret = NULL; const char *ifname; + char nas_identifier_default[300]; + char hostname[64] = "localhost"; char bssid[18]; ifname = lydx_get_cattr(cif, "name"); @@ -271,6 +363,24 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd fprintf(hostapd, "bss=%s\n", ifname); } + /* Check 802.11k/r/v configuration */ + roaming = lydx_get_child(ap, "roaming"); + dot11k = roaming ? lydx_get_child(roaming, "dot11k") : NULL; + dot11r = roaming ? lydx_get_child(roaming, "dot11r") : NULL; + dot11v = roaming ? lydx_get_child(roaming, "dot11v") : NULL; + enable_80211k = dot11k != NULL; + enable_80211r = dot11r != NULL; + enable_80211v = dot11v != NULL; + if (dot11v) { + const char *band_steering = lydx_get_cattr(dot11v, "band-steering"); + + enable_mbo = !band_steering || !strcmp(band_steering, "true"); + } else { + enable_mbo = false; + } + + + /* Set BSSID if custom MAC is configured */ if (!interface_get_phys_addr(cif, bssid)) fprintf(hostapd, "bssid=%s\n", bssid); @@ -279,6 +389,19 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd ssid = lydx_get_cattr(ap, "ssid"); hidden = lydx_get_cattr(ap, "hidden"); + if (enable_80211r) { + mobility_domain_raw = lydx_get_cattr(dot11r, "mobility-domain"); + mobility_domain = resolve_mobility_domain(mobility_domain_raw, ssid); + nas_identifier_cfg = lydx_get_cattr(dot11r, "nas-identifier"); + if (!nas_identifier_cfg || !strcmp(nas_identifier_cfg, "auto")) { + if (gethostname(hostname, sizeof(hostname)) != 0) + snprintf(hostname, sizeof(hostname), "localhost"); + hostname[sizeof(hostname) - 1] = '\0'; + snprintf(nas_identifier_default, sizeof(nas_identifier_default), + "%s-%s.%s", ifname, hostname, mobility_domain); + nas_identifier_cfg = nas_identifier_default; + } + } if (ssid) fprintf(hostapd, "ssid=%s\n", ssid); if (hidden && !strcmp(hidden, "true")) @@ -330,7 +453,7 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd } else if (!strcmp(security_mode, "wpa2-personal")) { fprintf(hostapd, "wpa=2\n"); /* WPA-PSK: Pre-shared key authentication */ - fprintf(hostapd, "wpa_key_mgmt=WPA-PSK\n"); + fprintf(hostapd, "wpa_key_mgmt=%sWPA-PSK\n", enable_80211r ? "FT-PSK " : ""); /* CCMP: AES-based encryption, mandatory for WPA2 */ fprintf(hostapd, "wpa_pairwise=CCMP\n"); if (secret) @@ -338,7 +461,7 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd } else if (!strcmp(security_mode, "wpa3-personal")) { fprintf(hostapd, "wpa=2\n"); /* SAE: Simultaneous Authentication of Equals, resistant to offline dictionary attacks */ - fprintf(hostapd, "wpa_key_mgmt=SAE\n"); + fprintf(hostapd, "wpa_key_mgmt=%sSAE\n", enable_80211r ? "FT-SAE " : ""); fprintf(hostapd, "rsn_pairwise=CCMP\n"); if (secret) fprintf(hostapd, "sae_password=%s\n", secret); @@ -346,7 +469,8 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd } else if (!strcmp(security_mode, "wpa2-wpa3-personal")) { fprintf(hostapd, "wpa=2\n"); /* Allow both PSK (WPA2) and SAE (WPA3) authentication */ - fprintf(hostapd, "wpa_key_mgmt=WPA-PSK SAE\n"); + fprintf(hostapd, "wpa_key_mgmt=%sWPA-PSK SAE\n", + enable_80211r ? "FT-PSK FT-SAE " : ""); fprintf(hostapd, "rsn_pairwise=CCMP\n"); if (secret) { fprintf(hostapd, "wpa_passphrase=%s\n", secret); @@ -356,6 +480,62 @@ static void wifi_gen_ssid_config(FILE *hostapd, struct lyd_node *cif, struct lyd fprintf(hostapd, "ieee80211w=1\n"); } + /* 802.11r: Fast BSS Transition */ + if (enable_80211r) { + fprintf(hostapd, "# Fast BSS Transition (802.11r)\n"); + fprintf(hostapd, "mobility_domain=%s\n", mobility_domain); + /* Over-the-air FT: the client authenticates directly with the + * target AP. Over-DS would relay the exchange through the wireless + * mesh backhaul, adding latency and packet loss, so it is not used. */ + fprintf(hostapd, "ft_over_ds=0\n"); + fprintf(hostapd, "ft_psk_generate_local=1\n"); + fprintf(hostapd, "nas_identifier=%s\n", nas_identifier_cfg); + } + + /* 802.11k: Radio Resource Management */ + if (enable_80211k) { + fprintf(hostapd, "# Radio Resource Management (802.11k)\n"); + fprintf(hostapd, "rrm_neighbor_report=1\n"); + fprintf(hostapd, "rrm_beacon_report=1\n"); + } + + /* 802.11v: BSS Transition Management */ + if (enable_80211v) { + fprintf(hostapd, "# BSS Transition Management (802.11v)\n"); + fprintf(hostapd, "bss_transition=1\n"); + } + + /* OKC: Opportunistic Key Caching */ + if (roaming) { + const char *okc = lydx_get_cattr(roaming, "okc"); + + if (!okc || !strcmp(okc, "true")) + fprintf(hostapd, "okc=1\n"); + } + + /* MBO: Multi-Band Operation. Advertises multi-band capability so + * MBO-aware clients make their own band-steering decisions; pairs + * with 802.11v BSS Transition Management above. */ + if (enable_mbo) { + const char *twin; + + fprintf(hostapd, "mbo=1\n"); + + /* Required for no_probe_resp_if_seen_on below: without it + * hostapd keeps no sta_track list, so the twin radio never + * knows which clients it has seen. Radio-level, emit once + * in the main section, never per BSS. */ + if (!is_bss) + fprintf(hostapd, "track_sta_max_num=100\n"); + + /* Active band steering: on a 2.4 GHz BSS, suppress probe + * responses to clients recently seen on the same-SSID 5/6 + * GHz BSS, nudging dual-band clients to the higher band. */ + twin = wifi_find_higher_band_twin(config, band, ssid); + if (twin) + fprintf(hostapd, "no_probe_resp_if_seen_on=%s\n", twin); + } + free(secret); } @@ -420,19 +600,290 @@ static const char *wifi_ht40_dir(int ch) return ((ch / 4) % 2) ? "[HT40+]" : "[HT40-]"; } +/* + * Read HT/VHT capability bitmasks from hardware via iw.py. + * Returns 0 on success, -1 on failure. + */ +static int wifi_read_phy_caps(const char *radio_name, unsigned int *ht_cap, unsigned int *vht_cap) +{ + json_error_t jerr; + json_t *root, *jht, *jvht; + char buf[256]; + size_t len; + FILE *pp; + + *ht_cap = 0; + *vht_cap = 0; + + pp = popenf("r", "/usr/libexec/infix/iw.py caps %s", radio_name); + if (!pp) + return -1; + + len = fread(buf, 1, sizeof(buf) - 1, pp); + pclose(pp); + buf[len] = '\0'; + + /* + * Parse JSON output: {"ht_cap": NNN, "vht_cap": NNN} + * Use jansson since hardware.c already includes it. + */ + root = json_loads(buf, 0, &jerr); + if (!root) + return -1; + + jht = json_object_get(root, "ht_cap"); + jvht = json_object_get(root, "vht_cap"); + + if (json_is_integer(jht)) + *ht_cap = (unsigned int)json_integer_value(jht); + if (json_is_integer(jvht)) + *vht_cap = (unsigned int)json_integer_value(jvht); + + json_decref(root); + return 0; +} + +/* + * Build hostapd ht_capab string from HT capability bitmask. + * IEEE 802.11-2016 Table 9-153 — HT Capabilities Info field + * + * The ht40_dir string ("[HT40+]" or "[HT40-]") is prepended when + * the configured channel width is 40MHz or wider. + */ +static void wifi_build_ht_capab(char *out, size_t sz, unsigned int ht_cap, + const char *ht40_dir, int want_ht40) +{ + char *p = out; + size_t rem = sz; + int n; + + *p = '\0'; + + if (want_ht40 && ht40_dir) { + n = snprintf(p, rem, "%s", ht40_dir); + p += n; + rem -= n; + } + + if (ht_cap & 0x0001) { + n = snprintf(p, rem, "[LDPC]"); + p += n; + rem -= n; + } + if (ht_cap & 0x0020) { + n = snprintf(p, rem, "[SHORT-GI-20]"); + p += n; + rem -= n; + } + if (ht_cap & 0x0040) { + n = snprintf(p, rem, "[SHORT-GI-40]"); + p += n; + rem -= n; + } + if (ht_cap & 0x0080) { + n = snprintf(p, rem, "[TX-STBC]"); + p += n; + rem -= n; + } + + /* RX-STBC: bits 8-9 */ + switch ((ht_cap >> 8) & 0x3) { + case 1: + n = snprintf(p, rem, "[RX-STBC1]"); + p += n; + rem -= n; + break; + case 2: + n = snprintf(p, rem, "[RX-STBC12]"); + p += n; + rem -= n; + break; + case 3: + n = snprintf(p, rem, "[RX-STBC123]"); + p += n; + rem -= n; + break; + } + + /* Max A-MSDU Length: bit 11 */ + if (ht_cap & 0x0800) { + n = snprintf(p, rem, "[MAX-AMSDU-7935]"); + p += n; + rem -= n; + } +} + +/* + * Build hostapd vht_capab string from VHT capability bitmask. + * IEEE 802.11-2016 Table 9-250 — VHT Capabilities Info field + * + * When the configured width is less than 160MHz, the VHT160 and + * SHORT-GI-160 flags are masked out to prevent hostapd from + * advertising capabilities the configuration does not use. + */ +static void wifi_build_vht_capab(char *out, size_t sz, unsigned int vht_cap, int chwidth) +{ + char *p = out; + size_t rem = sz; + int n; + + *p = '\0'; + + /* Max MPDU Length: bits 0-1 */ + switch (vht_cap & 0x3) { + case 1: + n = snprintf(p, rem, "[MAX-MPDU-7991]"); + p += n; + rem -= n; + break; + case 2: + n = snprintf(p, rem, "[MAX-MPDU-11454]"); + p += n; + rem -= n; + break; + } + + /* Supported Channel Width: bits 2-3 */ + if (chwidth >= 2) { + switch ((vht_cap >> 2) & 0x3) { + case 1: + n = snprintf(p, rem, "[VHT160]"); + p += n; + rem -= n; + break; + case 2: + n = snprintf(p, rem, "[VHT160-80PLUS80]"); + p += n; + rem -= n; + break; + } + } + + /* RXLDPC: bit 4 */ + if (vht_cap & 0x10) { + n = snprintf(p, rem, "[RXLDPC]"); + p += n; + rem -= n; + } + + /* Short GI for 80MHz: bit 5 */ + if (vht_cap & 0x20) { + n = snprintf(p, rem, "[SHORT-GI-80]"); + p += n; + rem -= n; + } + + /* Short GI for 160MHz: bit 6 — only when configured for 160MHz */ + if (chwidth >= 2 && (vht_cap & 0x40)) { + n = snprintf(p, rem, "[SHORT-GI-160]"); + p += n; + rem -= n; + } + + /* TX STBC: bit 7 */ + if (vht_cap & 0x80) { + n = snprintf(p, rem, "[TX-STBC-2BY1]"); + p += n; + rem -= n; + } + + /* RX STBC: bits 8-10 */ + switch ((vht_cap >> 8) & 0x7) { + case 1: + n = snprintf(p, rem, "[RX-STBC-1]"); + p += n; + rem -= n; + break; + case 2: + n = snprintf(p, rem, "[RX-STBC-12]"); + p += n; + rem -= n; + break; + case 3: + n = snprintf(p, rem, "[RX-STBC-123]"); + p += n; + rem -= n; + break; + case 4: + n = snprintf(p, rem, "[RX-STBC-1234]"); + p += n; + rem -= n; + break; + } + + /* SU Beamformer: bit 11 */ + if (vht_cap & 0x800) { + n = snprintf(p, rem, "[SU-BEAMFORMER]"); + p += n; + rem -= n; + } + + /* SU Beamformee: bit 12 */ + if (vht_cap & 0x1000) { + n = snprintf(p, rem, "[SU-BEAMFORMEE]"); + p += n; + rem -= n; + } + + /* MU Beamformer: bit 19 */ + if (vht_cap & 0x80000) { + n = snprintf(p, rem, "[MU-BEAMFORMER]"); + p += n; + rem -= n; + } + + /* MU Beamformee: bit 20 */ + if (vht_cap & 0x100000) { + n = snprintf(p, rem, "[MU-BEAMFORMEE]"); + p += n; + rem -= n; + } + + /* VHT TXOP PS: bit 21 */ + if (vht_cap & 0x200000) { + n = snprintf(p, rem, "[VHT-TXOP-PS]"); + p += n; + rem -= n; + } + + /* HTC-VHT: bit 22 */ + if (vht_cap & 0x400000) { + n = snprintf(p, rem, "[HTC-VHT]"); + p += n; + rem -= n; + } + + /* Max A-MPDU Length Exponent: bits 23-25 */ + n = (vht_cap >> 23) & 0x7; + if (n) { + int wrote = snprintf(p, rem, "[MAX-A-MPDU-LEN-EXP%d]", n); + p += wrote; + rem -= wrote; + } +} + /* Helper: Write radio-specific configuration */ -static void wifi_gen_radio_config(FILE *hostapd, struct lyd_node *radio_node) +static void wifi_gen_radio_config(FILE *hostapd, const char *radio_name, + struct lyd_node *radio_node) { const char *country, *channel, *band, *width; + unsigned int ht_cap = 0, vht_cap = 0; + char ht_capab[512], vht_capab[512]; + int chwidth = 0; /* 0=20/40, 1=80, 2=160 */ int ch = 0; + bool legacy_rates; country = lydx_get_cattr(radio_node, "country-code"); band = lydx_get_cattr(radio_node, "band"); channel = lydx_get_cattr(radio_node, "channel"); width = lydx_get_cattr(radio_node, "channel-width"); + legacy_rates = lydx_is_enabled(radio_node, "legacy-rates"); if (channel && strcmp(channel, "auto")) ch = atoi(channel); + /* Read HT/VHT hardware capabilities from PHY */ + wifi_read_phy_caps(radio_name, &ht_cap, &vht_cap); + if (country) fprintf(hostapd, "country_code=%s\n", country); @@ -459,13 +910,18 @@ static void wifi_gen_radio_config(FILE *hostapd, struct lyd_node *radio_node) fprintf(hostapd, "hw_mode=g\n"); /* - * Disable legacy 802.11b rates (1, 2, 5.5, 11 Mbps). - * Slow clients using these rates consume excessive - * airtime, degrading performance for all clients. + * Disable legacy 802.11b rates (1, 2, 5.5, 11 Mbps) + * unless explicitly enabled via 'legacy-rates'. Slow + * 802.11b clients consume excessive airtime, degrading + * performance for all clients. When enabled, hostapd + * keeps its default rate set so old 2.4GHz-only IoT + * devices can still associate. * Rates in 0.5 Mbps units: 60=6M, 90=9M, etc. */ - fprintf(hostapd, "supported_rates=60 90 120 180 240 360 480 540\n"); - fprintf(hostapd, "basic_rates=60 120 240\n"); + if (!legacy_rates) { + fprintf(hostapd, "supported_rates=60 90 120 180 240 360 480 540\n"); + fprintf(hostapd, "basic_rates=60 120 240\n"); + } } else if (!strcmp(band, "5GHz") || !strcmp(band, "6GHz")) { /* hw_mode=a: 5GHz/6GHz with 802.11a (OFDM) as baseline */ fprintf(hostapd, "hw_mode=a\n"); @@ -511,14 +967,13 @@ static void wifi_gen_radio_config(FILE *hostapd, struct lyd_node *radio_node) } /* - * Channel width configuration. + * Channel width + HT/VHT capability configuration. * - * 6GHz: bandwidth is determined by op_class (131-134), - * hostapd ignores he_oper_chwidth on 6GHz. No VHT/HT. - * - * 5GHz: requires explicit ht_capab/vht_capab strings - * matching hardware. Without vht_capab, 160MHz is not - * advertised in beacons. + * hostapd requires explicit ht_capab and vht_capab strings + * that match hardware capabilities. Without vht_capab, 160MHz + * support is not advertised in beacons, causing clients to + * connect at 80MHz. Read capabilities from hardware via iw.py + * and build the capability strings from the bitmasks. */ if (!strcmp(band, "6GHz")) { int op_class = 133; /* default 80MHz for 6GHz */ @@ -544,13 +999,18 @@ static void wifi_gen_radio_config(FILE *hostapd, struct lyd_node *radio_node) } } else if (width && strcmp(width, "auto")) { if (!strcmp(width, "20MHz")) { - fprintf(hostapd, "ht_capab=\n"); + chwidth = 0; + wifi_build_ht_capab(ht_capab, sizeof(ht_capab), ht_cap, NULL, 0); + fprintf(hostapd, "ht_capab=%s\n", ht_capab); if (strcmp(band, "2.4GHz")) { fprintf(hostapd, "vht_oper_chwidth=0\n"); fprintf(hostapd, "he_oper_chwidth=0\n"); } } else if (!strcmp(width, "40MHz")) { - fprintf(hostapd, "ht_capab=%s\n", ch ? wifi_ht40_dir(ch) : "[HT40+]"); + chwidth = 0; + wifi_build_ht_capab(ht_capab, sizeof(ht_capab), ht_cap, + ch ? wifi_ht40_dir(ch) : "[HT40+]", 1); + fprintf(hostapd, "ht_capab=%s\n", ht_capab); if (strcmp(band, "2.4GHz")) { fprintf(hostapd, "vht_oper_chwidth=0\n"); fprintf(hostapd, "he_oper_chwidth=0\n"); @@ -558,7 +1018,11 @@ static void wifi_gen_radio_config(FILE *hostapd, struct lyd_node *radio_node) } else if (!strcmp(width, "80MHz") && ch) { int center = wifi_center_chan_80(ch); - fprintf(hostapd, "ht_capab=%s\n", wifi_ht40_dir(ch)); + chwidth = 1; + wifi_build_ht_capab(ht_capab, sizeof(ht_capab), ht_cap, wifi_ht40_dir(ch), 1); + wifi_build_vht_capab(vht_capab, sizeof(vht_capab), vht_cap, chwidth); + fprintf(hostapd, "ht_capab=%s\n", ht_capab); + fprintf(hostapd, "vht_capab=%s\n", vht_capab); fprintf(hostapd, "vht_oper_chwidth=1\n"); fprintf(hostapd, "he_oper_chwidth=1\n"); if (center) { @@ -568,7 +1032,11 @@ static void wifi_gen_radio_config(FILE *hostapd, struct lyd_node *radio_node) } else if (!strcmp(width, "160MHz") && ch) { int center = wifi_center_chan_160(ch); - fprintf(hostapd, "ht_capab=%s\n", wifi_ht40_dir(ch)); + chwidth = 2; + wifi_build_ht_capab(ht_capab, sizeof(ht_capab), ht_cap, wifi_ht40_dir(ch), 1); + wifi_build_vht_capab(vht_capab, sizeof(vht_capab), vht_cap, chwidth); + fprintf(hostapd, "ht_capab=%s\n", ht_capab); + fprintf(hostapd, "vht_capab=%s\n", vht_capab); fprintf(hostapd, "vht_oper_chwidth=2\n"); fprintf(hostapd, "he_oper_chwidth=2\n"); if (center) { @@ -648,7 +1116,7 @@ static int wifi_gen_aps_on_radio(const char *radio_name, struct lyd_node *cifs, fprintf(hostapd, "\n"); /* Radio-specific configuration */ - wifi_gen_radio_config(hostapd, radio_node); + wifi_gen_radio_config(hostapd, radio_name, radio_node); /* Add BSS sections for secondary APs (multi-SSID) */ for (i = 1; i < ap_count; i++) { @@ -725,6 +1193,7 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l struct lyd_node *difs = NULL, *dif = NULL; int rc = SR_ERR_OK; int gps_changed = 0; + int wifi_changed = 0; if (!lydx_find_xpathf(diff, XPATH_BASE_)) return SR_ERR_OK; @@ -786,34 +1255,25 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l wifi_find_interfaces_on_radio(interfaces_diff, name, &wifi_iface_list, &wifi_iface_count); if (wifi_iface_count > 0) { - bool running, enabled; - ap = lydx_get_descendant(wifi_iface_list[0], "interface", "wifi", "access-point", NULL); if (ap && lydx_get_op(ap) != LYDX_OP_DELETE) { - /* AP mode - activate hostapd for radio */ snprintf(src, sizeof(src), HOSTAPD_CONF_NEXT, name); snprintf(dst, sizeof(dst), HOSTAPD_CONF, name); - running = !systemf("initctl -bfq status hostapd:%s", name); - enabled = fexistf(HOSTAPD_CONF_NEXT, name); - - if (enabled) { + if (fexistf(HOSTAPD_CONF_NEXT, name)) { (void)rename(src, dst); ap_interfaces++; - - if (running) - finit_reloadf("hostapd@%s", name); - else - finit_enablef("hostapd@%s", name); } } } if (!ap_interfaces) { - finit_disablef("hostapd@%s", name); erasef(HOSTAPD_CONF, name); erasef(HOSTAPD_CONF_NEXT, name); } free(wifi_iface_list); + /* All radios share one hostapd process; the service is + * (re)generated after the component loop below. */ + wifi_changed = 1; continue; default: continue; @@ -909,6 +1369,45 @@ int hardware_change(sr_session_ctx_t *session, struct lyd_node *config, struct l break; } } + + /* + * All AP radios run in a single hostapd process so that + * cross-radio directives (e.g. no_probe_resp_if_seen_on for band + * steering) resolve: those only consult interfaces managed by the + * same process. Regenerate the combined service from every staged + * /etc/hostapd-.conf and (re)start it. A restart re-reads + * all radio configs, so band edits and radio add/remove both apply. + */ + if (wifi_changed && event == SR_EV_DONE) { + glob_t gl = { 0 }; + + if (glob("/etc/hostapd-*.conf", 0, NULL, &gl) == 0 && gl.gl_pathc > 0) { + FILE *fp; + + fp = fopen(HOSTAPD_SERVICE, "w"); + if (!fp) { + ERRNO("Could not open " HOSTAPD_SERVICE); + rc = SR_ERR_INTERNAL; + } else { + size_t i; + + fprintf(fp, "# Generated by confd, do not edit.\n"); + fprintf(fp, "service name:hostapd \\\n"); + fprintf(fp, "\t[2345] hostapd -P /run/hostapd.pid"); + for (i = 0; i < gl.gl_pathc; i++) + fprintf(fp, " %s", gl.gl_pathv[i]); + fprintf(fp, " \\\n\t-- Wi-Fi Access Points\n"); + fclose(fp); + + finit_enable("hostapd"); + finit_reload("hostapd"); + } + } else { + unlink(HOSTAPD_SERVICE); + finit_disable("hostapd"); + } + globfree(&gl); + } err: return rc; diff --git a/src/confd/src/if-wifi.c b/src/confd/src/if-wifi.c index 95ecae28e..3c8bea5d2 100644 --- a/src/confd/src/if-wifi.c +++ b/src/confd/src/if-wifi.c @@ -79,19 +79,24 @@ int wifi_validate_secret(sr_session_ctx_t *session, struct lyd_node *cif) wifi_mode_t wifi_get_mode(struct lyd_node *iface) { - struct lyd_node *ap, *wifi; + struct lyd_node *ap, *mesh, *wifi; wifi = lydx_get_child(iface, "wifi"); if (!wifi) return wifi_unknown; - ap = lydx_get_child(wifi, "access-point"); if (ap) { if (lydx_get_op(ap) != LYDX_OP_DELETE) return wifi_ap; } + mesh = lydx_get_child(wifi, "mesh-point"); + if (mesh) { + if (lydx_get_op(mesh) != LYDX_OP_DELETE) + return wifi_mesh; + } + /* * Need to return station even if "station" also is false, * because station is the default scanning mode. @@ -101,19 +106,25 @@ wifi_mode_t wifi_get_mode(struct lyd_node *iface) int wifi_mode_changed(struct lyd_node *wifi) { - enum lydx_op ap_op = LYDX_OP_DELETE; - struct lyd_node *ap; + enum lydx_op op = LYDX_OP_DELETE; + struct lyd_node *node; if (!wifi) return 0; - ap = lydx_get_child(wifi, "access-point"); - if (ap) - ap_op = lydx_get_op(ap); + node = lydx_get_child(wifi, "access-point"); + if (node) + op = lydx_get_op(node); + if (node && (op == LYDX_OP_CREATE || op == LYDX_OP_DELETE)) + return 1; - DEBUG("MODE CHANGED: %d", ap && (ap_op == LYDX_OP_CREATE || ap_op == LYDX_OP_DELETE)); + node = lydx_get_child(wifi, "mesh-point"); + if (node) + op = lydx_get_op(node); + if (node && (op == LYDX_OP_CREATE || op == LYDX_OP_DELETE)) + return 1; - return (ap && (ap_op == LYDX_OP_CREATE || ap_op == LYDX_OP_DELETE)); + return 0; } /* @@ -150,7 +161,8 @@ int wifi_gen_station(struct lyd_node *cif) secret_name = NULL; } - radio_node = lydx_get_xpathf(cif, "../../hardware/component[name='%s']/wifi-radio", radio); + radio_node = lydx_get_xpathf(cif, + "/ietf-hardware:hardware/component[name='%s']/infix-hardware:wifi-radio", radio); country = lydx_get_cattr(radio_node, "country-code"); if (secret_name && strcmp(security_mode, "disabled") != 0) { @@ -171,10 +183,8 @@ int wifi_gen_station(struct lyd_node *cif) goto out; } - /* - * Background scanning every 10 seconds while not associated, when we - * have an SSID (below), bgscan assumes this task. - */ + /* autoscan drives scanning every 10s while unassociated; once + * associated no background scanning runs (bgscan disabled below). */ fprintf(wpa_supplicant, "ctrl_interface=/run/wpa_supplicant\n" "autoscan=periodic:10\n" @@ -190,12 +200,15 @@ int wifi_gen_station(struct lyd_node *cif) asprintf(&security_str, "key_mgmt=NONE"); else if (secret) asprintf(&security_str, - "key_mgmt=SAE WPA-PSK\n" + "key_mgmt=FT-SAE FT-PSK SAE WPA-PSK\n" " psk=\"%s\"", secret); + /* bgscan="" disables background scanning once associated: on a + * fixed backhaul link the periodic off-channel sweep stalls + * traffic and buys no roaming benefit. */ fprintf(wpa_supplicant, "network={\n" - " bgscan=\"simple: 30:-45:300\"\n" + " bgscan=\"\"\n" " ssid=\"%s\"\n" " %s\n" "}\n", ssid, security_str); @@ -227,6 +240,195 @@ int wifi_gen_station(struct lyd_node *cif) return rc; } + + +/* + * Center channel for 80MHz VHT/HE operation. + * 5GHz 80MHz channel groups and their center channels: + * 36-48(42), 52-64(58), 100-112(106), + * 116-128(122), 132-144(138), 149-161(155) + */ +static int wifi_center_chan_80(int ch) +{ + static const int grp[][2] = { + {36, 42}, {52, 58}, {100, 106}, {116, 122}, {132, 138}, {149, 155} + }; + int i; + + for (i = 0; i < 6; i++) + if (ch >= grp[i][0] && ch < grp[i][0] + 16) + return grp[i][1]; + return 0; +} + +/* + * Center channel for 160MHz VHT/HE operation. + * 5GHz 160MHz groups: 36-64(50), 100-128(114) + */ +static int wifi_center_chan_160(int ch) +{ + if (ch >= 36 && ch <= 64) + return 50; + if (ch >= 100 && ch <= 128) + return 114; + return 0; +} + +/* + * Convert WiFi channel number to frequency in MHz. + * Band is determined from channel range: + * 2.4GHz: channels 1-14 + * 5GHz: channels 32-177 + * 6GHz: channels 1-233 (identified by band string) + */ +static int wifi_chan_to_freq(int channel, const char *band) +{ + if (!strcmp(band, "6GHz")) + return 5950 + channel * 5; + + if (channel >= 1 && channel <= 13) + return 2407 + channel * 5; + if (channel == 14) + return 2484; + + /* 5GHz */ + return 5000 + channel * 5; +} + +/* + * Generate wpa_supplicant config for 802.11s mesh mode + */ +int wifi_gen_mesh(struct lyd_node *cif) +{ + const char *ifname, *mesh_id, *secret_name, *radio; + struct lyd_node *mesh, *security, *secret_node, *radio_node, *wifi; + const char *country, *band, *width; + FILE *wpa_supplicant = NULL; + unsigned char *secret = NULL; + int rc = SR_ERR_OK; + int channel, freq; + mode_t oldmask; + bool forwarding; + + ifname = lydx_get_cattr(cif, "name"); + wifi = lydx_get_child(cif, "wifi"); + if (!wifi) + return SR_ERR_OK; + + radio = lydx_get_cattr(wifi, "radio"); + mesh = lydx_get_child(wifi, "mesh-point"); + if (!mesh) + return SR_ERR_OK; + + mesh_id = lydx_get_cattr(mesh, "mesh-id"); + forwarding = lydx_is_enabled(mesh, "forwarding"); + + security = lydx_get_child(mesh, "security"); + secret_name = lydx_get_cattr(security, "secret"); + + radio_node = lydx_get_xpathf(cif, + "/ietf-hardware:hardware/component[name='%s']/infix-hardware:wifi-radio", radio); + country = lydx_get_cattr(radio_node, "country-code"); + band = lydx_get_cattr(radio_node, "band"); + width = lydx_get_cattr(radio_node, "channel-width"); + channel = atoi(lydx_get_cattr(radio_node, "channel") ? : "0"); + if (!channel && band && width && !strcmp(width, "auto")) { + if (!strcmp(band, "2.4GHz")) + channel = 6; + else if (!strcmp(band, "5GHz")) + channel = 36; + else if (!strcmp(band, "6GHz")) + channel = 1; + } + + freq = wifi_chan_to_freq(channel, band); + + if (secret_name) { + const char *b64; + + secret_node = lydx_get_xpathf(cif, + "../../keystore/symmetric-keys/symmetric-key[name='%s']", + secret_name); + b64 = lydx_get_cattr(secret_node, "cleartext-symmetric-key"); + if (b64) + secret = base64_decode((const unsigned char *)b64, strlen(b64), NULL); + } + + oldmask = umask(0077); + wpa_supplicant = fopenf("w", WPA_SUPPLICANT_CONF, ifname); + if (!wpa_supplicant) { + rc = SR_ERR_INTERNAL; + goto out; + } + + fprintf(wpa_supplicant, "ctrl_interface=/run/wpa_supplicant\n"); + if (country) + fprintf(wpa_supplicant, "country=%s\n", country); + + /* Global scope only (maps to peer_link_timeout): raise the 300s + * default so a fixed backhaul peer is not idled out and re-peered + * (MESH_CLOSE, reason 55), which briefly breaks forwarding. */ + fprintf(wpa_supplicant, "mesh_max_inactivity=3600\n"); + + fprintf(wpa_supplicant, "\nnetwork={\n"); + fprintf(wpa_supplicant, " mode=5\n"); + fprintf(wpa_supplicant, " ssid=\"%s\"\n", mesh_id); + fprintf(wpa_supplicant, " frequency=%d\n", freq); + + if (band && !strcmp(band, "6GHz") && (!width || !strcmp(width, "auto"))) { + int center = wifi_center_chan_80(channel); + + fprintf(wpa_supplicant, " ht40=1\n"); + fprintf(wpa_supplicant, " he=1\n"); + fprintf(wpa_supplicant, " max_oper_chwidth=1\n"); + if (center) + fprintf(wpa_supplicant, " vht_center_freq1=%d\n", wifi_chan_to_freq(center, band)); + } else if (width && strcmp(width, "auto")) { + if (!strcmp(width, "20MHz")) { + fprintf(wpa_supplicant, " disable_ht40=1\n"); + } else if (!strcmp(width, "40MHz")) { + fprintf(wpa_supplicant, " ht40=1\n"); + } else if (!strcmp(width, "80MHz")) { + int center = wifi_center_chan_80(channel); + + fprintf(wpa_supplicant, " ht40=1\n"); + if (!strcmp(band, "6GHz")) + fprintf(wpa_supplicant, " he=1\n"); + else + fprintf(wpa_supplicant, " vht=1\n"); + fprintf(wpa_supplicant, " max_oper_chwidth=1\n"); + if (center) + fprintf(wpa_supplicant, " vht_center_freq1=%d\n", wifi_chan_to_freq(center, band)); + } else if (!strcmp(width, "160MHz")) { + int center = wifi_center_chan_160(channel); + + fprintf(wpa_supplicant, " ht40=1\n"); + if (!strcmp(band, "6GHz")) + fprintf(wpa_supplicant, " he=1\n"); + else + fprintf(wpa_supplicant, " vht=1\n"); + fprintf(wpa_supplicant, " max_oper_chwidth=2\n"); + if (center) + fprintf(wpa_supplicant, " vht_center_freq1=%d\n", wifi_chan_to_freq(center, band)); + } + } + fprintf(wpa_supplicant, " mesh_fwding=%d\n", forwarding ? 1 : 0); + + fprintf(wpa_supplicant, " key_mgmt=SAE\n"); + fprintf(wpa_supplicant, " ieee80211w=2\n"); + if (secret) + fprintf(wpa_supplicant, " sae_password=\"%s\"\n", secret); + + fprintf(wpa_supplicant, "}\n"); + +out: + free(secret); + if (wpa_supplicant) + fclose(wpa_supplicant); + umask(oldmask); + + return rc; +} /* * Get probe-timeout for a radio from sysrepo config. * Returns 0 if not set. @@ -283,7 +485,7 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) fprintf(iw, "# Generated by Infix confd - WiFi Interface Creation\n"); fprintf(iw, "# Create %s interface %s on radio %s\n", - mode == wifi_station ? "station" : "access point", ifname, radio); + mode == wifi_station ? "station" : (mode == wifi_mesh ? "mesh" : "access point"), ifname, radio); /* Wait for PHY if probe-timeout is set (slow USB dongles) */ if (probe_timeout > 0) { @@ -317,6 +519,12 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net) case wifi_ap: fprintf(iw, "iw phy %s interface add %s type __ap\n", radio, ifname); break; + case wifi_mesh: + fprintf(iw, "iw phy %s interface add %s type mesh\n", radio, ifname); + wifi_gen_mesh(cif); + fprintf(iw, "initctl -bfq enable mesh@%s\n", ifname); + fprintf(iw, "initctl -bfq touch mesh@%s\n", ifname); + break; default: ERROR("WiFi mode %d unknown", mode); rc = SR_ERR_INVAL_ARG; @@ -350,9 +558,16 @@ int wifi_del_iface(struct lyd_node *dif, struct dagger *net) fprintf(iw, "iw dev %s del 2>/dev/null || ip link del %s 2>/dev/null || true\n", ifname, ifname); wifi = lydx_get_child(dif, "wifi"); - if (wifi && wifi_get_mode(wifi) != wifi_ap) { - erasef(WPA_SUPPLICANT_CONF, ifname); - fprintf(iw, "initctl -bfq disable wifi@%s\n", ifname); + if (wifi) { + int mode = wifi_get_mode(wifi); + + if (mode == wifi_mesh) { + erasef(WPA_SUPPLICANT_CONF, ifname); + fprintf(iw, "initctl -bfq disable mesh@%s\n", ifname); + } else if (mode != wifi_ap) { + erasef(WPA_SUPPLICANT_CONF, ifname); + fprintf(iw, "initctl -bfq disable wifi@%s\n", ifname); + } } fclose(iw); diff --git a/src/confd/src/interfaces.c b/src/confd/src/interfaces.c index a255c20ea..132ccca87 100644 --- a/src/confd/src/interfaces.c +++ b/src/confd/src/interfaces.c @@ -428,6 +428,8 @@ static int netdag_gen_afspec_set(sr_session_ctx_t *session, struct dagger *net, if (wifi_get_mode(cif) == wifi_station) return wifi_validate_secret(session, cif) ? : wifi_gen_station(cif); + if (wifi_get_mode(cif) == wifi_mesh) + return wifi_gen_mesh(cif); return 0; case IFT_DUMMY: case IFT_GRE: diff --git a/src/confd/src/interfaces.h b/src/confd/src/interfaces.h index a427872f0..a7bb77c07 100644 --- a/src/confd/src/interfaces.h +++ b/src/confd/src/interfaces.h @@ -130,6 +130,7 @@ int bridge_port_gen(struct lyd_node *dif, struct lyd_node *cif, FILE *ip); typedef enum wifi_mode_t { wifi_station, wifi_ap, + wifi_mesh, wifi_unknown } wifi_mode_t; @@ -138,6 +139,7 @@ int wifi_add_iface(struct lyd_node *cif, struct dagger *net); int wifi_del_iface(struct lyd_node *dif, struct dagger *net); int wifi_mode_changed(struct lyd_node *wifi); int wifi_gen_station(struct lyd_node *cif); +int wifi_gen_mesh(struct lyd_node *cif); wifi_mode_t wifi_get_mode(struct lyd_node *wifi); /* if-gre.c */ diff --git a/src/confd/yang/confd/infix-hardware.yang b/src/confd/yang/confd/infix-hardware.yang index 930d12295..b73f9a683 100644 --- a/src/confd/yang/confd/infix-hardware.yang +++ b/src/confd/yang/confd/infix-hardware.yang @@ -345,6 +345,20 @@ module infix-hardware { Only applicable in Access Point mode."; } + leaf legacy-rates { + type boolean; + default false; + description + "Allow legacy 802.11b rates (1, 2, 5.5, 11 Mbps) on 2.4 GHz. + + Disabled by default: slow 802.11b clients consume excessive + airtime and degrade throughput for all associated stations. + Enable only when old 2.4 GHz-only IoT devices require these + rates to associate. + + No effect on 5 GHz or 6 GHz radios."; + } + leaf probe-timeout { type uint8; description diff --git a/src/confd/yang/confd/infix-if-bridge.yang b/src/confd/yang/confd/infix-if-bridge.yang index 3e4d32a2a..7c5eff40b 100644 --- a/src/confd/yang/confd/infix-if-bridge.yang +++ b/src/confd/yang/confd/infix-if-bridge.yang @@ -939,8 +939,8 @@ submodule infix-if-bridge { must "not(../ip:ipv4/ip:address or ../ip:ipv6/ip:address)" { error-message "Bridge ports cannot have IP addresses configured."; } - must "not(derived-from-or-self(../if:type, 'infix-ift:wifi')) or ../infix-if:wifi/infix-if:access-point" { - error-message "WiFi interfaces can only be bridge ports when configured as Access Points."; + must "not(derived-from-or-self(../if:type, 'infix-ift:wifi')) or ../infix-if:wifi/infix-if:access-point or ../infix-if:wifi/infix-if:mesh-point" { + error-message "WiFi interfaces can only be bridge ports when configured as Access Points or Mesh Points."; } description "Bridge association and port specific settings."; uses bridge-port-common; diff --git a/src/confd/yang/confd/infix-if-wifi.yang b/src/confd/yang/confd/infix-if-wifi.yang index 1508cd390..781745f47 100644 --- a/src/confd/yang/confd/infix-if-wifi.yang +++ b/src/confd/yang/confd/infix-if-wifi.yang @@ -48,6 +48,15 @@ submodule infix-if-wifi { - Security: WPA2/WPA3 with keystore integration - Operational state: Connection status, RSSI, client lists"; + revision 2026-03-06 { + description + "Adding mesh support and roaming. + - Add 802.11s mesh point mode support. + - Add band-steering (MBO) support and OKC configuration. + - Add support for roaming, by adding support for 802.11v/k/r"; + reference "internal"; + } + revision 2025-12-17 { description "Major refactoring for AP mode support (BREAKING CHANGE): @@ -72,6 +81,27 @@ submodule infix-if-wifi { description "WiFi support is an optional build-time feature in Infix."; } + typedef mobility-domain { + type union { + type string { + pattern '[0-9a-fA-F]{4}'; + } + type enumeration { + enum hash { + description + "Derive mobility domain by hashing the SSID. + + OpenWrt-compatible: md5(ssid), first 4 hex chars."; + } + } + } + default "4f57"; + description + "802.11r Mobility Domain identifier (4 hex digits). + + Use 'hash' to automatically derive it from the SSID."; + } + augment "/if:interfaces/if:interface" { when "derived-from-or-self(if:type, 'infixift:wifi')" { description @@ -127,12 +157,14 @@ submodule infix-if-wifi { Once you've identified a network, configure either: - Station mode: Connect to an existing WiFi network - Access Point mode: Create a WiFi network for clients + - Mesh Point mode: Create an 802.11s mesh link Note: A radio can host either: - Multiple AP interfaces (multi-SSID), OR - - A single Station interface + - A single Station interface, OR + - A single Mesh Point interface - Mixing AP and Station on the same radio is not supported."; + Mixing AP and Mesh Point on the same radio is not supported."; case station { container station { @@ -421,6 +453,121 @@ submodule infix-if-wifi { } } + container roaming { + description + "Fast roaming and seamless handoff configuration. + + 802.11k: Radio Resource Management (neighbor discovery) + 802.11r: Fast BSS Transition (fast handoff) + 802.11v: BSS Transition Management (network-assisted roaming)"; + + container dot11k { + presence "Enable 802.11k Radio Resource Management"; + description + "Enable 802.11k Radio Resource Management. + + Allows clients to discover neighboring APs for better + roaming decisions. Enables: + - Neighbor reports + - Beacon reports"; + } + + container dot11r { + presence "Enable 802.11r Fast BSS Transition"; + must "../../security/mode != 'open'" { + error-message "802.11r requires WPA2/WPA3 security and cannot be used with open networks."; + } + description + "Enable 802.11r Fast BSS Transition. + + Reduces handoff latency from ~1s to <50ms through + pre-authentication. Required for seamless roaming."; + + leaf mobility-domain { + type mobility-domain; + default "4f57"; + description + "802.11r Mobility Domain identifier. + + All APs that clients should roam between MUST use the + same mobility domain ID. + + Can be either: + - A 4-character hex string (e.g., '4f57') + - 'hash' to auto-derive from SSID (OpenWrt-compatible)"; + } + + leaf nas-identifier { + type union { + type enumeration { + enum auto { + description + "Automatically derive NAS-Identifier as: + -.."; + } + } + type string { + length "1..253"; + } + } + default auto; + description + "Override NAS-Identifier used for 802.11r. + + Set to 'auto' to derive: + -.. + + This value should be unique per AP BSS and stable."; + } + } + + container dot11v { + presence "Enable 802.11v BSS Transition Management"; + description + "Enable 802.11v BSS Transition Management. + + Allows APs to suggest better APs to clients, improving + network-assisted roaming decisions."; + + leaf band-steering { + type boolean; + default true; + description + "Multi-Band Operation (MBO) band steering. + + Signals band preferences to capable clients, + encouraging dual-band devices to prefer 5GHz + over 2.4GHz when signal quality permits. + + MBO steers clients via 802.11v BSS Transition + Management, hence its placement under dot11v; + enabling dot11v enables band steering by default. + + Only meaningful when the same SSID is offered on + two or more bands (an access-point per radio); a + single-band BSS has no other band to steer toward. + + Safe to leave enabled (default) - only activates + when both AP and client support MBO."; + } + } + + leaf okc { + type boolean; + default true; + description + "Opportunistic Key Caching (OKC). + + Reduces re-authentication time for roaming clients + that don't support 802.11r. The AP caches PMK from + previous associations and shares them with other APs + in the same mobility group. + + Safe to leave enabled (default) - only activates when + both AP and client support it."; + } + } + /* Operational state */ container stations { @@ -496,6 +643,145 @@ submodule infix-if-wifi { } } } + + case mesh-point { + container mesh-point { + presence "Configure 802.11s mesh point mode"; + + must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:band" { + error-message "Parent radio must have 'band' configured for mesh mode"; + } + + must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:channel" { + error-message "Parent radio must have 'channel' configured for mesh mode"; + } + + must "/iehw:hardware/iehw:component[iehw:name = current()/../radio]/ih:wifi-radio/ih:country-code != '00'" { + error-message "Country code '00' is not allowed for mesh mode."; + } + + must "not(/if:interfaces/if:interface[wifi/access-point][wifi/radio = current()/../radio])" { + error-message "Mesh point and access point cannot coexist on the same radio"; + } + + description + "802.11s Mesh Point mode configuration. + + In mesh mode, the interface creates a peer-to-peer mesh + link with other mesh points sharing the same mesh ID. + + Only one mesh point interface is allowed per radio. + Mesh point and access point cannot share the same radio. + + Example use case: Wireless backhaul between APs."; + + leaf mesh-id { + type string { + length "1..32"; + } + mandatory true; + description + "Mesh network identifier. + + All mesh points that should form a mesh network + must use the same mesh ID."; + } + + leaf forwarding { + type boolean; + default true; + description + "Enable layer-2 mesh forwarding (mesh_fwding). + + When true, the mesh interface can be added to a Linux + bridge for transparent L2 connectivity (mesh portal). + + When false, only locally destined traffic is received."; + } + + container security { + description + "Mesh security configuration. + + All mesh links use WPA3-SAE encryption."; + + leaf secret { + type ks:central-symmetric-key-ref; + mandatory true; + description + "Pre-shared key (PSK) reference for SAE mesh. + + References a symmetric key in the keystore. + All mesh points in the same mesh must share + the same key."; + } + } + + /* Operational state */ + + container peers { + list peer { + config false; + key mac-address; + description + "List of currently connected mesh peers."; + + leaf mac-address { + type yang:mac-address; + description "Mesh peer MAC address."; + } + + leaf signal-strength { + type int16; + units "dBm"; + description "Peer signal strength in dBm."; + } + + leaf connected-time { + type uint32; + units "seconds"; + description "Time since peer connected, in seconds."; + } + + leaf rx-packets { + type yang:counter64; + description "Packets received from this peer."; + } + + leaf tx-packets { + type yang:counter64; + description "Packets transmitted to this peer."; + } + + leaf rx-bytes { + type yang:counter64; + units "octets"; + description "Bytes received from this peer."; + } + + leaf tx-bytes { + type yang:counter64; + units "octets"; + description "Bytes transmitted to this peer."; + } + + leaf rx-speed { + type uint32; + units "100 kbps"; + description + "Last received data rate from this peer in 100 kbps."; + } + + leaf tx-speed { + type uint32; + units "100 kbps"; + description + "Last transmitted data rate to this peer in 100 kbps."; + } + } + } + } + } } } } diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index 234409f2d..a686e8dbc 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1382,17 +1382,85 @@ def pr_wifi_stations(self): stations_table.print() + def pr_wifi_peers(self): + if not self.wifi: + return + + mesh = self.wifi.get("mesh-point", {}) + peers_data = mesh.get("peers", {}) + peers = peers_data.get("peer", []) + + if not peers: + return + + print("\nCONNECTED PEERS:") + peers_table = SimpleTable([ + Column('MAC'), + Column('SIGNAL'), + Column('TIME'), + Column('RX PKT'), + Column('TX PKT'), + Column('RX BYTES'), + Column('TX BYTES'), + Column('RX SPEED'), + Column('TX SPEED') + ]) + + for peer in peers: + mac = peer.get("mac-address", "unknown") + signal = peer.get("signal-strength") + signal_str = signal_to_status(signal) if signal is not None else "------" + + conn_time = peer.get("connected-time", 0) + time_str = f"{conn_time}s" + + rx_pkt = peer.get("rx-packets", 0) + tx_pkt = peer.get("tx-packets", 0) + rx_bytes = peer.get("rx-bytes", 0) + tx_bytes = peer.get("tx-bytes", 0) + + rx_speed = peer.get("rx-speed", 0) + tx_speed = peer.get("tx-speed", 0) + rx_speed_str = f"{rx_speed / 10:.1f}" if rx_speed else "-" + tx_speed_str = f"{tx_speed / 10:.1f}" if tx_speed else "-" + + peers_table.row(mac, signal_str, time_str, rx_pkt, tx_pkt, + rx_bytes, tx_bytes, rx_speed_str, tx_speed_str) + + peers_table.print() + def pr_proto_wifi(self, pipe=''): - if self.wifi and (ap := self.wifi.get("access-point", {})): - ssid = ap.get("ssid", "------") - stations = ap.get("stations", {}).get("station", []) - data_str = f"access-point ssid: {ssid} stations: {len(stations)}" - elif self.wifi and (station := self.wifi.get("station", {})): - ssid = station.get("ssid", "------") - data_str = f"station ssid: {ssid}" - if (signal := station.get("signal-strength")) is not None: - data_str += f" signal: {signal_to_status(signal)}" + ssid = None + signal = None + mode = None + + if self.wifi: + if "access-point" in self.wifi: + ap = self.wifi["access-point"] + ssid = ap.get("ssid", "------") + mode = "AP" + stations_data = ap.get("stations", {}) + stations = stations_data.get("station", []) + station_count = len(stations) + data_str = f"{mode}, ssid: {ssid}, stations: {station_count}" + elif "mesh-point" in self.wifi: + mesh = self.wifi["mesh-point"] + mesh_id = mesh.get("mesh-id", "------") + mode = "Mesh" + peers_data = mesh.get("peers", {}) + peers = peers_data.get("peer", []) + data_str = f"{mode}, mesh-id: {mesh_id}, peers: {len(peers)}" + else: + station=self.wifi.get("station", {}) + ssid = station.get("ssid", "------") + signal = station.get("signal-strength") + mode = "Station" + if signal is not None: + signal_str = signal_to_status(signal) + data_str = f"{mode}, ssid: {ssid}, signal: {signal_str}" + else: + data_str = f"{mode}, ssid: {ssid}" else: data_str = "ssid: ------" @@ -1664,9 +1732,8 @@ def _addr_lines(addrs): print(f"{'out-octets':<{19}}: {self.out_octets}") if self.wifi: - # Detect mode: AP has "stations", Station has "signal-strength" or "scan-results" - ap = self.wifi.get('access-point') - if ap: + if "access-point" in self.wifi: + ap = self.wifi['access-point'] mode = "access-point" ssid = ap.get('ssid', "----") stations_data = ap.get("stations", {}) @@ -1675,6 +1742,16 @@ def _addr_lines(addrs): print(f"{'ssid':<{19}}: {ssid}") print(f"{'connected stations':<{19}}: {len(stations)}") self.pr_wifi_stations() + elif "mesh-point" in self.wifi: + mesh = self.wifi['mesh-point'] + mode = "mesh-point" + mesh_id = mesh.get('mesh-id', "----") + peers_data = mesh.get("peers", {}) + peers = peers_data.get("peer", []) + print(f"{'mode':<{20}}: {mode}") + print(f"{'mesh-id':<{20}}: {mesh_id}") + print(f"{'connected peers':<{20}}: {len(peers)}") + self.pr_wifi_peers() else: mode = "station" station = self.wifi.get('station', {}) diff --git a/src/statd/python/yanger/ietf_interfaces/wifi.py b/src/statd/python/yanger/ietf_interfaces/wifi.py index 517b23b9a..e4d809422 100644 --- a/src/statd/python/yanger/ietf_interfaces/wifi.py +++ b/src/statd/python/yanger/ietf_interfaces/wifi.py @@ -58,6 +58,21 @@ def wifi_ap(ifname): return {'access-point': ap_data} if ap_data else {} +def wifi_mesh(ifname, info=None): + """Get operational data for mesh point mode using iw""" + mesh_data = {} + + if info is None: + info = get_iw_info(ifname) + if info.get('ssid'): + mesh_data['mesh-id'] = info['ssid'] + + peers = get_iw_stations(ifname) + if peers: + mesh_data['peers'] = {'peer': peers} + + return {'mesh-point': mesh_data} + def wifi_station(ifname): """Get operational data for Station mode using iw + wpa_cli for scanning""" station_data = {} @@ -100,6 +115,8 @@ def wifi(ifname): if mode == 'ap': result.update(wifi_ap(ifname)) + elif mode == 'mesh point': + result.update(wifi_mesh(ifname, info)) else: result.update(wifi_station(ifname))