Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
ad20017
statd: wifi: show mesh-id for 802.11s mesh
mattiaswal Sep 15, 2026
6bfc5c2
statd: Fix of-by-one in print
mattiaswal Sep 10, 2026
c936da9
statd: keep the radio name for the wifi radio component
mattiaswal Sep 11, 2026
1075e6f
webui: add wifi mesh point and access point roaming
mattiaswal Sep 11, 2026
3145d48
webui: allow switching a wifi interface's mode
mattiaswal Sep 11, 2026
5efad8c
webui: stop hidden inline forms from blocking submit
mattiaswal Sep 11, 2026
d706f14
webui: disable every hidden block, not just the inline key forms
mattiaswal Sep 11, 2026
9a80be8
statd: wifi: report mesh forwarding from the kernel
mattiaswal Sep 11, 2026
b9e2253
webui: name sensors by what they measure, not by their list key
mattiaswal Sep 11, 2026
2143b47
webui: name Key Vitals by the component they measure
mattiaswal Sep 11, 2026
78f3dec
webui: enable the country select when adding a wifi radio
mattiaswal Sep 15, 2026
d7c925c
webui: add RFC 8072 YANG Patch to the restconf client
mattiaswal Sep 15, 2026
bf67ff6
webui: save wifi in one YANG Patch
mattiaswal Sep 15, 2026
4fc4730
webui: save interface general settings in one YANG Patch
mattiaswal Sep 15, 2026
1b3e063
webui: save ethernet settings in one YANG Patch
mattiaswal Sep 15, 2026
db5a886
webui: save bridge type and members in one YANG Patch
mattiaswal Sep 15, 2026
ac4c32f
webui: save IP family settings in one YANG Patch
mattiaswal Sep 15, 2026
766262e
webui: prefill the country when editing a wifi radio
mattiaswal Sep 15, 2026
053e01e
webui: show inline + New form errors next to the form
mattiaswal Sep 15, 2026
b04c811
webui: escape HX-Trigger event text to ASCII
mattiaswal Sep 15, 2026
b2372b7
rousette: fix YANG Patch remove of a typed leaf
mattiaswal Sep 15, 2026
f3d21a1
webui: only link http and https URLs from mDNS TXT records
mattiaswal Sep 15, 2026
79d45c1
webui: send Cache-Control: no-store for pages and fragments
mattiaswal Sep 15, 2026
d55c100
webui: reject malformed RESTCONF paths in the client
mattiaswal Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion board/common/rootfs/usr/libexec/infix/iw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:]))

Expand Down Expand Up @@ -583,6 +583,34 @@ def parse_phy_caps(phy_name):
return {'ht_cap': ht_cap, 'vht_cap': vht_cap}


def parse_mesh_param(ifname):
"""
Parse 'iw dev <name> get mesh_param' output for mesh point mode
Returns: {param: value}, values as int where they are numeric

Lines are 'mesh_fwding = 1' or 'mesh_retry_timeout = 100 milliseconds',
the unit is dropped.
"""
output = run_iw('dev', ifname, 'get', 'mesh_param')
if not output:
return {}

result = {}
for line in output.splitlines():
key, sep, value = line.partition('=')
if not sep:
continue
key, value = key.strip(), value.split()
if not key or not value:
continue
try:
result[key] = int(value[0])
except ValueError:
result[key] = value[0]

return result


