From ad2001735fd854eeda647db8510fe3bf3951ab53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Tue, 15 Sep 2026 10:50:48 +0200 Subject: [PATCH 01/24] statd: wifi: show mesh-id for 802.11s mesh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'iw dev info' reports no SSID for a mesh interface, so the leaf stayed empty in operational status, in the CLI and in the WebUI. Ask wpa_supplicant instead, the one that joined the mesh, so the mesh id is absent until it actually has rather than echoing what was configured. wpa_supplicant prints SSIDs through printf_encode(), so the value needs decoding. Scan results have done that all along; factor it out and use it for both. Signed-off-by: Mattias Walström --- board/common/rootfs/usr/libexec/infix/iw.py | 2 +- doc/ChangeLog.md | 2 + .../python/yanger/ietf_interfaces/wifi.py | 46 +++++++++++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/board/common/rootfs/usr/libexec/infix/iw.py b/board/common/rootfs/usr/libexec/infix/iw.py index 87cb078e2..407a5ea10 100755 --- a/board/common/rootfs/usr/libexec/infix/iw.py +++ b/board/common/rootfs/usr/libexec/infix/iw.py @@ -273,7 +273,7 @@ def parse_interface_info(ifname): elif stripped.startswith('addr '): result['mac'] = stripped.split()[1] - # SSID (AP mode) or mesh-id (mesh point mode) — kernel uses same attr + # SSID, only reported for AP and station interfaces elif stripped.startswith('ssid '): result['ssid'] = decode_iw_ssid(' '.join(stripped.split()[1:])) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 543556c67..274d35105 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -43,6 +43,8 @@ All notable changes to the project are documented in this file. ### Fixes - Fix #1619: Raspberry Pi kernel panic when configure Wi-Fi +- Wi-Fi mesh point interfaces showed an empty `mesh-id` in operational + status (CLI and WebUI). [relsup]: https://github.com/kernelkit/infix/blob/main/doc/releases.md diff --git a/src/statd/python/yanger/ietf_interfaces/wifi.py b/src/statd/python/yanger/ietf_interfaces/wifi.py index 5e1dee1d5..5ca759f63 100644 --- a/src/statd/python/yanger/ietf_interfaces/wifi.py +++ b/src/statd/python/yanger/ietf_interfaces/wifi.py @@ -58,14 +58,48 @@ def wifi_ap(ifname): return {'access-point': ap_data} if ap_data else {} +def decode_wpa_ssid(ssid): + """Decode an SSID as wpa_supplicant prints it + + wpa_supplicant runs SSIDs through printf_encode(), so anything + outside printable ASCII arrives as \\xHH. Control characters are + dropped, a rogue AP must not get to write escape sequences to a + terminal. + """ + try: + ssid = ssid.encode().decode('unicode_escape').encode('latin-1').decode('utf-8') + except (UnicodeDecodeError, UnicodeEncodeError): + pass + return ''.join(c for c in ssid if c.isprintable()) + + +def get_wpa_status(ifname): + """Get wpa_supplicant status as a dict of key=value lines""" + data = HOST.run(('wpa_cli', '-i', ifname, 'status'), default='FAIL') + if not data or data == 'FAIL': + return {} + + status = {} + for line in data.splitlines(): + key, sep, val = line.partition('=') + if sep: + status[key.strip()] = val.strip() + + return status + + 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'] + # 'iw dev info' reports no SSID for a mesh interface, so ask + # wpa_supplicant, which is the one that joined the mesh. Absent + # until it has, which is the honest answer: no mesh, no mesh id. + mesh_id = info.get('ssid') or get_wpa_status(ifname).get('ssid') + if mesh_id: + mesh_data['mesh-id'] = decode_wpa_ssid(mesh_id) peers = get_iw_stations(ifname) if peers: @@ -147,13 +181,7 @@ def parse_wpa_scan_result(scan_output): continue flags = parts[3].strip() - ssid = parts[4].strip() if len(parts) > 4 else "" - try: - ssid = ssid.encode().decode('unicode_escape').encode('latin-1').decode('utf-8') - except (UnicodeDecodeError, UnicodeEncodeError): - pass - # Strip control chars (terminal injection risk from rogue APs) - ssid = ''.join(c for c in ssid if c.isprintable()) + ssid = decode_wpa_ssid(parts[4].strip() if len(parts) > 4 else "") # Skip hidden SSIDs (empty or null-filled) if not ssid or ssid.isspace(): From 6bfc5c2e4844044ed743f539a256a893f0134fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Thu, 10 Sep 2026 20:30:58 +0200 Subject: [PATCH 02/24] statd: Fix of-by-one in print admin@jaffa:/> show interface wifi-mesh name : wifi-mesh type : wifi index : 17 mtu : 1500 operational status : up ip forwarding : disabled physical address : 82:0c:43:26:60:00 ipv4 addresses : ipv6 addresses : in-octets : 1181221 out-octets : 743429 mode : mesh-point mesh-id : laser-mesh connected peers : 2 --- src/statd/python/cli_pretty/cli_pretty.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/statd/python/cli_pretty/cli_pretty.py b/src/statd/python/cli_pretty/cli_pretty.py index f098c2734..c6b254b95 100755 --- a/src/statd/python/cli_pretty/cli_pretty.py +++ b/src/statd/python/cli_pretty/cli_pretty.py @@ -1753,9 +1753,9 @@ def _addr_lines(addrs): 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)}") + print(f"{'mode':<{19}}: {mode}") + print(f"{'mesh-id':<{19}}: {mesh_id}") + print(f"{'connected peers':<{19}}: {len(peers)}") self.pr_wifi_peers() else: mode = "station" From c936da9293651bcf8021dca3e165d5c668b30951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 11 Sep 2026 09:09:13 +0200 Subject: [PATCH 03/24] statd: keep the radio name for the wifi radio component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PHY's hwmon device is named after the radio, so its temperature sensor claimed "radio0" and unique_names() renamed the radio component itself "radio0-1". Interfaces reference their radio by name, so nothing could match an interface to its radio any more: 'show hardware' and the WebUI both lost the link. Make the sensor a child of the radio instead, named radio0-temp, the way the SoC sensor sits under the CPU. Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 5 +++ src/statd/python/yanger/ietf_hardware.py | 40 +++++++++++++++++------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index 274d35105..b3ff6818d 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -45,6 +45,11 @@ All notable changes to the project are documented in this file. - Fix #1619: Raspberry Pi kernel panic when configure Wi-Fi - Wi-Fi mesh point interfaces showed an empty `mesh-id` in operational status (CLI and WebUI). +- Wi-Fi radio hardware components were renamed `radio0-1`, `radio1-1`, in + operational status because the radio's temperature sensor took the + `radio0` name first, so `show hardware` and the WebUI could no longer + match interfaces to their radio. The sensor is now a child of the + radio component, named `radio0-temp` [relsup]: https://github.com/kernelkit/infix/blob/main/doc/releases.md diff --git a/src/statd/python/yanger/ietf_hardware.py b/src/statd/python/yanger/ietf_hardware.py index 385e9cf3c..b52580924 100644 --- a/src/statd/python/yanger/ietf_hardware.py +++ b/src/statd/python/yanger/ietf_hardware.py @@ -516,21 +516,37 @@ def create_sensor(sensor_name, value, value_type, value_scale, label=None): sensor["parent"] = parent components.extend(sensors) - # Enrich WiFi PHY sensors with descriptive information - wifi_info = get_wifi_phy_info() + return adopt_wifi_sensors(components, get_wifi_phy_info()) + + +def adopt_wifi_sensors(components, wifi_info): + """ + A WiFi PHY's hwmon device is named after the radio, so whatever this + builds for it takes the radio's name before wifi_radio_components() + gets there, and unique_names() renames the radio instead, breaking + the wifi/radio leafref that interfaces are bound by. + + Give the radio its name back. Its sensors hang off it, the way the + die sensors hang off the CPU, and the module head a multi-sensor + device would get is dropped: the radio component already is one. + """ + out = [] for component in components: name = component.get("name", "") - # Match radio0, radio1, etc. sensors - if name.startswith("radio") and name in wifi_info: - phy = wifi_info[name] - # Add WiFi-specific description - component["description"] = phy["description"] - # Optionally change class to wifi for WiFi PHY sensors - if component.get("class") == "iana-hardware:sensor": - # Keep as sensor but we could create a parent WiFi component later if needed - pass + if name not in wifi_info: + out.append(component) + continue - return components + if component.get("class") == "iana-hardware:module": + continue # the radio heads its own sensors + + kind = component.get("sensor-data", {}).get("value-type", "sensor") + component["name"] = f"{name}-{'temp' if kind == 'celsius' else kind}" + component["parent"] = name + component["description"] = "Temperature" if kind == "celsius" else kind.title() + out.append(component) + + return out def thermal_sensor_components(): From 1075e6f355d7a77712caac78d1b3a7d7e750b745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= Date: Fri, 11 Sep 2026 09:09:31 +0200 Subject: [PATCH 04/24] webui: add wifi mesh point and access point roaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mesh point interfaces could only be created and inspected through the Advanced YANG tree. Add them to the interface wizard and editor, and show mesh id and peers on the WiFi and interface status pages. Station status gains the BSSID it is associated to, so the access points of a roaming ESS can be told apart. Access point roaming (802.11k/r/v, band steering, OKC) gets its own editor section. The whole wifi container is written in one PUT: a merge can neither drop an unticked presence container nor revert a leaf to its default. Roaming and mesh forwarding stay on the configure page. They are config-only leaves with no operational counterpart, and a status page has no business restating running config as if it were state. Signed-off-by: Mattias Walström --- doc/ChangeLog.md | 4 + src/webui/README.md | 4 +- src/webui/internal/handlers/common.go | 44 +++ .../internal/handlers/configure_interfaces.go | 219 +++++++++--- src/webui/internal/handlers/interfaces.go | 130 +++++-- src/webui/internal/handlers/wifi.go | 23 +- src/webui/internal/handlers/wifi_mesh_test.go | 330 ++++++++++++++++++ src/webui/internal/server/server.go | 40 +-- src/webui/static/js/app.js | 82 +++-- .../templates/pages/configure-interfaces.html | 118 ++++++- src/webui/templates/pages/iface-detail.html | 9 +- src/webui/templates/pages/wifi.html | 15 +- 12 files changed, 849 insertions(+), 169 deletions(-) create mode 100644 src/webui/internal/handlers/wifi_mesh_test.go diff --git a/doc/ChangeLog.md b/doc/ChangeLog.md index b3ff6818d..397b5bdc0 100644 --- a/doc/ChangeLog.md +++ b/doc/ChangeLog.md @@ -39,6 +39,10 @@ All notable changes to the project are documented in this file. boot and configuration changes, issue #961. Same rationale as Debian's dash-as-/bin/sh. Bash remains available for interactive use and for scripts using `#!/bin/bash` +- WebUI: add 802.11s mesh point support to the WiFi interface wizard and + editor, show mesh peers on the WiFi and interface status pages, and add + an editor section for access point roaming (802.11k/r/v, band steering, + OKC). ### Fixes diff --git a/src/webui/README.md b/src/webui/README.md index 61cb2ff08..b4df0146f 100644 --- a/src/webui/README.md +++ b/src/webui/README.md @@ -13,8 +13,8 @@ a browser-friendly format. with bridge member grouping - **Interfaces** -- list with status, addresses, and per-type detail; click through to a detail page with live-updating counters, WiFi - station table, scan results, WireGuard peers, and ethernet frame - statistics + station and mesh peer tables, scan results, WireGuard peers, and + ethernet frame statistics - **Firewall** -- zone-to-zone policy matrix - **Keystore** -- symmetric and asymmetric key display - **Firmware** -- slot overview, install from URL with live progress diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 6a5138672..d258c7013 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -4,6 +4,8 @@ package handlers import ( "context" + "fmt" + "html/template" "net/http" "strconv" "strings" @@ -79,3 +81,45 @@ func newPageData(w http.ResponseWriter, r *http.Request, page, leaf string) Page CfgUnsaved: cfgUnsavedFromRequest(r), } } + +// IfaceTemplateFuncs is the FuncMap the configure-interfaces template is +// parsed with. Exported so tests can parse the real template the same way. +func IfaceTemplateFuncs() template.FuncMap { + return template.FuncMap{ + "shortPMD": ShortenPMD, + "add": func(a, b int) int { return a + b }, + "deref": func(v any) any { + switch p := v.(type) { + case *bool: + if p != nil { + return *p + } + case *uint32: + if p != nil { + return *p + } + case *int: + if p != nil { + return *p + } + } + return nil + }, + // dict lets callers pass keyed args to nested templates, e.g. + // {{template "foo" (dict "Key" .X "Selected" "")}}. + "dict": func(values ...any) (map[string]any, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("dict: odd argument count") + } + m := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + k, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict: non-string key at position %d", i) + } + m[k] = values[i+1] + } + return m, nil + }, + } +} diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index 0f8da6451..f82f3c535 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -106,7 +106,7 @@ type cfgIfaceRow struct { IsLagPort bool IsVlan bool IsWifi bool - WifiMode string // "station" or "access-point" once known + WifiMode string // "station", "access-point" or "mesh-point" once known HasIP bool // can carry IP addresses // ParentBridgeIs8021Q says whether the bridge this port is attached // to has VLAN filtering on. PVID only makes sense in that mode, so @@ -290,7 +290,17 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req "wg-key": descOr(mgr, ifPath+"/infix-interfaces:wireguard/private-key", "Reference to the WireGuard private key (X25519/Curve25519) stored in the keystore."), "wg-port": descOr(mgr, ifPath+"/infix-interfaces:wireguard/listen-port", "Local UDP port to listen on for incoming WireGuard traffic (default 51820)."), "wifi-radio": descOr(mgr, ifPath+"/infix-interfaces:wifi/radio", "Parent WiFi radio (hardware component, class=wifi). Configure the radio's band, channel, and country code in Configure › Hardware first."), - "wifi-mode": "Station (client) connects to an existing AP. Access Point creates a network that clients join. Only one Station per radio; multiple APs per radio supported.", + "wifi-mode": "Station (client) connects to an existing AP. Access Point creates a network that clients join. Mesh Point forms an 802.11s peer-to-peer link with other mesh points. One Station or Mesh Point per radio; multiple APs per radio supported. AP and Mesh Point cannot share a radio.", + "wifi-mesh-id": descOr(mgr, ifPath+"/infix-interfaces:wifi/mesh-point/mesh-id", "Mesh network identifier (1–32 characters). All mesh points that should form one mesh must use the same mesh ID."), + "wifi-forwarding": descOr(mgr, ifPath+"/infix-interfaces:wifi/mesh-point/forwarding", "Layer-2 mesh forwarding. Leave on to let this node relay traffic for other mesh points and to bridge the mesh interface into a LAN (mesh portal). Off means only locally destined traffic is received."), + "wifi-mesh-secret": descOr(mgr, ifPath+"/infix-interfaces:wifi/mesh-point/security/secret", "Pre-shared key reference for the WPA3-SAE mesh. All mesh points in the same mesh must share the same key."), + "wifi-dot11k": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/dot11k", "802.11k Radio Resource Management: neighbor and beacon reports let clients discover nearby APs before roaming."), + "wifi-dot11r": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/dot11r", "802.11r Fast BSS Transition: pre-authentication cuts handoff time to under 50 ms. Requires WPA2/WPA3 security, plus identical SSID, passphrase and mobility domain on all APs in the group."), + "wifi-dot11r-md": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/dot11r/mobility-domain", "802.11r mobility domain: four hex digits shared by every AP clients roam between, or 'hash' to derive it from the SSID (OpenWrt-compatible)."), + "wifi-dot11r-nas": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/dot11r/nas-identifier", "NAS-Identifier for 802.11r key lookup, unique per AP. 'auto' derives -.."), + "wifi-dot11v": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/dot11v", "802.11v BSS Transition Management: lets the AP suggest a better AP to clients (network-assisted roaming)."), + "wifi-band-steering": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/dot11v/band-steering", "Multi-Band Operation (MBO) band steering nudges dual-band clients toward 5/6 GHz. Only matters when the same SSID is offered on more than one band."), + "wifi-okc": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/roaming/okc", "Opportunistic Key Caching speeds up re-authentication for roaming clients without 802.11r. Safe to leave on; only used when both AP and client support it."), "wifi-ssid": descOr(mgr, ifPath+"/infix-interfaces:wifi/station/ssid", "WiFi network name (1–32 characters). Case-sensitive; must match the target network for Station mode."), "wifi-sec-mode": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/mode", "Security mode. Open is unencrypted (insecure). For AP: wpa2-wpa3-personal is recommended for compatibility + security."), "wifi-secret": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/secret", "Pre-shared key reference — a symmetric key in the keystore. 8–63 characters per the WPA spec."), @@ -753,7 +763,7 @@ func (h *ConfigureInterfacesHandler) CreateInterface(w http.ResponseWriter, r *h } mode := r.FormValue("wifi-mode") ssid := strings.TrimSpace(r.FormValue("ssid")) - if ssid == "" { + if ssid == "" && mode != "mesh-point" { renderSaveError(w, fmt.Errorf("SSID is required")) return } @@ -794,8 +804,15 @@ func (h *ConfigureInterfacesHandler) CreateInterface(w http.ResponseWriter, r *h } sta["security"] = security wifi["station"] = sta + case "mesh-point": + mp, err := wifiMeshPointFromForm(r) + if err != nil { + renderSaveError(w, err) + return + } + wifi["mesh-point"] = mp default: - renderSaveError(w, fmt.Errorf("WiFi mode must be 'station' or 'access-point'")) + renderSaveError(w, fmt.Errorf("WiFi mode must be 'station', 'access-point' or 'mesh-point'")) return } iface["infix-interfaces:wifi"] = wifi @@ -1752,11 +1769,11 @@ func (h *ConfigureInterfacesHandler) SaveBridgeMulticast(w http.ResponseWriter, renderSaved(w, "Multicast saved") } -// SaveWifi PATCHes the WiFi interface container plus, when the form -// also carries radio fields, the mirrored wifi-radio component — both -// in a single PATCH on the candidate root so the interface SSID/sec -// and the radio's country/band/channel land atomically. The mode -// (station vs access-point) is fixed at wizard-create time and not +// SaveWifi replaces the interface's wifi container whole (radio plus the +// mode container) so unticked presence containers and leaves reverted to +// their defaults actually go away, then PATCHes the mirrored wifi-radio +// component when the form also carried radio fields. The mode (station, +// access-point or mesh-point) is fixed at wizard-create time and not // switched here. // POST /configure/interfaces/{name}/wifi func (h *ConfigureInterfacesHandler) SaveWifi(w http.ResponseWriter, r *http.Request) { @@ -1766,61 +1783,154 @@ func (h *ConfigureInterfacesHandler) SaveWifi(w http.ResponseWriter, r *http.Req } name := r.PathValue("name") mode := r.FormValue("mode") - if mode != "station" && mode != "access-point" { - renderSaveError(w, fmt.Errorf("mode must be 'station' or 'access-point'")) + radio := strings.TrimSpace(r.FormValue("radio")) + if radio == "" { + renderSaveError(w, fmt.Errorf("a WiFi radio reference is required")) + return + } + var leaf map[string]any + switch mode { + case "station", "access-point": + leaf = map[string]any{"ssid": r.FormValue("ssid")} + secMode := r.FormValue("sec-mode") + if secMode != "" { + sec := map[string]any{"mode": secMode} + if secret := r.FormValue("secret"); secret != "" { + sec["secret"] = secret + } + leaf["security"] = sec + } + if mode == "access-point" { + if r.FormValue("hidden") == "on" { + leaf["hidden"] = true + } + roaming, err := wifiRoamingFromForm(r) + if err != nil { + renderSaveError(w, err) + return + } + if _, ok := roaming["dot11r"]; ok && secMode == "open" { + renderSaveError(w, fmt.Errorf("802.11r requires WPA2/WPA3 security, not an open network")) + return + } + if len(roaming) > 0 { + leaf["roaming"] = roaming + } + } + case "mesh-point": + var err error + if leaf, err = wifiMeshPointFromForm(r); err != nil { + renderSaveError(w, err) + return + } + default: + renderSaveError(w, fmt.Errorf("mode must be 'station', 'access-point' or 'mesh-point'")) + return + } + wifi := map[string]any{"radio": radio, mode: leaf} + body := map[string]any{"infix-interfaces:wifi": wifi} + if err := h.RC.Put(r.Context(), ifacePath(name)+"/infix-interfaces:wifi", body); err != nil { + log.Printf("configure interfaces %s wifi: %v", name, err) + renderSaveError(w, err) return } - leaf := map[string]any{"ssid": r.FormValue("ssid")} - if secMode := r.FormValue("sec-mode"); secMode != "" { - sec := map[string]any{"mode": secMode} - if secret := r.FormValue("secret"); secret != "" { - sec["secret"] = secret + // Radio half, only when the form actually carried a country (the + // wifi-radio container's mandatory leaf). Without it parseWiFiRadio + // would reject a form whose user only touched the WiFi side and left + // the radio fields untouched-empty. + if strings.TrimSpace(r.FormValue("country-code")) != "" { + rc, err := parseWiFiRadio(r) + if err != nil { + renderSaveError(w, err) + return + } + hw := map[string]any{ + "ietf-hardware:hardware": map[string]any{ + "component": []map[string]any{{ + "name": radio, + "class": "infix-hardware:wifi", + "infix-hardware:wifi-radio": rc, + }}, + }, + } + if err := h.RC.Patch(r.Context(), candidatePath, hw); err != nil { + log.Printf("configure interfaces %s wifi radio %s: %v", name, radio, err) + renderSaveError(w, err) + return } - leaf["security"] = sec } - if mode == "access-point" { - leaf["hidden"] = r.FormValue("hidden") == "on" + renderSaved(w, "WiFi saved") +} + +// wifiMeshPointFromForm builds the mesh-point container from the wizard +// or editor form. Both post the forwarding checkbox with a hidden "false" +// companion, so only an explicit "false" is written; absent means the +// YANG default (true). +func wifiMeshPointFromForm(r *http.Request) (map[string]any, error) { + meshID := strings.TrimSpace(r.FormValue("mesh-id")) + if meshID == "" { + return nil, fmt.Errorf("mesh ID is required") } - wifi := map[string]any{mode: leaf} - // Picker change re-binds the wifi/radio leaf so the user can move - // the interface to a different (already-configured) radio. - if radioRef := strings.TrimSpace(r.FormValue("radio")); radioRef != "" { - wifi["radio"] = radioRef + secret := strings.TrimSpace(r.FormValue("secret")) + if secret == "" { + return nil, fmt.Errorf("a PSK is required for mesh mode (WPA3-SAE)") } - iface := map[string]any{ - "name": name, - "infix-interfaces:wifi": wifi, + mp := map[string]any{ + "mesh-id": meshID, + "security": map[string]any{"secret": secret}, } - body := map[string]any{ - "ietf-interfaces:interfaces": map[string]any{ - "interface": []map[string]any{iface}, - }, + if r.FormValue("forwarding") == "false" { + mp["forwarding"] = false } - // Radio half of the atomic write — only when the form actually - // carried a country (the wifi-radio container's mandatory leaf). - // Without it parseWiFiRadio would reject a form whose user only - // touched the WiFi side and left the radio fields untouched-empty. - radio := strings.TrimSpace(r.FormValue("radio")) - if radio != "" && strings.TrimSpace(r.FormValue("country-code")) != "" { - rc, err := parseWiFiRadio(r) - if err != nil { - renderSaveError(w, err) - return + return mp, nil +} + +// wifiRoamingFromForm builds the access-point/roaming container from the +// editor's checkboxes. Leaves at their YANG default (okc, band-steering, +// blank mobility-domain and NAS identifier) are left out so the config +// only carries what the user changed. +func wifiRoamingFromForm(r *http.Request) (map[string]any, error) { + roaming := map[string]any{} + if r.FormValue("okc") != "on" { + roaming["okc"] = false + } + if r.FormValue("dot11k") == "on" { + roaming["dot11k"] = map[string]any{} + } + if r.FormValue("dot11r") == "on" { + dot11r := map[string]any{} + md := strings.ToLower(strings.TrimSpace(r.FormValue("mobility-domain"))) + if md != "" { + if md != "hash" && !isHex4(md) { + return nil, fmt.Errorf("mobility domain must be four hex digits or 'hash'") + } + dot11r["mobility-domain"] = md } - body["ietf-hardware:hardware"] = map[string]any{ - "component": []map[string]any{{ - "name": radio, - "class": "infix-hardware:wifi", - "infix-hardware:wifi-radio": rc, - }}, + if nas := strings.TrimSpace(r.FormValue("nas-identifier")); nas != "" { + dot11r["nas-identifier"] = nas } + roaming["dot11r"] = dot11r } - if err := h.RC.Patch(r.Context(), candidatePath, body); err != nil { - log.Printf("configure interfaces %s wifi: %v", name, err) - renderSaveError(w, err) - return + if r.FormValue("dot11v") == "on" { + dot11v := map[string]any{} + if r.FormValue("band-steering") != "on" { + dot11v["band-steering"] = false + } + roaming["dot11v"] = dot11v } - renderSaved(w, "WiFi saved") + return roaming, nil +} + +func isHex4(s string) bool { + if len(s) != 4 { + return false + } + for _, c := range s { + if !strings.ContainsRune("0123456789abcdef", c) { + return false + } + } + return true } // DeleteLagPort detaches an interface from its LAG. @@ -2063,6 +2173,8 @@ func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON, oper []ifaceJ row.WifiMode = "access-point" case iface.WiFi.Station != nil: row.WifiMode = "station" + case iface.WiFi.MeshPoint != nil: + row.WifiMode = "mesh-point" } } row.EthAutoneg = true // YANG default when no candidate value is set @@ -2742,6 +2854,9 @@ func configSummary(row *cfgIfaceRow) []string { } if row.IsWifi && row.WifiMode != "" { tags = append(tags, row.WifiMode) + if ap := row.WiFi.AccessPoint; ap != nil && wifiRoamingSummary(ap.Roaming) != "" { + tags = append(tags, "roaming") + } } if row.IsBridge && row.BridgeIs8021Q { tags = append(tags, "802.1Q") diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 5307a6bb4..8f5b80699 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -91,17 +91,53 @@ type wifiJSON struct { Radio string `json:"radio"` AccessPoint *wifiAPJSON `json:"access-point"` Station *wifiStationJSON `json:"station"` + MeshPoint *wifiMeshJSON `json:"mesh-point"` } type wifiAPJSON struct { - SSID string `json:"ssid"` - Hidden *bool `json:"hidden"` - Security *wifiSecJSON `json:"security"` + SSID string `json:"ssid"` + Hidden *bool `json:"hidden"` + Security *wifiSecJSON `json:"security"` + Roaming *wifiRoamingJSON `json:"roaming"` Stations struct { Station []wifiStaJSON `json:"station"` } `json:"stations"` } +// wifiRoamingJSON mirrors access-point/roaming. dot11k/r/v are presence +// containers, so a non-nil pointer means "enabled" even when the object +// is empty. +type wifiRoamingJSON struct { + Dot11k *struct{} `json:"dot11k"` + Dot11r *wifiDot11rJSON `json:"dot11r"` + Dot11v *wifiDot11vJSON `json:"dot11v"` + OKC *bool `json:"okc"` +} + +type wifiDot11rJSON struct { + MobilityDomain string `json:"mobility-domain"` + NASIdentifier string `json:"nas-identifier"` +} + +type wifiDot11vJSON struct { + BandSteering *bool `json:"band-steering"` +} + +// wifiMeshJSON mirrors the 802.11s mesh-point container. Peers reuse the +// station shape since the YANG leaves are identical. +type wifiMeshJSON struct { + MeshID string `json:"mesh-id"` + Forwarding *bool `json:"forwarding"` + Security *wifiMeshSecJSON `json:"security"` + Peers struct { + Peer []wifiStaJSON `json:"peer"` + } `json:"peers"` +} + +type wifiMeshSecJSON struct { + Secret string `json:"secret"` +} + type wifiSecJSON struct { Mode string `json:"mode"` Secret string `json:"secret"` @@ -121,6 +157,7 @@ type wifiStaJSON struct { type wifiStationJSON struct { SSID string `json:"ssid"` + BSSID string `json:"bssid"` Security *wifiSecJSON `json:"security"` SignalStrength *int `json:"signal-strength"` RxSpeed yangInt64 `json:"rx-speed"` @@ -521,6 +558,9 @@ func makeIfaceEntry(iface ifaceJSON, fwdSet map[string]bool) ifaceEntry { e.Detail = fmt.Sprintf("AP, ssid: %s, stations: %d", ap.SSID, n) } else if st := iface.WiFi.Station; st != nil { e.Detail = fmt.Sprintf("Station, ssid: %s", st.SSID) + } else if mp := iface.WiFi.MeshPoint; mp != nil { + n := len(mp.Peers.Peer) + e.Detail = fmt.Sprintf("Mesh, mesh-id: %s, peers: %d", mp.MeshID, n) } } @@ -556,12 +596,16 @@ type ifaceDetailData struct { SupportedPMDs []string // short names (what the PHY can do) AdvertisedPMDs []string // short names (what autoneg announces) Addresses []addrEntry - WiFiMode string // "Access Point" or "Station" + WiFiMode string // "Access Point", "Station" or "Mesh Point" WiFiSSID string + WiFiBSSID string // station: AP currently associated to + WiFiMeshID string WiFiSignal string WiFiRxSpeed string WiFiTxSpeed string WiFiStationCount string // e.g. "3" for AP mode + WiFiPeerCount string // mesh mode + WiFiStaTitle string // "Connected Stations" or "Mesh Peers" WGPeerSummary string // e.g. "3 peers (2 up)" Counters ifaceCounters EthFrameStats []kvEntry @@ -604,7 +648,7 @@ type wgPeerEntry struct { type wifiStaEntry struct { MAC string Signal string - SignalCSS string // "excellent", "good", "poor", "bad" + SignalCSS string // see wifiSignalCSS Time string RxPkts string TxPkts string @@ -638,6 +682,11 @@ func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*iface } // buildDetailData converts raw RESTCONF interface data to template data. +// +// Only operational data is rendered here. Mesh forwarding and AP roaming +// are config-only leaves that never reach the operational datastore, and +// a status page has no business restating running config as if it were +// state, so they live on the configure page alone. func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { d := ifaceDetailData{ Name: iface.Name, @@ -698,12 +747,22 @@ func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData { d.WiFiMode = "Access Point" d.WiFiSSID = ap.SSID d.WiFiStationCount = fmt.Sprintf("%d", len(ap.Stations.Station)) + d.WiFiStaTitle = "Connected Stations" for _, s := range ap.Stations.Station { d.WiFiStations = append(d.WiFiStations, buildWifiStaEntry(s)) } + } else if mp := iface.WiFi.MeshPoint; mp != nil { + d.WiFiMode = "Mesh Point" + d.WiFiMeshID = mp.MeshID + d.WiFiPeerCount = fmt.Sprintf("%d", len(mp.Peers.Peer)) + d.WiFiStaTitle = "Mesh Peers" + for _, p := range mp.Peers.Peer { + d.WiFiStations = append(d.WiFiStations, buildWifiStaEntry(p)) + } } else if st := iface.WiFi.Station; st != nil { d.WiFiMode = "Station" d.WiFiSSID = st.SSID + d.WiFiBSSID = st.BSSID if st.SignalStrength != nil { d.WiFiSignal = fmt.Sprintf("%d dBm", *st.SignalStrength) } @@ -860,6 +919,39 @@ func formatEthernetLink(bps uint64, duplex string) string { return s } +// wifiRoamingSummary renders the non-default roaming settings as one +// line, e.g. "802.11k, 802.11r (domain 4f57), 802.11v (band steering)" +// or "no OKC". Empty when everything is at its default. +func wifiRoamingSummary(rm *wifiRoamingJSON) string { + if rm == nil { + return "" + } + var parts []string + if rm.Dot11k != nil { + parts = append(parts, "802.11k") + } + if rm.Dot11r != nil { + // GETs omit default leaves, so an absent domain is the YANG + // default, the same value the editor shows. + md := rm.Dot11r.MobilityDomain + if md == "" { + md = "4f57" + } + parts = append(parts, "802.11r (domain "+md+")") + } + if rm.Dot11v != nil { + s := "802.11v" + if bs := rm.Dot11v.BandSteering; bs == nil || *bs { + s += " (band steering)" + } + parts = append(parts, s) + } + if rm.OKC != nil && !*rm.OKC { + parts = append(parts, "no OKC") + } + return strings.Join(parts, ", ") +} + func buildWifiStaEntry(s wifiStaJSON) wifiStaEntry { e := wifiStaEntry{ MAC: s.MACAddress, @@ -872,18 +964,8 @@ func buildWifiStaEntry(s wifiStaJSON) wifiStaEntry { TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10), } if s.SignalStrength != nil { - sig := *s.SignalStrength - e.Signal = fmt.Sprintf("%d dBm", sig) - switch { - case sig >= -50: - e.SignalCSS = "excellent" - case sig >= -60: - e.SignalCSS = "good" - case sig >= -70: - e.SignalCSS = "poor" - default: - e.SignalCSS = "bad" - } + e.Signal = fmt.Sprintf("%d dBm", *s.SignalStrength) + e.SignalCSS = wifiSignalCSS(*s.SignalStrength) } return e } @@ -900,18 +982,8 @@ func buildWifiScanEntry(sr wifiScanResultJSON) wifiScanEntry { e.Encryption = "Open" } if sr.SignalStrength != nil { - sig := *sr.SignalStrength - e.Signal = fmt.Sprintf("%d dBm", sig) - switch { - case sig >= -50: - e.SignalCSS = "excellent" - case sig >= -60: - e.SignalCSS = "good" - case sig >= -70: - e.SignalCSS = "poor" - default: - e.SignalCSS = "bad" - } + e.Signal = fmt.Sprintf("%d dBm", *sr.SignalStrength) + e.SignalCSS = wifiSignalCSS(*sr.SignalStrength) } return e } diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index 16d52be91..e4033b81a 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -100,13 +100,16 @@ type ChannelSurvey struct { // WiFiInterface is the template data for a virtual WiFi interface. type WiFiInterface struct { Name string - Mode string // "ap" or "station" - SSID string + Mode string // "ap", "station" or "mesh" + SSID string // mesh-id in mesh mode OperStatus string StatusUp bool - // AP mode - APClients []WiFiClient + // AP stations or mesh peers; ClientsEmpty is the message shown when + // the list is empty in a mode that has one. + Clients []WiFiClient + ClientsEmpty string // Station mode + BSSID string Signal string SignalCSS string RxSpeed string @@ -301,16 +304,24 @@ func buildWiFiInterfaces(radioName string, ifaces []ifaceJSON) []WiFiInterface { OperStatus: iface.OperStatus, StatusUp: iface.OperStatus == "up", } - if ap := iface.WiFi.AccessPoint; ap != nil { wi.Mode = "ap" wi.SSID = ap.SSID + wi.ClientsEmpty = "No stations connected." for _, s := range ap.Stations.Station { - wi.APClients = append(wi.APClients, buildWiFiClient(s)) + wi.Clients = append(wi.Clients, buildWiFiClient(s)) + } + } else if mp := iface.WiFi.MeshPoint; mp != nil { + wi.Mode = "mesh" + wi.SSID = mp.MeshID + wi.ClientsEmpty = "No mesh peers connected." + for _, p := range mp.Peers.Peer { + wi.Clients = append(wi.Clients, buildWiFiClient(p)) } } else if st := iface.WiFi.Station; st != nil { wi.Mode = "station" wi.SSID = st.SSID + wi.BSSID = st.BSSID if st.SignalStrength != nil { sig := *st.SignalStrength wi.Signal = fmt.Sprintf("%d dBm", sig) diff --git a/src/webui/internal/handlers/wifi_mesh_test.go b/src/webui/internal/handlers/wifi_mesh_test.go new file mode 100644 index 000000000..ec4a92bc4 --- /dev/null +++ b/src/webui/internal/handlers/wifi_mesh_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: MIT + +package handlers + +import ( + "bytes" + "encoding/json" + "html/template" + "net/http" + "net/http/httptest" + "net/url" + "os" + "regexp" + "strings" + "testing" +) + +// wifiIfacesFixture is operational data, shaped the way statd delivers +// it: config-only leaves (mesh forwarding, AP roaming) are absent, +// because the operational subscription replaces the running config for +// the whole interfaces subtree. wifiCfgFixture is the candidate config +// the configure page renders from. +const wifiIfacesFixture = `{"ietf-interfaces:interfaces":{"interface":[ +{"name":"wifi0-mesh","type":"infix-if-type:wifi","oper-status":"up", + "infix-interfaces:wifi":{"radio":"phy0","mesh-point":{"mesh-id":"backhaul", + "security":{}, + "peers":{"peer":[{"mac-address":"02:00:00:00:00:01","signal-strength":-55,"connected-time":90, + "rx-bytes":"2048","tx-bytes":"4096","rx-speed":"650","tx-speed":"1200"}]}}}}, +{"name":"wifi1-ap","type":"infix-if-type:wifi","oper-status":"up", + "infix-interfaces:wifi":{"radio":"phy1","access-point":{"ssid":"office","security":{},"roaming":{}}}}, +{"name":"wifi2","type":"infix-if-type:wifi","oper-status":"up", + "infix-interfaces:wifi":{"radio":"phy2","station":{"ssid":"upstream","bssid":"aa:bb:cc:dd:ee:ff","signal-strength":-61}}}, +{"name":"wifi3-mesh","type":"infix-if-type:wifi","oper-status":"down", + "infix-interfaces:wifi":{"radio":"phy3","mesh-point":{"mesh-id":"lonely","security":{"secret":"mesh-psk"}}}} +]}}` + +const wifiCfgFixture = `{"ietf-interfaces:interfaces":{"interface":[ +{"name":"wifi0-mesh","type":"infix-if-type:wifi", + "infix-interfaces:wifi":{"radio":"phy0","mesh-point":{"mesh-id":"backhaul","forwarding":false, + "security":{"secret":"mesh-psk"}}}}, +{"name":"wifi1-ap","type":"infix-if-type:wifi", + "infix-interfaces:wifi":{"radio":"phy1","access-point":{"ssid":"office","security":{"mode":"wpa3-personal","secret":"psk"}, + "roaming":{"dot11k":{},"dot11r":{"mobility-domain":"hash"},"dot11v":{"band-steering":false},"okc":false}}}}, +{"name":"wifi2","type":"infix-if-type:wifi", + "infix-interfaces:wifi":{"radio":"phy2","station":{"ssid":"upstream","security":{"secret":"psk"}}}}, +{"name":"wifi3-mesh","type":"infix-if-type:wifi", + "infix-interfaces:wifi":{"radio":"phy3","mesh-point":{"mesh-id":"lonely","security":{"secret":"mesh-psk"}}}} +]}}` + +func decodeWiFiFixture(t *testing.T) []ifaceJSON { + t.Helper() + var w interfacesWrapper + if err := json.Unmarshal([]byte(wifiIfacesFixture), &w); err != nil { + t.Fatalf("decode fixture: %v", err) + } + return w.Interfaces.Interface +} + +func TestBuildWiFiInterfaces_MeshPeersAndBSSID(t *testing.T) { + ifaces := decodeWiFiFixture(t) + + mesh := buildWiFiInterfaces("phy0", ifaces) + if len(mesh) != 1 || mesh[0].Mode != "mesh" || mesh[0].SSID != "backhaul" { + t.Fatalf("mesh: got %+v", mesh) + } + if len(mesh[0].Clients) != 1 { + t.Errorf("mesh peers: %+v", mesh[0]) + } + if mesh[0].Clients[0].Signal != "-55 dBm" || mesh[0].Clients[0].SignalCSS != "signal-good" { + t.Errorf("peer signal: %+v", mesh[0].Clients[0]) + } + + ap := buildWiFiInterfaces("phy1", ifaces) + if len(ap) != 1 || ap[0].Mode != "ap" { + t.Fatalf("ap: got %+v", ap) + } + + sta := buildWiFiInterfaces("phy2", ifaces) + if len(sta) != 1 || sta[0].BSSID != "aa:bb:cc:dd:ee:ff" { + t.Fatalf("station bssid: got %+v", sta) + } +} + +func TestWiFiRoamingSummary(t *testing.T) { + tr := true + cases := []struct { + name string + in *wifiRoamingJSON + want string + }{ + {"nil", nil, ""}, + {"okc default", &wifiRoamingJSON{OKC: &tr}, ""}, + {"okc off alone", &wifiRoamingJSON{OKC: new(bool)}, "no OKC"}, + {"k", &wifiRoamingJSON{Dot11k: &struct{}{}}, "802.11k"}, + {"r explicit md", &wifiRoamingJSON{Dot11r: &wifiDot11rJSON{MobilityDomain: "ab12"}}, "802.11r (domain ab12)"}, + {"r default md omitted", &wifiRoamingJSON{Dot11r: &wifiDot11rJSON{}}, "802.11r (domain 4f57)"}, + {"v default steering", &wifiRoamingJSON{Dot11v: &wifiDot11vJSON{}}, "802.11v (band steering)"}, + } + for _, c := range cases { + if got := wifiRoamingSummary(c.in); got != c.want { + t.Errorf("%s: got %q want %q", c.name, got, c.want) + } + } +} + +func TestBuildDetailData_MeshAndStation(t *testing.T) { + ifaces := decodeWiFiFixture(t) + req := httptest.NewRequest(http.MethodGet, "/interfaces/x", nil) + + d := buildDetailData(req, &ifaces[0]) + if d.WiFiMode != "Mesh Point" || d.WiFiMeshID != "backhaul" { + t.Errorf("mesh detail: %+v", d) + } + if d.WiFiPeerCount != "1" || d.WiFiStaTitle != "Mesh Peers" || len(d.WiFiStations) != 1 { + t.Errorf("mesh peers: count=%q title=%q n=%d", d.WiFiPeerCount, d.WiFiStaTitle, len(d.WiFiStations)) + } + + d = buildDetailData(req, &ifaces[1]) + if d.WiFiMode != "Access Point" || d.WiFiStaTitle != "Connected Stations" { + t.Errorf("ap detail: %+v", d) + } + + d = buildDetailData(req, &ifaces[2]) + if d.WiFiBSSID != "aa:bb:cc:dd:ee:ff" { + t.Errorf("station bssid: %q", d.WiFiBSSID) + } + + e := makeIfaceEntry(ifaces[0], nil) + if e.Detail != "Mesh, mesh-id: backhaul, peers: 1" { + t.Errorf("list detail: %q", e.Detail) + } +} + +func roamingForm(t *testing.T, v url.Values) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/configure/interfaces/wifi0/wifi", strings.NewReader(v.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if err := req.ParseForm(); err != nil { + t.Fatal(err) + } + return req +} + +func TestWiFiRoamingFromForm(t *testing.T) { + roaming, err := wifiRoamingFromForm(roamingForm(t, url.Values{ + "dot11k": {"on"}, "dot11r": {"on"}, "mobility-domain": {"AB12"}, "nas-identifier": {"auto"}, + "dot11v": {"on"}, "band-steering": {"on"}, "okc": {"on"}, + })) + if err != nil { + t.Fatal(err) + } + r := roaming["dot11r"].(map[string]any) + if r["mobility-domain"] != "ab12" || r["nas-identifier"] != "auto" { + t.Errorf("dot11r = %v", r) + } + // okc and band-steering are on: both at their YANG default, so absent. + if _, ok := roaming["okc"]; ok { + t.Errorf("okc should be absent when on: %v", roaming) + } + if _, ok := roaming["dot11v"].(map[string]any)["band-steering"]; ok { + t.Errorf("band-steering should be absent when on: %v", roaming) + } + if _, ok := roaming["dot11k"]; !ok { + t.Error("dot11k missing") + } + + // Unticked presence containers and blank sub-fields are simply absent; + // SaveWifi PUTs the whole container so that is enough to clear them. + roaming, err = wifiRoamingFromForm(roamingForm(t, url.Values{"dot11r": {"on"}, "mobility-domain": {"hash"}})) + if err != nil { + t.Fatal(err) + } + for _, k := range []string{"dot11k", "dot11v"} { + if _, ok := roaming[k]; ok { + t.Errorf("%s should be absent", k) + } + } + r = roaming["dot11r"].(map[string]any) + if roaming["okc"] != false || r["mobility-domain"] != "hash" { + t.Errorf("roaming = %v", roaming) + } + // Mesh: the checkbox posts "true" ahead of its hidden "false" + // companion, so an unticked box arrives as "false". + mp, err := wifiMeshPointFromForm(roamingForm(t, url.Values{"mesh-id": {" backhaul "}, "secret": {"k"}, "forwarding": {"false"}})) + if err != nil || mp["mesh-id"] != "backhaul" || mp["forwarding"] != false { + t.Errorf("mesh-point = %v, %v", mp, err) + } + mp, err = wifiMeshPointFromForm(roamingForm(t, url.Values{"mesh-id": {"m"}, "secret": {"k"}, "forwarding": {"true", "false"}})) + if err != nil { + t.Fatal(err) + } + if _, ok := mp["forwarding"]; ok { + t.Error("forwarding at default should be absent") + } + if _, err := wifiMeshPointFromForm(roamingForm(t, url.Values{"mesh-id": {"m"}})); err == nil { + t.Error("expected error without PSK") + } + if _, ok := r["nas-identifier"]; ok { + t.Error("blank nas-identifier should be absent") + } + + if _, err := wifiRoamingFromForm(roamingForm(t, url.Values{"dot11r": {"on"}, "mobility-domain": {"xyz"}})); err == nil { + t.Error("expected error for bad mobility domain") + } +} + +// realTemplates parses the on-disk templates the way server.go does, so +// the tests catch field-name typos in the mesh/roaming markup. Only the +// configure-interfaces page is parsed with a FuncMap in production, so +// funcs is nil for the others. +func realTemplates(t *testing.T, funcs template.FuncMap, patterns ...string) *template.Template { + t.Helper() + tmpl, err := template.New("").Funcs(funcs).ParseFS(os.DirFS("../../templates"), patterns...) + if err != nil { + t.Fatalf("parse templates: %v", err) + } + return tmpl +} + +func TestWiFiPageRendersMeshAndRoaming(t *testing.T) { + tmpl := realTemplates(t, nil, "layouts/*.html", "pages/wifi.html") + ifaces := decodeWiFiFixture(t) + comps := []hwComponentWiFiJSON{ + {Name: "phy0", WiFiRadio: &wifiRadioHWJSON{Band: "5GHz"}}, + {Name: "phy1", WiFiRadio: &wifiRadioHWJSON{Band: "2.4GHz"}}, + {Name: "phy2", WiFiRadio: &wifiRadioHWJSON{Band: "5GHz"}}, + {Name: "phy3", WiFiRadio: &wifiRadioHWJSON{Band: "5GHz"}}, + } + data := wifiData{Radios: buildWiFiRadios(comps, ifaces)} + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + t.Fatalf("render: %v", err) + } + out := buf.String() + for _, want := range []string{ + "backhaul", "02:00:00:00:00:01", `class="signal-good"`, + "aa:bb:cc:dd:ee:ff", "lonely", "No mesh peers connected", + } { + if !strings.Contains(out, want) { + t.Errorf("wifi page missing %q", want) + } + } + if strings.Contains(out, "signal-signal-") { + t.Error("doubled signal- class prefix") + } + // Config-only leaves have no place on a status page. + for _, never := range []string{"Roaming", "Forwarding", "802.11r"} { + if strings.Contains(out, never) { + t.Errorf("wifi page shows config-only %q", never) + } + } +} + +func TestIfaceDetailRendersMesh(t *testing.T) { + tmpl := realTemplates(t, nil, "layouts/*.html", "pages/iface-detail.html", "fragments/iface-counters.html") + ifaces := decodeWiFiFixture(t) + req := httptest.NewRequest(http.MethodGet, "/interfaces/wifi0-mesh", nil) + data := buildDetailData(req, &ifaces[0]) + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + t.Fatalf("render: %v", err) + } + out := buf.String() + for _, want := range []string{"Mesh Point", "backhaul", "Mesh Peers", "02:00:00:00:00:01", `class="signal-good"`} { + if !strings.Contains(out, want) { + t.Errorf("detail page missing %q", want) + } + } + for _, never := range []string{"Mesh Forwarding", "Roaming"} { + if strings.Contains(out, never) { + t.Errorf("detail page shows config-only %q", never) + } + } + if strings.Contains(out, "signal-signal-") || strings.Contains(out, `class="signal-bad"`) { + t.Error("detail page uses a signal class with no CSS rule") + } +} + +func TestConfigureInterfacesRendersMeshAndRoamingEditors(t *testing.T) { + tmpl := realTemplates(t, IfaceTemplateFuncs(), "layouts/*.html", "fragments/configure-toolbar.html", + "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", + "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html") + // The configure page reads candidate config, not operational. + var cw interfacesWrapper + if err := json.Unmarshal([]byte(wifiCfgFixture), &cw); err != nil { + t.Fatalf("decode config fixture: %v", err) + } + cfgIfaces := cw.Interfaces.Interface + + rows := make([]cfgIfaceRow, 0, 2) + for _, iface := range cfgIfaces[:2] { + row := cfgIfaceRow{ifaceJSON: iface, TypeSlug: "wifi", TypeDisplay: "WiFi", IsWifi: true, Desc: map[string]string{}} + switch { + case iface.WiFi.AccessPoint != nil: + row.WifiMode = "access-point" + case iface.WiFi.MeshPoint != nil: + row.WifiMode = "mesh-point" + } + row.ConfigTags = configSummary(&row) + rows = append(rows, row) + } + data := cfgIfacePageData{ + Interfaces: rows, + Desc: map[string]string{}, + WizardNames: map[string]string{"wifi": "wifi3"}, + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil { + t.Fatalf("render: %v", err) + } + out := buf.String() + for _, want := range []string{ + `WiFi (Mesh Point)`, `name="mesh-id"`, `value="backhaul"`, + `name="forwarding"`, `Roaming (802.11k/r/v)`, + `name="dot11k" checked`, `name="mobility-domain"`, `value="hash"`, `data-fold-target="wifi-row-wifi1-ap-dot11r-md wifi-row-wifi1-ap-dot11r-nas"`, + `add-iface-wifi-mode-mesh`, `add-iface-wifi-meshid-row`, + `roaming`, + } { + if !strings.Contains(out, want) { + t.Errorf("configure page missing %q", want) + } + } + // Forwarding is explicitly false in the fixture, so the box is unticked. + if regexp.MustCompile(`name="forwarding"\s+checked`).MatchString(out) { + t.Error("forwarding checkbox should not be checked") + } +} diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index b68213525..e2f58199a 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -4,7 +4,6 @@ package server import ( "context" - "fmt" "html/template" "io/fs" "net/http" @@ -157,44 +156,7 @@ func New( if err != nil { return nil, err } - ifFuncs := template.FuncMap{ - "shortPMD": handlers.ShortenPMD, - "add": func(a, b int) int { return a + b }, - "deref": func(v any) any { - switch p := v.(type) { - case *bool: - if p != nil { - return *p - } - case *uint32: - if p != nil { - return *p - } - case *int: - if p != nil { - return *p - } - } - return nil - }, - // dict lets callers pass keyed args to nested templates, e.g. - // {{template "foo" (dict "Key" .X "Selected" "")}}. - "dict": func(values ...any) (map[string]any, error) { - if len(values)%2 != 0 { - return nil, fmt.Errorf("dict: odd argument count") - } - m := make(map[string]any, len(values)/2) - for i := 0; i < len(values); i += 2 { - k, ok := values[i].(string) - if !ok { - return nil, fmt.Errorf("dict: non-string key at position %d", i) - } - m[k] = values[i+1] - } - return m, nil - }, - } - cfgIfTmpl, err := template.New("").Funcs(ifFuncs).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html") + cfgIfTmpl, err := template.New("").Funcs(handlers.IfaceTemplateFuncs()).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html") if err != nil { return nil, err } diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 57fba9b40..546bd4404 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -1030,17 +1030,26 @@ // Interface page glue (replaces inline hx-on / inline