def main():
if len(sys.argv) < 2:
print(json.dumps({
Expand All @@ -594,6 +622,7 @@ def main():
'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)',
'mesh': 'Get mesh parameters in mesh point mode (requires interface)',
'caps': 'Get HT/VHT capability bitmasks (requires PHY/radio)'
},
'examples': [
Expand All @@ -603,6 +632,7 @@ def main():
'iw.py info wlan0',
'iw.py station wifi0',
'iw.py link wlan0',
'iw.py mesh wifi0',
'iw.py survey wlan0',
'iw.py caps radio0'
]
Expand Down Expand Up @@ -636,6 +666,11 @@ def main():
data = {'error': 'link command requires interface argument'}
else:
data = parse_link(sys.argv[2])
elif command == 'mesh':
if len(sys.argv) < 3:
data = {'error': 'mesh command requires interface argument'}
else:
data = parse_mesh_param(sys.argv[2])
elif command == 'survey':
if len(sys.argv) < 3:
data = {'error': 'survey command requires interface argument'}
Expand Down
18 changes: 18 additions & 0 deletions doc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,28 @@ 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

- Fix #1619: Raspberry Pi kernel panic when configure Wi-Fi
- WebUI: "Save" in the interface editor and "OK" in Add Interface
did nothing for Wi-Fi and WireGuard interfaces. The inline "+ New"
forms for keystore keys and radios kept their required fields active
while hidden, which silently blocked the form they sit in
- 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`
- WebUI: a WiFi interface can be switched between station, access point
and mesh point from the interface editor. The mode used to be fixed
when the interface was created

[relsup]: https://github.com/kernelkit/infix/blob/main/doc/releases.md

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
From 68f5da1c81f2fe16bb11b6341ced616043958eec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mattias=20Walstr=C3=B6m?= <mattias.walstrom@wires.se>
Date: Tue, 15 Sep 2026 16:20:46 +0200
Subject: [PATCH] restconf: YANG Patch delete/remove of a typed leaf
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Organization: Wires

The edit for an operation without a value was created with newPath2()
and no value, which only works for leaves whose type accepts the empty
string. Removing an enumeration or boolean leaf failed with LY_EVALID.
Create the node opaque, as the DELETE method already does, and set the
operation as an attribute when it is.

Signed-off-by: Mattias Walström <lazzer@gmail.com>
---
src/restconf/Server.cpp | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)

--- a/src/restconf/Server.cpp
+++ b/src/restconf/Server.cpp
@@ -388,9 +388,11 @@
auto [lyParentPath, lastPathSegment] = asLibyangPathSplit(ctx, uriPath);

if (!valueStr) {
- // Some YANG patch operations do not have a value node, e.g., delete or move
+ // Some YANG patch operations do not have a value node, e.g., delete or move.
+ // A leaf cannot be created without a value unless it is opaque, so ask for that;
+ // containers and list entries still come out as regular nodes.
auto lyFullPath = asRestconfRequest(ctx, "PATCH", uriPath).path;
- auto [parent, node] = ctx.newPath2(lyFullPath);
+ auto [parent, node] = ctx.newPath2(lyFullPath, std::nullopt, libyang::CreationOptions::Opaque);
editNode = parent;
replacementNode = node;
} else if (!lyParentPath.empty()) {
@@ -639,6 +641,16 @@
auto target = childLeafValue(editContainer, "target");
auto operation = childLeafValue(editContainer, "operation");

+ // An opaque node (a typed leaf being deleted) cannot carry metadata; sysrepo reads the
+ // operation from an attribute on those instead, same as for the DELETE method.
+ auto setOperation = [&netconfMod](libyang::DataNode& node, const std::string& op) {
+ if (node.isOpaque()) {
+ node.newAttrOpaqueJSON("ietf-netconf", "operation", op);
+ } else {
+ node.newMeta(netconfMod, "operation", op);
+ }
+ };
+
auto [singleEdit, replacementNode] = createEditForPutAndPatch(ctx, requestCtx->req.uri().raw_path + target, yangPatchValueAsJSON(editContainer), libyang::DataFormat::JSON);
validateInputMetaAttributes(ctx, *singleEdit);

@@ -659,10 +671,10 @@
throw ErrorResponse(400, "protocol", "invalid-value", "Leaf 'point' must always come with leaf 'where' set to 'before' or 'after'");
}

- replacementNode->newMeta(netconfMod, "operation", operation == "insert" ? "create" : "merge");
+ setOperation(*replacementNode, operation == "insert" ? "create" : "merge");
yangInsert(ctx, *replacementNode, where, point);
} else {
- replacementNode->newMeta(netconfMod, "operation", operation);
+ setOperation(*replacementNode, operation);
}

if (!mergedEdits) {
6 changes: 3 additions & 3 deletions src/statd/python/cli_pretty/cli_pretty.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 28 additions & 12 deletions src/statd/python/yanger/ietf_hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
60 changes: 51 additions & 9 deletions src/statd/python/yanger/ietf_interfaces/wifi.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ def get_iw_stations(ifname):
return []


def get_iw_mesh_param(ifname):
"""Get mesh parameters via iw.py (mesh point mode)"""
try:
data = HOST.run(('/usr/libexec/infix/iw.py', 'mesh', ifname), default='{}')
return json.loads(data)
except Exception:
pass
return {}


def get_iw_link(ifname):
"""Get link info via iw.py (station mode)"""
try:
Expand Down Expand Up @@ -58,14 +68,52 @@ 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)

fwding = get_iw_mesh_param(ifname).get('mesh_fwding')
if fwding is not None:
mesh_data['forwarding'] = bool(fwding)

peers = get_iw_stations(ifname)
if peers:
Expand Down Expand Up @@ -147,13 +195,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():
Expand Down
4 changes: 2 additions & 2 deletions src/webui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading