diff --git a/net/prplmesh/Makefile b/net/prplmesh/Makefile new file mode 100644 index 0000000000..bdc15142cf --- /dev/null +++ b/net/prplmesh/Makefile @@ -0,0 +1,76 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=prplmesh +PKG_VERSION:=6.0.1 +PKG_RELEASE:=1 + +PKG_SOURCE:=prplMesh-$(PKG_VERSION).tar.gz +PKG_SOURCE_URL:=https://gitlab.com/prpl-foundation/prplmesh/prplMesh/-/archive/$(PKG_VERSION)/ +PKG_HASH:=68602c7bdf521de1797c0e58e66bb45cf105726dfd5a75d8429038012e19a702 +PKG_BUILD_DIR:=$(BUILD_DIR)/prplMesh-$(PKG_VERSION) + +PKG_LICENSE:=BSD-2-Clause-Patent BSD-3-Clause ISC MIT +PKG_LICENSE_FILES:=LICENSE LICENSES/BSD-3-Clause.txt LICENSES/ISC.txt LICENSES/MIT.txt +PKG_MAINTAINER:=Andrei Ovcharenko + +# The tlvf code generator (framework/tlvf/tlvf.py) runs at build time and +# needs a host Python with PyYAML +PKG_BUILD_DEPENDS:=python3/host python-yaml/host +CMAKE_BINARY_SUBDIR:=build-openwrt-stock + +include $(INCLUDE_DIR)/package.mk +include $(INCLUDE_DIR)/cmake.mk +include ../../lang/python/python3-host.mk + +define Package/prplmesh + SECTION:=net + CATEGORY:=Network + TITLE:=prplMesh controller and agent for stock OpenWrt + URL:=https://gitlab.com/prpl-foundation/prplmesh/prplMesh + DEPENDS:=+libstdcpp +libpthread +librt +libjson-c +libopenssl +libnl-core +libnl-genl +libnl-route +libuci +libubus +libubox +getopt +endef + +define Package/prplmesh/description + prplMesh EasyMesh controller and agent built for stock OpenWrt. It talks + to the hostapd instances of the installed wpad over their UNIX control + sockets through a small bundled control-interface client; it does not + bundle, build or replace hostapd. +endef + +define Package/prplmesh/conffiles +/etc/config/prplmesh +endef + +CMAKE_OPTIONS += \ + -DTARGET_PLATFORM=openwrt \ + -DTARGET_PLATFORM_TYPE=stock \ + -DBWL_TYPE=NL80211 \ + -DBWL_BUNDLED_WPA_CTRL=ON \ + -DENABLE_NBAPI=OFF \ + -DUSE_PRPLMESH_WHM=OFF \ + -DPRPLMESH_STOCK_OPENWRT_UCI=ON \ + -DBUILD_AGENT=ON \ + -DBUILD_CONTROLLER=ON \ + -DBUILD_TESTS=OFF \ + -DPRPLMESH_VERSION=$(PKG_VERSION) \ + -DPRPLMESH_REVISION=openwrt-r$(PKG_RELEASE) \ + -DBUILD_DATE=$(SOURCE_DATE_EPOCH) \ + -DCMAKE_INSTALL_PREFIX=/usr/libexec/prplmesh \ + -DPLATFORM_STAGING_DIR=$(STAGING_DIR) \ + -DPLATFORM_INCLUDE_DIR=$(STAGING_DIR)/usr/include \ + -DPython3_EXECUTABLE=$(HOST_PYTHON3_BIN) + +define Package/prplmesh/install + $(INSTALL_DIR) $(1)/usr/libexec/prplmesh/bin $(1)/usr/libexec/prplmesh/scripts $(1)/usr/share/prplmesh/config $(1)/usr/lib $(1)/etc/init.d $(1)/etc/config + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/bin/* $(1)/usr/libexec/prplmesh/bin/ + $(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/scripts/prplmesh_utils.sh $(1)/usr/libexec/prplmesh/scripts/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/share/* $(1)/usr/share/prplmesh/ + $(INSTALL_DATA) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/config/* $(1)/usr/share/prplmesh/config/ + $(LN) ../../share/prplmesh $(1)/usr/libexec/prplmesh/share + $(LN) ../../share/prplmesh/config $(1)/usr/libexec/prplmesh/config + $(CP) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/lib/*.so* $(1)/usr/lib/ + $(INSTALL_BIN) ./files/prplmesh.init $(1)/etc/init.d/prplmesh + $(INSTALL_CONF) ./files/prplmesh.config $(1)/etc/config/prplmesh +endef + +$(eval $(call BuildPackage,prplmesh)) diff --git a/net/prplmesh/files/prplmesh.config b/net/prplmesh/files/prplmesh.config new file mode 100644 index 0000000000..e720ad29f6 --- /dev/null +++ b/net/prplmesh/files/prplmesh.config @@ -0,0 +1,69 @@ +# beerocks derives radio and VAP identifiers from wlan interface names, +# so the fronthaul BSS netdevs must be named wlan (wireless section +# 'option ifname'). Adjust the interface lists below to the actual names. + +config prplmesh 'config' + option enabled '0' + option management_mode 'Multi-AP-Controller-and-Agent' + option enable '1' + option onboarding '0' + option master '1' + option gateway '1' + option passive_mode '0' + option wired_backhaul '1' + option operational '0' + option ssid '' + option mode_enabled 'WPA2-Personal' + option key_passphrase '' + option mem_only_psk '0' + option certification_mode '0' + option stop_on_failure_attempts '0' + option backhaul_wire_iface 'br-lan' + option backhaul_band 'auto' + option mandatory_interfaces 'wlan0,wlan1' + option band_steering '1' + option client_11k_roaming '1' + option client_roaming '1' + option load_balancing '0' + option dfs_reentry '1' + option roaming_hysteresis_percent_bonus '10' + option steering_disassoc_timer_msec '200' + option link_metrics_request_interval_sec '60' + option clients_measurement_mode '1' + option radio_stats_enable '1' + option diagnostics_measurements '0' + option unsuccessful_assoc_report_policy '1' + option unsuccessful_assoc_max_reporting_rate '30' + option ire_roaming '1' + option exclude_hostap_iface '0' + option use_explicit_bss_profiles '1' + +config wifi-device 'radio0' + option hostap_iface 'wlan0' + option wireless_section 'default_radio0' + option wireless_device 'radio0' + option hostap_iface_steer_vaps 'wlan0' + option dcs_enable '0' + +config wifi-device 'radio1' + option hostap_iface 'wlan1' + option wireless_section 'default_radio1' + option wireless_device 'radio1' + option hostap_iface_steer_vaps 'wlan1' + option dcs_enable '0' + +config bss_profile 'main_24_5' + option ssid '' + option key '' + option encryption 'sae-mixed' + option bands '2g 5g' + option fronthaul '1' + option backhaul '0' + +config bss_profile 'main_6' + option ssid '' + option key '' + option encryption 'sae' + option bands '6g' + option fronthaul '1' + option backhaul '0' diff --git a/net/prplmesh/files/prplmesh.init b/net/prplmesh/files/prplmesh.init new file mode 100644 index 0000000000..f25a1f2dd0 --- /dev/null +++ b/net/prplmesh/files/prplmesh.init @@ -0,0 +1,133 @@ +#!/bin/sh /etc/rc.common +# shellcheck disable=SC2034 +# shellcheck disable=SC3043 # ash supports local + +START=99 +STOP=10 +USE_PROCD=1 + +PRPLMESH_DIR=/usr/libexec/prplmesh +LOG_ROLL_INTERVAL_SECONDS=86400 + +mesh_enabled() { + [ "$(uci -q get prplmesh.config.enabled)" = "1" ] +} + +register_process() { + local name="$1"; shift + procd_open_instance "$name" + procd_set_param command "$@" + procd_set_param env PRPLMESH_DIR="$PRPLMESH_DIR" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +register_log_roller() { + local helper="$PRPLMESH_DIR/scripts/prplmesh_utils.sh" + + procd_open_instance log-roller + # shellcheck disable=SC2016 + procd_set_param command /bin/sh -c ' + helper=$1 + interval=$2 + while sleep "$interval"; do + "$helper" roll_logs || logger -t prplmesh "log roll failed" + done' sh "$helper" "$LOG_ROLL_INTERVAL_SECONDS" + procd_set_param respawn 3600 5 5 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +start_service() { + local mode need_agent=1 need_controller=1 log_roller=1 + + mesh_enabled || return 0 + + # A standalone controller runs without local radios, a plain agent + # without the controller; everything else keeps both. + mode="$(uci -q get prplmesh.config.management_mode)" + case "$mode" in + Multi-AP-Controller) need_agent=0 ;; + Multi-AP-Agent) need_controller=0 ;; + esac + + [ -x "$PRPLMESH_DIR/bin/ieee1905_transport" ] || { + logger -t prplmesh "ieee1905_transport is missing or not executable" + return 1 + } + + # Log rotation is not worth keeping the mesh down for: run without + # it when the helper is stripped from the image. + [ -x "$PRPLMESH_DIR/scripts/prplmesh_utils.sh" ] || { + logger -t prplmesh "log-roll helper is missing or not executable; log rotation disabled" + log_roller=0 + } + + if [ "$need_controller" = 1 ]; then + [ -x "$PRPLMESH_DIR/bin/beerocks_controller" ] || { + logger -t prplmesh "beerocks_controller is missing or not executable" + return 1 + } + fi + + if [ "$need_agent" = 1 ]; then + [ -x "$PRPLMESH_DIR/bin/beerocks_agent" ] || { + logger -t prplmesh "beerocks_agent is missing or not executable" + return 1 + } + [ -n "$(uci -q get prplmesh.config.mandatory_interfaces)" ] || { + logger -t prplmesh "mandatory_interfaces not set; agent left stopped" + return 1 + } + fi + + if [ "$log_roller" = 1 ] && [ ! -e /var/run/prplmesh.boot_roll ]; then + # Prune previous-boot logs before the daemons open new ones: the + # helper only deletes old rotations and signals running daemons, + # and none of ours are up yet, so nothing is rotated out early. + # Reload triggers re-enter start_service with the daemons up + # (procd reload falls through to start), so stamp the roll as + # done for this boot; /var/run is tmpfs and clears the stamp. + "$PRPLMESH_DIR/scripts/prplmesh_utils.sh" roll_logs >/dev/null 2>&1 \ + || logger -t prplmesh "startup log roll failed" + touch /var/run/prplmesh.boot_roll + fi + + register_process transport "$PRPLMESH_DIR/bin/ieee1905_transport" + + if [ "$log_roller" = 1 ]; then + register_log_roller + fi + + if [ "$need_controller" = 1 ]; then + register_process controller "$PRPLMESH_DIR/bin/beerocks_controller" + fi + + if [ "$need_agent" = 1 ]; then + # hostapd recreates its control sockets on boot and on wireless + # reloads, so wait for them under procd supervision instead of + # failing the whole start on a transient race. + # shellcheck disable=SC2016 + register_process agent /bin/sh -c ' + while :; do + ok=1 + IFS="," + for interface in $(uci -q get prplmesh.config.mandatory_interfaces); do + unset IFS + [ -n "$interface" ] || continue + [ -S "/var/run/hostapd/$interface" ] || { ok=0; break; } + done + unset IFS + [ "$ok" = 1 ] && break + sleep 5 + done + exec "$PRPLMESH_DIR/bin/beerocks_agent"' + fi +} + +service_triggers() { + procd_add_reload_trigger prplmesh wireless network +} diff --git a/net/prplmesh/patches/100-stock-platform.patch b/net/prplmesh/patches/100-stock-platform.patch new file mode 100644 index 0000000000..566e71da69 --- /dev/null +++ b/net/prplmesh/patches/100-stock-platform.patch @@ -0,0 +1,526 @@ +From 3e5e89033a8118021d176c86122f07229110e46f Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Tue, 21 Jul 2026 22:33:14 +0300 +Subject: [PATCH] platform: support stock OpenWrt UCI and nl80211 + +Add an opt-in platform mode that keeps stock OpenWrt in charge of UCI, +ubus, the Linux bridge and hostapd. In this mode prplMesh uses the +existing nl80211 and hostapd control interfaces instead of depending on +prplOS WHM or Ambiorix. + +The UCI backend reads the runtime bridge and radio mappings, avoids NBAPI +when it is disabled, and supplies the small portability fixes needed by +the stock OpenWrt toolchain. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + CMakeLists.txt | 1 + + agent/src/beerocks/slave/CMakeLists.txt | 3 +- + .../beerocks/slave/beerocks_agent_main.cpp | 14 +++ + .../include/bcl/beerocks_ucc_server_factory.h | 1 + + common/beerocks/bwl/shared/nl80211_socket.h | 2 + + controller/config/CMakeLists.txt | 2 +- + .../beerocks/master/beerocks_master_main.cpp | 31 ++++++ + framework/platform/bpl/CMakeLists.txt | 61 +++++++----- + .../bpl/cfg/linux/bpl_cfg_ifaces.cpp.in | 1 + + framework/platform/bpl/cfg/uci/bpl.cpp | 1 + + framework/platform/bpl/cfg/uci/bpl_cfg.cpp | 96 ++++++++++++++++++- + .../platform/bpl/cfg/uci/bpl_cfg_helper.cpp | 47 +++++++++ + .../platform/bpl/cfg/uci/bpl_cfg_helper.h | 10 ++ + .../platform/bpl/cfg/uci/bpl_cfg_uci.cpp | 18 ++++ + 14 files changed, 261 insertions(+), 27 deletions(-) + +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -66,6 +66,7 @@ option (ENABLE_NBAPI "Build the northbou + option (ENABLE_VBSS "Build with Virtual BSS support" ON) + + option(USE_PRPLMESH_WHM "Use prplMesh Wireless Hardware Manager" ON) ++option(PRPLMESH_STOCK_OPENWRT_UCI "Build against stock OpenWrt UCI/ubus without Ambiorix or WHM" OFF) + + ## Generic checks and defaults + +--- a/agent/src/beerocks/slave/CMakeLists.txt ++++ b/agent/src/beerocks/slave/CMakeLists.txt +@@ -15,11 +15,12 @@ file(GLOB beerocks_agent_sources + ${MODULE_PATH}/tasks/*.c* + ${MODULE_PATH}/traffic_separation/*.c* + ${MODULE_PATH}/gate/*.c* +- ${MODULE_PATH}/nbapi/*.c* + ${MODULE_PATH}/*.c* + ) + + if (ENABLE_NBAPI) ++ file(GLOB beerocks_agent_nbapi_sources ${MODULE_PATH}/nbapi/*.c*) ++ list(APPEND beerocks_agent_sources ${beerocks_agent_nbapi_sources}) + add_subdirectory("nbapi") + endif() + +--- a/agent/src/beerocks/slave/beerocks_agent_main.cpp ++++ b/agent/src/beerocks/slave/beerocks_agent_main.cpp +@@ -632,6 +632,20 @@ int main(int argc, char *argv[]) + return 1; + } + } ++ ++ // Stock OpenWrt can move the wired EasyMesh backhaul between bridges at ++ // runtime (for example from the management LAN to a dedicated SSID ++ // network). Keep every agent component on the UCI-selected bridge rather ++ // than the static value shipped in beerocks_agent.conf. ++ std::string configured_bridge_iface; ++ if (beerocks::bpl::bpl_cfg_get_backhaul_wire_iface(configured_bridge_iface) && ++ !configured_bridge_iface.empty()) { ++ beerocks_slave_conf.bridge_iface = configured_bridge_iface; ++ } else { ++ LOG(WARNING) << "Unable to read backhaul_wire_iface; using bridge_iface='" ++ << beerocks_slave_conf.bridge_iface << "' from agent configuration"; ++ } ++ + // beerocks system hang tester + if (beerocks_slave_conf.enable_system_hang_test == "1") { + +--- a/common/beerocks/bcl/include/bcl/beerocks_ucc_server_factory.h ++++ b/common/beerocks/bcl/include/bcl/beerocks_ucc_server_factory.h +@@ -12,6 +12,7 @@ + #include + #include + ++#include + #include + + namespace beerocks { +--- a/common/beerocks/bwl/shared/nl80211_socket.h ++++ b/common/beerocks/bwl/shared/nl80211_socket.h +@@ -21,6 +21,8 @@ namespace bwl { + class nl80211_socket : public nl_genl_socket { + + public: ++ using nl_genl_socket::send_receive_msg; ++ + /** + * @brief Class destructor. + */ +--- a/controller/config/CMakeLists.txt ++++ b/controller/config/CMakeLists.txt +@@ -1,5 +1,5 @@ + set(BEEROCKS_CONTROLLER_DATAELEMENTS_VAP_CONFIGS 0) +-if (NOT USE_PRPLMESH_WHM) ++if (NOT USE_PRPLMESH_WHM AND NOT PRPLMESH_STOCK_OPENWRT_UCI) + set(BEEROCKS_CONTROLLER_DATAELEMENTS_VAP_CONFIGS 1) + endif() + +--- a/controller/src/beerocks/master/beerocks_master_main.cpp ++++ b/controller/src/beerocks/master/beerocks_master_main.cpp +@@ -672,6 +672,18 @@ int main(int argc, char *argv[]) + } + } + ++ // The wired EasyMesh tree can be moved between OpenWrt bridges at runtime. ++ // Keep the controller on the same UCI-selected bridge as the agent instead ++ // of retaining the static bridge_iface from beerocks_agent.conf. ++ std::string configured_bridge_iface; ++ if (beerocks::bpl::bpl_cfg_get_backhaul_wire_iface(configured_bridge_iface) && ++ !configured_bridge_iface.empty()) { ++ beerocks_slave_conf.bridge_iface = configured_bridge_iface; ++ } else { ++ LOG(WARNING) << "Unable to read backhaul_wire_iface; using bridge_iface='" ++ << beerocks_slave_conf.bridge_iface << "' from agent configuration"; ++ } ++ + std::string base_master_name = std::string(BEEROCKS_CONTROLLER); + + //kill running master +@@ -820,6 +832,25 @@ int main(int argc, char *argv[]) + beerocks::btl::create_broker_client_factory(broker_uds_path, event_loop); + LOG_IF(!broker_client_factory, FATAL) << "Unable to create broker client factory!"; + ++ // The controller can run without a local agent. Configure the transport directly so that ++ // IEEE1905.1 frames received on the bridge are not dropped while no agent is present (or while ++ // the local agent is still starting). Keep this client alive for the lifetime of the controller. ++ auto transport_config_client = broker_client_factory->create_instance(); ++ if (!transport_config_client) { ++ LOG(ERROR) << "Unable to create broker client for transport configuration!"; ++ return 1; ++ } ++ ++ if (!transport_config_client->configure_al_mac(tlvf::mac_from_string(bridge_info.mac))) { ++ LOG(ERROR) << "Unable to configure transport AL MAC " << bridge_info.mac; ++ return 1; ++ } ++ ++ if (!transport_config_client->configure_interfaces(bridge_iface, {}, true, true)) { ++ LOG(ERROR) << "Unable to configure transport bridge " << bridge_iface; ++ return 1; ++ } ++ + son::Controller controller(master_db, std::move(broker_client_factory), std::move(ucc_server), + std::move(cmdu_server), timer_manager, event_loop); + +--- a/framework/platform/bpl/CMakeLists.txt ++++ b/framework/platform/bpl/CMakeLists.txt +@@ -87,19 +87,32 @@ if (TARGET_PLATFORM STREQUAL "openwrt") + add_definitions(-DHAVE_C99) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -std=c99") + +- # Other OpenWRT based platforms (e.g. Turris-Omnia or GL.iNet-B1300) ++ # Stock OpenWrt with native UCI/ubus and no Ambiorix/WHM dependencies. + else() + +- file(GLOB_RECURSE bpl_platform_sources +- ${MODULE_PATH}/arp/linux/*.c* +- ${MODULE_PATH}/dhcp/luci_rpc/*.c* +- ${MODULE_PATH}/db/uci/*.c* +- ${MODULE_PATH}/cfg/dm/*.c* +- ${MODULE_PATH}/cfg/vendor/*.c* +- ${MODULE_PATH}/common/uci/*.c* +- ${MODULE_PATH}/board/system_ubus/*.c* +- ${MODULE_PATH}/service_prioritization/tc/service_prio_utils_tc.cpp +- ) ++ if (PRPLMESH_STOCK_OPENWRT_UCI) ++ file(GLOB_RECURSE bpl_platform_sources ++ ${MODULE_PATH}/arp/linux/*.c* ++ ${MODULE_PATH}/cfg/uci/*.c* ++ ${MODULE_PATH}/cfg/vendor/*.c* ++ ${MODULE_PATH}/common/uci/*.c* ++ ${MODULE_PATH}/db/uci/*.c* ++ ${MODULE_PATH}/dhcp/uci/*.c* ++ ${MODULE_PATH}/board/system_ubus/*.c* ++ ${MODULE_PATH}/service_prioritization/tc/service_prio_utils_tc.cpp ++ ) ++ else() ++ file(GLOB_RECURSE bpl_platform_sources ++ ${MODULE_PATH}/arp/linux/*.c* ++ ${MODULE_PATH}/dhcp/luci_rpc/*.c* ++ ${MODULE_PATH}/db/uci/*.c* ++ ${MODULE_PATH}/cfg/dm/*.c* ++ ${MODULE_PATH}/cfg/vendor/*.c* ++ ${MODULE_PATH}/common/uci/*.c* ++ ${MODULE_PATH}/board/system_ubus/*.c* ++ ${MODULE_PATH}/service_prioritization/tc/service_prio_utils_tc.cpp ++ ) ++ endif() + + find_package(ubus REQUIRED) + +@@ -159,11 +172,13 @@ else() + + endif() + +-find_package(amxb REQUIRED) +-find_package(amxc REQUIRED) +-find_package(amxd REQUIRED) +-find_package(amxp REQUIRED) +-list(APPEND BPL_LIBS amxb amxc amxd amxp) ++if (NOT PRPLMESH_STOCK_OPENWRT_UCI) ++ find_package(amxb REQUIRED) ++ find_package(amxc REQUIRED) ++ find_package(amxd REQUIRED) ++ find_package(amxp REQUIRED) ++ list(APPEND BPL_LIBS amxb amxc amxd amxp) ++endif() + + # BPL pwhm DM via wbapi + if (USE_PRPLMESH_WHM) +@@ -185,7 +200,7 @@ if (USE_PRPLMESH_WHM) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${WBAPI_SOCK}") + + list(APPEND BPL_LIBS wbapi) +-else () ++elseif (NOT PRPLMESH_STOCK_OPENWRT_UCI) + file(GLOB_RECURSE bpl_platform_sources + ${MODULE_PATH}/*/linux/*.c* + ${MODULE_PATH}/service_prioritization/dummy/service_prio_utils_dummy.c* +@@ -194,11 +209,13 @@ else () + endif() + + # BPL configuration via nbapi +-list(FIND bpl_platform_sources ${MODULE_PATH}/cfg/dm/bpl_amx.cpp _index) +-message("bpl_platform_sources: ${bpl_platform_sources}") +-if (_index EQUAL -1) +- message("APPEND bpl_platform_sources with ${MODULE_PATH}/cfg/dm/bpl_amx.cpp") +- list(APPEND bpl_platform_sources ${MODULE_PATH}/cfg/dm/bpl_amx.cpp) ++if (NOT PRPLMESH_STOCK_OPENWRT_UCI) ++ list(FIND bpl_platform_sources ${MODULE_PATH}/cfg/dm/bpl_amx.cpp _index) ++ message("bpl_platform_sources: ${bpl_platform_sources}") ++ if (_index EQUAL -1) ++ message("APPEND bpl_platform_sources with ${MODULE_PATH}/cfg/dm/bpl_amx.cpp") ++ list(APPEND bpl_platform_sources ${MODULE_PATH}/cfg/dm/bpl_amx.cpp) ++ endif() + endif() + list(APPEND BPL_LIBS nbapi) + +--- a/framework/platform/bpl/cfg/linux/bpl_cfg_ifaces.cpp.in ++++ b/framework/platform/bpl/cfg/linux/bpl_cfg_ifaces.cpp.in +@@ -6,6 +6,7 @@ + * See LICENSE file for more details. + */ + ++ #include + #include + + namespace beerocks { +--- a/framework/platform/bpl/cfg/uci/bpl.cpp ++++ b/framework/platform/bpl/cfg/uci/bpl.cpp +@@ -7,6 +7,7 @@ + */ + + #include ++#include + + #include + +--- a/framework/platform/bpl/cfg/uci/bpl_cfg.cpp ++++ b/framework/platform/bpl/cfg/uci/bpl_cfg.cpp +@@ -16,14 +16,41 @@ + #include "bpl_cfg_uci.h" + + #include ++#include + #include + #include + ++#include ++ + using namespace mapf; + + namespace beerocks { + namespace bpl { + ++static bool read_device_info_file(const char *path, std::string &value) ++{ ++ std::ifstream input(path, std::ios::binary); ++ if (!input.good()) { ++ return false; ++ } ++ ++ std::getline(input, value, '\0'); ++ auto line_end = value.find_first_of("\r\n"); ++ if (line_end != std::string::npos) { ++ value.erase(line_end); ++ } ++ while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) { ++ value.pop_back(); ++ } ++ auto first = value.find_first_not_of(" \t"); ++ if (first == std::string::npos) { ++ value.clear(); ++ return false; ++ } ++ value.erase(0, first); ++ return true; ++} ++ + int cfg_get_hostap_iface_steer_vaps(int32_t radio_num, + char hostap_iface_steer_vaps[BPL_LOAD_STEER_ON_VAPS_LEN]) + { +@@ -152,8 +179,8 @@ int cfg_get_dcs_channel_pool(const BPL_W + return RETURN_ERR; + } + +- if (radio_num < 0) { +- MAPF_ERR("invalid input: radio_num < 0"); ++ if (iface.radio_num < 0) { ++ MAPF_ERR("invalid input: iface.radio_num < 0"); + return RETURN_ERR; + } + +@@ -1102,8 +1129,12 @@ bool cfg_get_clients_unicast_measurement + + bool cfg_get_private_bridge_iface(std::string &bridge_iface) + { ++ if (bpl_cfg_get_backhaul_wire_iface(bridge_iface) && !bridge_iface.empty()) { ++ return true; ++ } ++ + bridge_iface = DEFAULT_PRIVATE_BRIDGE_IFACE; +- return true; ++ return false; + } + + bool cfg_get_guest_bridge_iface(std::string &bridge_iface) +@@ -1132,5 +1163,64 @@ bool cfg_get_traffic_separation_guest_vi + + bool cfg_commit_changes() { return uci_commit_changes("prplmesh"); } + ++bool get_serial_number(std::string &serial_number) ++{ ++ if (!read_device_info_file("/sys/firmware/devicetree/base/serial-number", serial_number) && ++ !read_device_info_file("/proc/device-tree/serial-number", serial_number) && ++ !read_device_info_file("/tmp/sysinfo/board_name", serial_number)) { ++ serial_number = "openwrt-prplmesh"; ++ } ++ return true; ++} ++ ++bool get_software_version(std::string &software_version) ++{ ++ software_version = beerocks::version::get_module_version(); ++ return true; ++} ++ ++bool get_manufacturer(std::string &manufacturer) ++{ ++ if (!read_device_info_file("/tmp/sysinfo/board_name", manufacturer)) { ++ manufacturer = "OpenWrt"; ++ return true; ++ } ++ ++ auto separator = manufacturer.find(','); ++ if (separator != std::string::npos) { ++ manufacturer.erase(separator); ++ } ++ return true; ++} ++ ++bool get_model_name(std::string &model_name) ++{ ++ if (!read_device_info_file("/tmp/sysinfo/model", model_name)) { ++ model_name = "OpenWrt"; ++ } ++ return true; ++} ++ ++bool get_model_number(std::string &model_number) ++{ ++ if (!read_device_info_file("/tmp/sysinfo/board_name", model_number)) { ++ return get_model_name(model_number); ++ } ++ return true; ++} ++ ++bool get_ruid_chipset_vendor(const sMacAddr &ruid, std::string &chipset_vendor) ++{ ++ (void)ruid; ++ chipset_vendor = "generic-nl80211"; ++ return true; ++} ++ ++bool get_max_prioritization_rules(uint32_t &max_prioritization_rules) ++{ ++ max_prioritization_rules = 1; ++ return true; ++} ++ + } // namespace bpl + } // namespace beerocks +--- a/framework/platform/bpl/cfg/uci/bpl_cfg_helper.cpp ++++ b/framework/platform/bpl/cfg/uci/bpl_cfg_helper.cpp +@@ -11,6 +11,7 @@ + + #include "bpl_cfg_helper.h" + #include "bpl_cfg_uci.h" ++#include "bpl_cfg_wifi_utils.h" + + #include + #include +@@ -38,6 +39,52 @@ int cfg_get_prplmesh_hostapd_ifaces(std: + (char *)"hostap_iface", hostapd_ifaces); + } + ++bool cfg_get_prplmesh_wireless_section(const std::string &iface, std::string §ion_name) ++{ ++ section_name.clear(); ++ ++ // Keep compatibility with systems that still explicitly configure ifname. ++ if (uci_find_section_by_option("wireless", "wifi-iface", "ifname", iface, section_name) && ++ !section_name.empty()) { ++ return true; ++ } ++ ++ std::string prplmesh_radio; ++ if (!uci_find_section_by_option("prplmesh", "wifi-device", "hostap_iface", iface, ++ prplmesh_radio) || ++ prplmesh_radio.empty()) { ++ return false; ++ } ++ ++ if (uci_get_option("prplmesh", "wifi-device", prplmesh_radio, "wireless_section", ++ section_name) && ++ uci_section_exists("wireless", "wifi-iface", section_name)) { ++ return true; ++ } ++ ++ std::string wireless_device; ++ if (!uci_get_option("prplmesh", "wifi-device", prplmesh_radio, "wireless_device", ++ wireless_device)) { ++ wireless_device = prplmesh_radio; ++ } ++ ++ std::vector sections; ++ if (!uci_get_all_sections("wireless", "wifi-iface", sections)) { ++ return false; ++ } ++ std::vector matching_sections; ++ for (const auto &candidate : sections) { ++ std::string device; ++ std::string mode; ++ if (uci_get_option("wireless", "wifi-iface", candidate, "device", device) && ++ device == wireless_device && ++ uci_get_option("wireless", "wifi-iface", candidate, "mode", mode) && mode == "ap") { ++ matching_sections.push_back(candidate); ++ } ++ } ++ return wifi_cfg_utils::select_unique_wireless_section(matching_sections, section_name); ++} ++ + int cfg_get_prplmesh_radio_param(int radio_id, const std::string &radio_param, char *buf, + size_t buf_len) + { +--- a/framework/platform/bpl/cfg/uci/bpl_cfg_helper.h ++++ b/framework/platform/bpl/cfg/uci/bpl_cfg_helper.h +@@ -26,6 +26,16 @@ namespace bpl { + int cfg_get_prplmesh_hostapd_ifaces(std::unordered_map &hostapd_ifaces); + + /** ++ * Resolve a runtime hostapd interface to its stock OpenWrt wifi-iface section. ++ * ++ * Modern OpenWrt normally omits the legacy wireless.*.ifname option. In that ++ * case the prplmesh wifi-device section may provide wireless_section (for ++ * example default_radio0). As a compatibility fallback, wireless_device or ++ * the prplmesh section name is matched against wireless.*.device. ++ */ ++bool cfg_get_prplmesh_wireless_section(const std::string &iface, std::string §ion_name); ++ ++/** + * Returns the value of requested param from DB + * + * @param [in] param prplmesh param key string +--- a/framework/platform/bpl/cfg/uci/bpl_cfg_uci.cpp ++++ b/framework/platform/bpl/cfg/uci/bpl_cfg_uci.cpp +@@ -8,6 +8,7 @@ + + #include "bpl_cfg_uci.h" + #include "../../common/utils/utils.h" ++#include "bpl_cfg_helper.h" + #include + #include + +@@ -130,6 +131,23 @@ int cfg_uci_get_wireless_from_ifname(enu + } + } + ++ // Modern stock OpenWrt omits wireless.*.ifname. Resolve the runtime ++ // hostapd interface through the explicit prplmesh mapping first. ++ if (!is_section_found) { ++ std::string mapped_section; ++ if (cfg_get_prplmesh_wireless_section(interface_name, mapped_section)) { ++ uci_foreach_element(&p->sections, e) ++ { ++ s = uci_to_section(e); ++ if (!strncmp(s->type, "wifi-iface", MAX_UCI_BUF_LEN) && ++ mapped_section == s->e.name) { ++ is_section_found = true; ++ break; ++ } ++ } ++ } ++ } ++ + // If interface not found in etc/config/wireless 'config wifi-iface'. + // Try to get ifname using option path from 'config wifi-device'. + // This doesn't work for RDK-B, since there is no etc/config/wireless file. diff --git a/net/prplmesh/patches/110-stock-wifi-hardening.patch b/net/prplmesh/patches/110-stock-wifi-hardening.patch new file mode 100644 index 0000000000..2082fb5f52 --- /dev/null +++ b/net/prplmesh/patches/110-stock-wifi-hardening.patch @@ -0,0 +1,594 @@ +From f5ba6681fa3f980eeb5109726bf00d34c50c560c Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Tue, 21 Jul 2026 22:33:15 +0300 +Subject: [PATCH] platform: harden stock OpenWrt Wi-Fi profiles + +Let the stock UCI backend use explicit multi-band BSS profiles, merge +identical profiles, and reject incomplete or invalid input. Keep the +legacy wireless-section path available when explicit profiles are not +requested. Secured profiles must carry a plausible key: an 8..63 +character passphrase or a 64 hex digit PSK, anything else is rejected. +An explicit profile must state its encryption; an omitted option is an +error instead of silently configuring an open network. + +Also avoid building platform tests in non-test builds and make the +association-frame bitfields portable across the OpenWrt toolchain. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + .../platform/bpl/cfg/uci/bpl_cfg_wifi.cpp | 215 +++++++++++------- + .../platform/bpl/cfg/uci/bpl_cfg_wifi_utils.h | 139 +++++++++++ + framework/platform/bpl/test/CMakeLists.txt | 8 + + .../bpl/test/bpl_cfg_wifi_utils_test.cpp | 66 ++++++ + .../assoc_frame_bitfields.h | 1 + + 5 files changed, 348 insertions(+), 81 deletions(-) + create mode 100644 framework/platform/bpl/cfg/uci/bpl_cfg_wifi_utils.h + create mode 100644 framework/platform/bpl/test/bpl_cfg_wifi_utils_test.cpp + +--- a/framework/platform/bpl/cfg/uci/bpl_cfg_wifi.cpp ++++ b/framework/platform/bpl/cfg/uci/bpl_cfg_wifi.cpp +@@ -14,16 +14,113 @@ + + #include "bpl_cfg_helper.h" + #include "bpl_cfg_uci.h" ++#include "bpl_cfg_wifi_utils.h" + + #include + #include + #include + ++#include ++#include ++ + using namespace mapf; + + namespace beerocks { + namespace bpl { + ++static bool same_bss_profile(const son::wireless_utils::sBssInfoConf &lhs, ++ const son::wireless_utils::sBssInfoConf &rhs) ++{ ++ return lhs.ssid == rhs.ssid && lhs.authentication_type == rhs.authentication_type && ++ lhs.encryption_type == rhs.encryption_type && lhs.network_key == rhs.network_key && ++ lhs.fronthaul == rhs.fronthaul && lhs.backhaul == rhs.backhaul; ++} ++ ++static void ++add_or_merge_bss_profile(std::list &wireless_settings, ++ son::wireless_utils::sBssInfoConf configuration) ++{ ++ for (auto &existing : wireless_settings) { ++ if (!same_bss_profile(existing, configuration)) { ++ continue; ++ } ++ existing.operating_class.splice(existing.operating_class.end(), ++ configuration.operating_class); ++ existing.operating_class.sort(); ++ existing.operating_class.unique(); ++ return; ++ } ++ wireless_settings.push_back(std::move(configuration)); ++} ++ ++static bool ++bpl_cfg_get_explicit_bss_profiles(std::list &wireless_settings) ++{ ++ std::vector sections; ++ if (!uci_get_all_sections("prplmesh", "bss_profile", sections)) { ++ LOG(ERROR) << "No explicit prplmesh bss_profile sections found"; ++ return false; ++ } ++ ++ for (const auto §ion_name : sections) { ++ OptionsUnorderedMap options; ++ if (!uci_get_section("prplmesh", "bss_profile", section_name, options)) { ++ LOG(ERROR) << "Failed to read explicit BSS profile " << section_name; ++ return false; ++ } ++ ++ son::wireless_utils::sBssInfoConf configuration{}; ++ configuration.ssid = options["ssid"]; ++ configuration.network_key = options["key"]; ++ // An absent encryption option must not silently fall back to an ++ // open network; intentionally open profiles say 'none' explicitly. ++ if (options["encryption"].empty()) { ++ LOG(ERROR) << "Missing encryption in explicit BSS profile " << section_name; ++ return false; ++ } ++ configuration.authentication_type = ++ wifi_cfg_utils::authentication_from_uci(options["encryption"]); ++ configuration.encryption_type = wifi_cfg_utils::encryption_from_uci(options["encryption"]); ++ configuration.fronthaul = wifi_cfg_utils::parse_bool(options["fronthaul"], true); ++ configuration.backhaul = wifi_cfg_utils::parse_bool(options["backhaul"], false); ++ ++ std::istringstream bands(options["bands"]); ++ std::string band; ++ while (bands >> band) { ++ if (!wifi_cfg_utils::append_operating_classes(band, configuration.operating_class)) { ++ LOG(ERROR) << "Invalid band '" << band << "' in BSS profile " << section_name; ++ return false; ++ } ++ } ++ ++ if (configuration.ssid.empty() || configuration.operating_class.empty() || ++ configuration.authentication_type == WSC::eWscAuth::WSC_AUTH_INVALID || ++ configuration.encryption_type == WSC::eWscEncr::WSC_ENCR_INVALID) { ++ LOG(ERROR) << "Incomplete or invalid explicit BSS profile " << section_name; ++ return false; ++ } ++ ++ if (configuration.authentication_type != WSC::eWscAuth::WSC_AUTH_OPEN) { ++ const std::string &key = configuration.network_key; ++ bool valid_key = key.length() >= 8 && key.length() <= 64; ++ if (valid_key && key.length() == 64) { ++ // 64 characters mean a raw PSK, which is hexadecimal only; ++ // passphrases are limited to 8..63 characters. ++ valid_key = key.find_first_not_of("0123456789abcdefABCDEF") == std::string::npos; ++ } ++ if (!valid_key) { ++ LOG(ERROR) << "Missing or invalid key in secured explicit BSS profile " ++ << section_name; ++ return false; ++ } ++ } ++ ++ add_or_merge_bss_profile(wireless_settings, std::move(configuration)); ++ } ++ ++ return !wireless_settings.empty(); ++} ++ + static bool bpl_cfg_get_bss_configuration(const std::string §ion_name, + son::wireless_utils::sBssInfoConf &configuration) + { +@@ -36,38 +133,9 @@ static bool bpl_cfg_get_bss_configuratio + // Fill in wireless credentials from option values read from UCI configuration. + configuration.ssid = options["ssid"]; + +- auto starts_with = [](const std::string &prefix, const std::string &value) { +- return (value.compare(0, prefix.size(), prefix) == 0); +- }; +- +- auto contains = [](const std::string &substring, const std::string &value) { +- return (value.find(substring) != std::string::npos); +- }; +- +- auto get_authentication_type = [&](const std::string &encryption) { +- if ("none" == encryption || encryption.empty()) { +- return WSC::eWscAuth::WSC_AUTH_OPEN; +- } else if (starts_with("psk2", encryption)) { +- return WSC::eWscAuth::WSC_AUTH_WPA2PSK; +- } else if ("sae" == encryption) { +- return WSC::eWscAuth::WSC_AUTH_SAE; +- } +- return WSC::eWscAuth::WSC_AUTH_INVALID; +- }; +- configuration.authentication_type = get_authentication_type(options["encryption"]); +- +- auto get_encryption_type = [&](const std::string &encryption) { +- if ("none" == encryption || encryption.empty()) { +- return WSC::eWscEncr::WSC_ENCR_NONE; +- } else if (contains("+tkip", encryption)) { +- return WSC::eWscEncr::WSC_ENCR_TKIP; +- } else if (("psk2" == encryption) || ("sae" == encryption) || +- contains("+aes", encryption) || contains("+ccmp", encryption)) { +- return WSC::eWscEncr::WSC_ENCR_AES; +- } +- return WSC::eWscEncr::WSC_ENCR_INVALID; +- }; +- configuration.encryption_type = get_encryption_type(options["encryption"]); ++ configuration.authentication_type = ++ wifi_cfg_utils::authentication_from_uci(options["encryption"]); ++ configuration.encryption_type = wifi_cfg_utils::encryption_from_uci(options["encryption"]); + + configuration.network_key = options["key"]; + +@@ -128,8 +196,8 @@ int cfg_get_wifi_params(const std::strin + cfg_uci_get_wireless_bool(TYPE_RADIO, iface.c_str(), "disabled", &disabled); + wlan_params->enabled = !disabled; + +- if (cfg_uci_get_wireless_bool(TYPE_RADIO, iface, "sub_band_dfs", &wlan_params->sub_band_dfs) == +- RETURN_ERR) { ++ if (cfg_uci_get_wireless_bool(TYPE_RADIO, iface.c_str(), "sub_band_dfs", ++ &wlan_params->sub_band_dfs) == RETURN_ERR) { + // Failed to find "sub_band_dfs", set to to default value. + wlan_params->sub_band_dfs = false; + } +@@ -143,7 +211,7 @@ int cfg_get_wifi_params(const std::strin + + // country code + char alpha_2[MAX_UCI_BUF_LEN] = {0}; +- cfg_uci_get_wireless_from_ifname(TYPE_RADIO, iface, "country", alpha_2); ++ cfg_uci_get_wireless_from_ifname(TYPE_RADIO, iface.c_str(), "country", alpha_2); + + wlan_params->country_code[0] = alpha_2[0]; + wlan_params->country_code[1] = alpha_2[1]; +@@ -153,6 +221,15 @@ int cfg_get_wifi_params(const std::strin + + bool bpl_cfg_get_wireless_settings(std::list &wireless_settings) + { ++ int use_explicit_profiles = 0; ++ if (cfg_get_prplmesh_param_int_default("use_explicit_bss_profiles", &use_explicit_profiles, ++ 0) != RETURN_OK) { ++ return false; ++ } ++ if (use_explicit_profiles != 0) { ++ return bpl_cfg_get_explicit_bss_profiles(wireless_settings); ++ } ++ + // Get all "wireless.wifi-iface" section names in UCI configuration + const std::string package_name = "wireless"; + const std::string section_type = "wifi-iface"; +@@ -227,7 +304,7 @@ bool bpl_cfg_get_wireless_settings(std:: + continue; + } + +- son::wireless_utils::sBssInfoConf configuration; ++ son::wireless_utils::sBssInfoConf configuration{}; + if (!bpl_cfg_get_bss_configuration(section_name, configuration)) { + LOG(DEBUG) << "Failed to get SSID and WiFi credentials from section " << section_name; + continue; +@@ -251,13 +328,11 @@ bool bpl_cfg_get_wireless_settings(std:: + continue; + } + +- // Option "hwmode" in device section selects the wireless protocol to use, possible values +- // are 11b, 11g, and 11a. ++ // Modern OpenWrt uses option "band". Fall back to legacy "hwmode". ++ std::string band; ++ uci_get_option(package_name, "wifi-device", device, "band", band); + std::string hwmode; +- if (!uci_get_option(package_name, "wifi-device", device, "hwmode", hwmode)) { +- LOG(DEBUG) << "Failed to get 'hwmode' from section " << device; +- continue; +- } ++ uci_get_option(package_name, "wifi-device", device, "hwmode", hwmode); + + // The mode used by upstream hostapd (11b, 11g, 11n, 11ac, 11ax) is governed by several parameters in + // the configuration file. However, as explained in the comment below from hostapd.conf, the +@@ -274,17 +349,11 @@ bool bpl_cfg_get_wireless_settings(std:: + // + // For MaxLinear's devices, by default '11bgnax' is used for 2.4Ghz bands, and '11anacax' is + // used for 5Ghz bands (see 'files/scripts/lib/netifd/wireless/mac80211.sh' in the swpal package). +- if (hwmode.empty() || (hwmode == "11b") || (hwmode == "11g") || hwmode == "11bgnax") { +- configuration.operating_class.splice( +- configuration.operating_class.end(), +- son::wireless_utils::string_to_wsc_oper_class("24g")); +- } else if (hwmode == "11a" || hwmode == "11anacax") { +- configuration.operating_class.splice( +- configuration.operating_class.end(), +- son::wireless_utils::string_to_wsc_oper_class("5g")); +- } else { ++ const auto band_or_hwmode = band.empty() ? hwmode : band; ++ if (!wifi_cfg_utils::append_operating_classes(band_or_hwmode, ++ configuration.operating_class)) { + LOG(DEBUG) << "Failed to get frequency band for SSID " << configuration.ssid +- << " from hwmode " << hwmode; ++ << " from band/hwmode " << band_or_hwmode; + continue; + } + +@@ -326,10 +395,10 @@ bool bpl_cfg_get_wireless_settings(std:: + } + } + +- wireless_settings.push_back(configuration); +- + LOG(DEBUG) << "Configuration added for SSID " << configuration.ssid +- << " (hwmode = " << hwmode << ")"; ++ << " (band/hwmode = " << band_or_hwmode << ")"; ++ ++ add_or_merge_bss_profile(wireless_settings, std::move(configuration)); + } + + return true; +@@ -340,7 +409,7 @@ bool bpl_cfg_get_wifi_credentials(const + { + // Find the "wireless.wifi-iface" section in UCI configuration for the given interface + std::string section_name; +- if (!uci_find_section_by_option("wireless", "wifi-iface", "ifname", iface, section_name)) { ++ if (!cfg_get_prplmesh_wireless_section(iface, section_name)) { + LOG(ERROR) << "Failed to find configuration section for interface " << iface; + return false; + } +@@ -366,9 +435,8 @@ bool bpl_cfg_set_wifi_credentials(const + // Find the "wireless.wifi-iface" section in UCI configuration for the given interface + const std::string package_name = "wireless"; + const std::string section_type = "wifi-iface"; +- const std::string option_name = "ifname"; + std::string section_name; +- if (!uci_find_section_by_option(package_name, section_type, option_name, iface, section_name)) { ++ if (!cfg_get_prplmesh_wireless_section(iface, section_name)) { + LOG(ERROR) << "Failed to find configuration section for interface " << iface; + return false; + } +@@ -382,22 +450,8 @@ bool bpl_cfg_set_wifi_credentials(const + OptionsUnorderedMap options; + options["ssid"] = configuration.ssid; + +- auto get_encryption = [](WSC::eWscAuth authentication_type, WSC::eWscEncr encryption_type) { +- std::string encryption = "none"; +- if (authentication_type == WSC::eWscAuth::WSC_AUTH_WPA2PSK) { +- encryption = "psk2"; +- if (encryption_type == WSC::eWscEncr::WSC_ENCR_TKIP) { +- encryption += "+tkip"; +- } else if (encryption_type == WSC::eWscEncr::WSC_ENCR_AES) { +- encryption += "+aes"; +- } +- } else if (authentication_type == WSC::eWscAuth::WSC_AUTH_SAE) { +- encryption = "sae"; +- } +- return encryption; +- }; +- options["encryption"] = +- get_encryption(configuration.authentication_type, configuration.encryption_type); ++ options["encryption"] = wifi_cfg_utils::encryption_to_uci(configuration.authentication_type, ++ configuration.encryption_type); + + options["key"] = configuration.network_key; + +@@ -476,18 +530,17 @@ void cfg_wifi_reset_wps_credentials() { + + int cfg_get_hostap_iface(int32_t radio_num, std::string &hostap_iface) + { +- if (!hostap_iface) { +- MAPF_ERR("cfg_get_hostap_iface: invalid input: hostap_iface is NULL"); +- return RETURN_ERR; +- } +- + if (radio_num < 0) { + MAPF_ERR("cfg_get_hostap_iface: invalid input: radio_num < 0"); + return RETURN_ERR; + } + +- char iface_c_str[IFNAMSIZ]; +- auto result = cfg_get_prplmesh_radio_param(radio_num, "hostap_iface", c_iface, IFNAMSIZ); ++ char iface_c_str[IFNAMSIZ] = {0}; ++ auto result = cfg_get_prplmesh_radio_param(radio_num, "hostap_iface", iface_c_str, IFNAMSIZ); ++ if (result != RETURN_OK) { ++ hostap_iface.clear(); ++ return result; ++ } + hostap_iface = std::string(iface_c_str); + return result; + } +--- /dev/null ++++ b/framework/platform/bpl/cfg/uci/bpl_cfg_wifi_utils.h +@@ -0,0 +1,139 @@ ++/* SPDX-License-Identifier: BSD-2-Clause-Patent */ ++ ++#ifndef BPL_CFG_WIFI_UTILS_H_ ++#define BPL_CFG_WIFI_UTILS_H_ ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include ++ ++namespace beerocks { ++namespace bpl { ++namespace wifi_cfg_utils { ++ ++inline bool starts_with(const std::string &value, const std::string &prefix) ++{ ++ return value.compare(0, prefix.size(), prefix) == 0; ++} ++ ++inline bool contains(const std::string &value, const std::string &substring) ++{ ++ return value.find(substring) != std::string::npos; ++} ++ ++inline WSC::eWscAuth authentication_from_uci(const std::string &encryption) ++{ ++ if (encryption.empty() || encryption == "none") { ++ return WSC::eWscAuth::WSC_AUTH_OPEN; ++ } ++ if (starts_with(encryption, "sae-mixed") || starts_with(encryption, "psk2+sae")) { ++ return WSC::eWscAuth(WSC::eWscAuth::WSC_AUTH_WPA2PSK | WSC::eWscAuth::WSC_AUTH_SAE); ++ } ++ if (starts_with(encryption, "psk2")) { ++ return WSC::eWscAuth::WSC_AUTH_WPA2PSK; ++ } ++ if (starts_with(encryption, "sae")) { ++ return WSC::eWscAuth::WSC_AUTH_SAE; ++ } ++ return WSC::eWscAuth::WSC_AUTH_INVALID; ++} ++ ++inline WSC::eWscEncr encryption_from_uci(const std::string &encryption) ++{ ++ if (encryption.empty() || encryption == "none") { ++ return WSC::eWscEncr::WSC_ENCR_NONE; ++ } ++ if (contains(encryption, "+tkip")) { ++ return WSC::eWscEncr::WSC_ENCR_TKIP; ++ } ++ if (starts_with(encryption, "psk2") || starts_with(encryption, "sae") || ++ contains(encryption, "+aes") || contains(encryption, "+ccmp")) { ++ return WSC::eWscEncr::WSC_ENCR_AES; ++ } ++ return WSC::eWscEncr::WSC_ENCR_INVALID; ++} ++ ++inline std::string encryption_to_uci(WSC::eWscAuth authentication, WSC::eWscEncr encryption) ++{ ++ const auto auth_bits = static_cast(authentication); ++ if ((auth_bits & WSC::eWscAuth::WSC_AUTH_WPA2PSK) && ++ (auth_bits & WSC::eWscAuth::WSC_AUTH_SAE)) { ++ return "sae-mixed"; ++ } ++ if (authentication == WSC::eWscAuth::WSC_AUTH_SAE) { ++ return "sae"; ++ } ++ if (authentication == WSC::eWscAuth::WSC_AUTH_WPA2PSK) { ++ if (encryption == WSC::eWscEncr::WSC_ENCR_TKIP) { ++ return "psk2+tkip"; ++ } ++ return "psk2+aes"; ++ } ++ return "none"; ++} ++ ++inline bool append_operating_classes(const std::string &band_or_hwmode, ++ std::list &operating_classes) ++{ ++ std::string value = band_or_hwmode; ++ std::transform(value.begin(), value.end(), value.begin(), ++ [](unsigned char c) { return static_cast(std::tolower(c)); }); ++ ++ std::string band; ++ if (value == "2g" || value == "2.4g" || value == "24g" || value.empty() || value == "11b" || ++ value == "11g" || value == "11bgnax") { ++ band = "24g"; ++ } else if (value == "5g" || value == "5ghz" || value == "11a" || value == "11anacax") { ++ band = "5g"; ++ } else if (value == "6g" || value == "6ghz") { ++ band = "6g"; ++ } else { ++ return false; ++ } ++ ++ auto classes = son::wireless_utils::string_to_wsc_oper_class(band); ++ operating_classes.splice(operating_classes.end(), classes); ++ operating_classes.sort(); ++ operating_classes.unique(); ++ return true; ++} ++ ++inline bool parse_bool(const std::string &value, bool default_value) ++{ ++ if (value.empty()) { ++ return default_value; ++ } ++ std::string normalized = value; ++ std::transform(normalized.begin(), normalized.end(), normalized.begin(), ++ [](unsigned char c) { return static_cast(std::tolower(c)); }); ++ if (normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on") { ++ return true; ++ } ++ if (normalized == "0" || normalized == "false" || normalized == "no" || normalized == "off") { ++ return false; ++ } ++ return default_value; ++} ++ ++inline bool select_unique_wireless_section(const std::vector &candidates, ++ std::string §ion_name) ++{ ++ section_name.clear(); ++ if (candidates.size() != 1 || candidates.front().empty()) { ++ return false; ++ } ++ section_name = candidates.front(); ++ return true; ++} ++ ++} // namespace wifi_cfg_utils ++} // namespace bpl ++} // namespace beerocks ++ ++#endif // BPL_CFG_WIFI_UTILS_H_ +--- a/framework/platform/bpl/test/CMakeLists.txt ++++ b/framework/platform/bpl/test/CMakeLists.txt +@@ -5,3 +5,11 @@ target_include_directories(bpl_test PUBL + $ + $ + ) ++ ++add_executable(bpl_cfg_wifi_utils_test bpl_cfg_wifi_utils_test.cpp) ++target_link_libraries(bpl_cfg_wifi_utils_test gtest_main bcl) ++target_include_directories(bpl_cfg_wifi_utils_test PRIVATE ++ $ ++ $ ++) ++add_test(NAME bpl_cfg_wifi_utils_test COMMAND $) +--- /dev/null ++++ b/framework/platform/bpl/test/bpl_cfg_wifi_utils_test.cpp +@@ -0,0 +1,66 @@ ++/* SPDX-License-Identifier: BSD-2-Clause-Patent */ ++ ++#include "../cfg/uci/bpl_cfg_wifi_utils.h" ++ ++#include ++ ++using namespace beerocks::bpl::wifi_cfg_utils; ++ ++TEST(BplCfgWifiUtils, MapsStockOpenWrtSecurityModes) ++{ ++ EXPECT_EQ(WSC::eWscAuth::WSC_AUTH_WPA2PSK, authentication_from_uci("psk2")); ++ EXPECT_EQ(WSC::eWscAuth::WSC_AUTH_SAE, authentication_from_uci("sae")); ++ const auto transition = authentication_from_uci("sae-mixed"); ++ EXPECT_NE(0, static_cast(transition) & WSC::eWscAuth::WSC_AUTH_WPA2PSK); ++ EXPECT_NE(0, static_cast(transition) & WSC::eWscAuth::WSC_AUTH_SAE); ++ EXPECT_EQ(WSC::eWscEncr::WSC_ENCR_AES, encryption_from_uci("sae-mixed")); ++} ++ ++TEST(BplCfgWifiUtils, MapsSecurityBackToUci) ++{ ++ const auto transition = ++ static_cast(WSC::eWscAuth::WSC_AUTH_WPA2PSK | WSC::eWscAuth::WSC_AUTH_SAE); ++ EXPECT_EQ("psk2+aes", ++ encryption_to_uci(WSC::eWscAuth::WSC_AUTH_WPA2PSK, WSC::eWscEncr::WSC_ENCR_AES)); ++ EXPECT_EQ("sae", encryption_to_uci(WSC::eWscAuth::WSC_AUTH_SAE, WSC::eWscEncr::WSC_ENCR_AES)); ++ EXPECT_EQ("sae-mixed", encryption_to_uci(transition, WSC::eWscEncr::WSC_ENCR_AES)); ++} ++ ++TEST(BplCfgWifiUtils, MergesTwoFourFiveAndSixGhzOperatingClasses) ++{ ++ std::list classes; ++ EXPECT_TRUE(append_operating_classes("2g", classes)); ++ EXPECT_TRUE(append_operating_classes("5g", classes)); ++ EXPECT_TRUE(append_operating_classes("6g", classes)); ++ EXPECT_NE(classes.end(), std::find(classes.begin(), classes.end(), 81)); ++ EXPECT_NE(classes.end(), std::find(classes.begin(), classes.end(), 115)); ++ EXPECT_NE(classes.end(), std::find(classes.begin(), classes.end(), 131)); ++ ++ const auto before = classes; ++ EXPECT_FALSE(append_operating_classes("invalid-band", classes)); ++ EXPECT_EQ(before, classes); ++} ++ ++TEST(BplCfgWifiUtils, ParsesExplicitBooleansWithSafeDefault) ++{ ++ EXPECT_TRUE(parse_bool("yes", false)); ++ EXPECT_FALSE(parse_bool("off", true)); ++ EXPECT_TRUE(parse_bool("invalid", true)); ++} ++ ++TEST(BplCfgWifiUtils, SelectsOnlyAnUnambiguousWirelessSection) ++{ ++ const std::vector none; ++ const std::vector one{"default_radio0"}; ++ const std::vector two{"default_radio0", "guest_radio0"}; ++ std::string selected; ++ ++ EXPECT_FALSE(select_unique_wireless_section(none, selected)); ++ EXPECT_TRUE(selected.empty()); ++ ++ EXPECT_TRUE(select_unique_wireless_section(one, selected)); ++ EXPECT_EQ("default_radio0", selected); ++ ++ EXPECT_FALSE(select_unique_wireless_section(two, selected)); ++ EXPECT_TRUE(selected.empty()); ++} +--- a/framework/tlvf/src/include/tlvf/AssociationRequestFrame/assoc_frame_bitfields.h ++++ b/framework/tlvf/src/include/tlvf/AssociationRequestFrame/assoc_frame_bitfields.h +@@ -4,6 +4,7 @@ + + #include + #include ++#include + #include + + namespace assoc_frame { diff --git a/net/prplmesh/patches/120-bwl-bundled-wpa-ctrl-client.patch b/net/prplmesh/patches/120-bwl-bundled-wpa-ctrl-client.patch new file mode 100644 index 0000000000..d10875bccf --- /dev/null +++ b/net/prplmesh/patches/120-bwl-bundled-wpa-ctrl-client.patch @@ -0,0 +1,462 @@ +From 918ec01a76995deca767c5bc98e32de3573b1385 Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Sun, 26 Jul 2026 21:23:07 +0300 +Subject: [PATCH] bwl: add bundled minimal wpa_ctrl client + +The NL80211 flavor compiles wpa_ctrl.c and os_unix.c straight out of a +hostapd source tree, so every integration has to carry a hostapd +checkout at build time even when the target only talks to the control +sockets of an already installed hostapd. + +Add a small self-contained implementation of the documented control +interface protocol with the same API and semantics as hostap's +wpa_ctrl.h (solicited replies with a 10 s timeout and -2 on expiry, +event filtering while waiting, ATTACH/DETACH, pending/recv/get_fd), and +a BWL_BUNDLED_WPA_CTRL option (default OFF) that builds it instead of +the hostapd sources. The client socket is created group accessible for +user/group network so a hostapd jailed the way OpenWrt does it can +reply, which is what the OpenWrt tree patches into wpa_ctrl.c. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + common/beerocks/bwl/CMakeLists.txt | 64 +++-- + .../bwl/nl80211/wpa_ctrl_compat/wpa_ctrl.h | 68 +++++ + .../nl80211/wpa_ctrl_compat/wpa_ctrl_compat.c | 272 ++++++++++++++++++ + 3 files changed, 383 insertions(+), 21 deletions(-) + create mode 100644 common/beerocks/bwl/nl80211/wpa_ctrl_compat/wpa_ctrl.h + create mode 100644 common/beerocks/bwl/nl80211/wpa_ctrl_compat/wpa_ctrl_compat.c + +--- a/common/beerocks/bwl/CMakeLists.txt ++++ b/common/beerocks/bwl/CMakeLists.txt +@@ -115,34 +115,56 @@ elseif(BWL_TYPE STREQUAL "DWPAL") + + elseif(BWL_TYPE STREQUAL "NL80211") + +- file(GLOB HOSTAPD_SEARCH_PATHS "${PLATFORM_BUILD_DIR}/hostapd*/hostapd-*") +- find_path(HOSTAPD_INCLUDE_DIR NAMES "src/common/wpa_ctrl.h" PATHS ${HOSTAPD_SEARCH_PATHS} NO_CMAKE_FIND_ROOT_PATH) +- set(HOSTAPD_DIR "${HOSTAPD_INCLUDE_DIR}") ++ option(BWL_BUNDLED_WPA_CTRL ++ "Use the bundled minimal hostapd control interface client instead of a hostapd source tree" ++ OFF) + + find_package(nl-genl-3 REQUIRED) + list(APPEND BWL_LIBS nl-genl-3) + +- file(GLOB bwl_platform_sources +- ${MODULE_PATH}/shared/*.c* +- ${MODULE_PATH}/nl80211/*.c* +- +- # WPA Control Interface +- ${HOSTAPD_DIR}/src/common/wpa_ctrl.c +- ${HOSTAPD_DIR}/src/utils/os_unix.c +- ) +- +- # Enable WPA control interface +- add_definitions( +- -DCONFIG_CTRL_IFACE +- -DCONFIG_CTRL_IFACE_UNIX +- ) +- +- # Hostapd/NL80211 include directories +- include_directories( +- ${HOSTAPD_DIR}/src/utils +- ${HOSTAPD_DIR}/src/common +- ${HOSTAPD_DIR}/src/drivers +- ) ++ if(BWL_BUNDLED_WPA_CTRL) ++ ++ file(GLOB bwl_platform_sources ++ ${MODULE_PATH}/shared/*.c* ++ ${MODULE_PATH}/nl80211/*.c* ++ ++ # WPA Control Interface (bundled minimal client) ++ ${MODULE_PATH}/nl80211/wpa_ctrl_compat/wpa_ctrl_compat.c ++ ) ++ ++ include_directories( ++ ${MODULE_PATH}/nl80211/wpa_ctrl_compat ++ ) ++ ++ else() ++ ++ file(GLOB HOSTAPD_SEARCH_PATHS "${PLATFORM_BUILD_DIR}/hostapd*/hostapd-*") ++ find_path(HOSTAPD_INCLUDE_DIR NAMES "src/common/wpa_ctrl.h" PATHS ${HOSTAPD_SEARCH_PATHS} NO_CMAKE_FIND_ROOT_PATH) ++ set(HOSTAPD_DIR "${HOSTAPD_INCLUDE_DIR}") ++ ++ file(GLOB bwl_platform_sources ++ ${MODULE_PATH}/shared/*.c* ++ ${MODULE_PATH}/nl80211/*.c* ++ ++ # WPA Control Interface ++ ${HOSTAPD_DIR}/src/common/wpa_ctrl.c ++ ${HOSTAPD_DIR}/src/utils/os_unix.c ++ ) ++ ++ # Enable WPA control interface ++ add_definitions( ++ -DCONFIG_CTRL_IFACE ++ -DCONFIG_CTRL_IFACE_UNIX ++ ) ++ ++ # Hostapd/NL80211 include directories ++ include_directories( ++ ${HOSTAPD_DIR}/src/utils ++ ${HOSTAPD_DIR}/src/common ++ ${HOSTAPD_DIR}/src/drivers ++ ) ++ ++ endif() + + # Platform libraries + link_directories( +--- /dev/null ++++ b/common/beerocks/bwl/nl80211/wpa_ctrl_compat/wpa_ctrl.h +@@ -0,0 +1,68 @@ ++/* SPDX-License-Identifier: BSD-2-Clause-Patent ++ * ++ * SPDX-FileCopyrightText: 2026 the prplMesh contributors (see AUTHORS.md) ++ * ++ * This code is subject to the terms of the BSD+Patent license. ++ * See LICENSE file for more details. ++ */ ++ ++/* ++ * Minimal client for the hostapd / wpa_supplicant control interface. ++ * ++ * Self-contained implementation of the documented control interface ++ * protocol (https://w1.fi/wpa_supplicant/devel/ctrl_iface_page.html) with ++ * the same API as hostap's wpa_ctrl.h, so BWL can talk to the control ++ * sockets of an already installed hostapd without needing a hostapd ++ * source tree at build time. Only UNIX datagram sockets are supported. ++ */ ++ ++#ifndef BWL_WPA_CTRL_COMPAT_H_ ++#define BWL_WPA_CTRL_COMPAT_H_ ++ ++#include ++ ++#ifdef __cplusplus ++extern "C" { ++#endif ++ ++struct wpa_ctrl; ++ ++/* Open a connection to the daemon control socket at @ctrl_path. ++ * Returns NULL on failure. */ ++struct wpa_ctrl *wpa_ctrl_open(const char *ctrl_path); ++ ++/* Same as wpa_ctrl_open(), but create the local client socket inside ++ * @cli_path instead of /tmp. */ ++struct wpa_ctrl *wpa_ctrl_open2(const char *ctrl_path, const char *cli_path); ++ ++/* Close the connection and remove the local client socket. */ ++void wpa_ctrl_close(struct wpa_ctrl *ctrl); ++ ++/* Send @cmd and wait for the solicited reply. Unsolicited event messages ++ * arriving while waiting are passed to @msg_cb when given and dropped ++ * otherwise. On entry *reply_len holds the size of @reply; on success it ++ * is set to the reply size (the reply is not NUL terminated). Returns 0 ++ * on success, -1 on error and -2 on timeout. */ ++int wpa_ctrl_request(struct wpa_ctrl *ctrl, const char *cmd, size_t cmd_len, char *reply, ++ size_t *reply_len, void (*msg_cb)(char *msg, size_t len)); ++ ++/* Register (unregister) this connection as an event monitor. ++ * Returns 0 on success, -1 on failure and -2 on timeout. */ ++int wpa_ctrl_attach(struct wpa_ctrl *ctrl); ++int wpa_ctrl_detach(struct wpa_ctrl *ctrl); ++ ++/* Receive one pending message. Sizes behave as in wpa_ctrl_request(). ++ * Returns 0 on success and -1 on error. */ ++int wpa_ctrl_recv(struct wpa_ctrl *ctrl, char *reply, size_t *reply_len); ++ ++/* Return 1 when a message is waiting, 0 when not, -1 on error. */ ++int wpa_ctrl_pending(struct wpa_ctrl *ctrl); ++ ++/* File descriptor of the underlying socket for select()/poll() loops. */ ++int wpa_ctrl_get_fd(struct wpa_ctrl *ctrl); ++ ++#ifdef __cplusplus ++} ++#endif ++ ++#endif /* BWL_WPA_CTRL_COMPAT_H_ */ +--- /dev/null ++++ b/common/beerocks/bwl/nl80211/wpa_ctrl_compat/wpa_ctrl_compat.c +@@ -0,0 +1,272 @@ ++/* SPDX-License-Identifier: BSD-2-Clause-Patent ++ * ++ * SPDX-FileCopyrightText: 2026 the prplMesh contributors (see AUTHORS.md) ++ * ++ * This code is subject to the terms of the BSD+Patent license. ++ * See LICENSE file for more details. ++ */ ++ ++#include "wpa_ctrl.h" ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++/* Directory for the local (client) end of the connection. The daemon only ++ * replies to a bound datagram socket, so every connection creates one. */ ++#define WPA_CTRL_CLIENT_DIR "/tmp" ++ ++/* How long wpa_ctrl_request() waits for a solicited reply. Same value as ++ * the reference client so BWL retry counting keeps its meaning. */ ++#define WPA_CTRL_REPLY_TIMEOUT_SEC 10 ++ ++/* OpenWrt runs hostapd inside a procd jail as user/group "network" while ++ * BWL usually runs as root. The daemon must be able to send datagrams to ++ * our client socket, so the socket node is made group accessible and ++ * handed over to that account (statically allocated as 101 on OpenWrt, ++ * resolved by name when possible). */ ++#define WPA_CTRL_DAEMON_FALLBACK_ID 101 ++ ++struct wpa_ctrl { ++ int s; ++ struct sockaddr_un local; ++ struct sockaddr_un dest; ++}; ++ ++static void wpa_ctrl_grant_daemon_access(const char *path) ++{ ++ struct group *grp = getgrnam("network"); ++ struct passwd *pwd = getpwnam("network"); ++ gid_t gid = grp ? grp->gr_gid : (gid_t)WPA_CTRL_DAEMON_FALLBACK_ID; ++ uid_t uid = pwd ? pwd->pw_uid : (uid_t)WPA_CTRL_DAEMON_FALLBACK_ID; ++ ++ /* Group first so restricted processes still hand over what they can, ++ * then full ownership; each step is best effort by design - even when ++ * both fail the socket keeps working for a root daemon. */ ++ if (lchown(path, (uid_t)-1, gid) != 0) { ++ /* best effort by design */ ++ } ++ if (lchown(path, uid, gid) != 0) { ++ /* best effort by design */ ++ } ++} ++ ++struct wpa_ctrl *wpa_ctrl_open2(const char *ctrl_path, const char *cli_path) ++{ ++ struct wpa_ctrl *ctrl; ++ static unsigned int counter = 0; ++ unsigned int tries = 0; ++ size_t dest_len; ++ int flags; ++ int written; ++ ++ if (!ctrl_path) { ++ return NULL; ++ } ++ if (!cli_path) { ++ cli_path = WPA_CTRL_CLIENT_DIR; ++ } ++ ++ ctrl = calloc(1, sizeof(*ctrl)); ++ if (!ctrl) { ++ return NULL; ++ } ++ ++ ctrl->s = socket(PF_UNIX, SOCK_DGRAM, 0); ++ if (ctrl->s < 0) { ++ free(ctrl); ++ return NULL; ++ } ++ ++ /* Set client socket file permissions so that bind() creates the client ++ * socket with these permissions and there is no need to try to change ++ * them with chmod() after bind() which would have potential issues with ++ * race conditions. These permissions are needed to make sure the jailed ++ * hostapd on OpenWrt can send replies to this socket. */ ++ if (fchmod(ctrl->s, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP) != 0) { ++ /* best effort by design */ ++ } ++ ++ ctrl->local.sun_family = AF_UNIX; ++ ++try_again: ++ written = snprintf(ctrl->local.sun_path, sizeof(ctrl->local.sun_path), "%s/wpa_ctrl_%d-%u", ++ cli_path, (int)getpid(), counter); ++ if (written < 0 || (size_t)written >= sizeof(ctrl->local.sun_path)) { ++ close(ctrl->s); ++ free(ctrl); ++ return NULL; ++ } ++ tries++; ++ if (bind(ctrl->s, (struct sockaddr *)&ctrl->local, sizeof(ctrl->local)) < 0) { ++ if (errno == EADDRINUSE && tries < 2) { ++ /* A previous process that had our PID left the node behind; ++ * reclaim the name. */ ++ unlink(ctrl->local.sun_path); ++ goto try_again; ++ } ++ if (tries < 5) { ++ counter++; ++ goto try_again; ++ } ++ close(ctrl->s); ++ free(ctrl); ++ return NULL; ++ } ++ counter++; ++ ++ wpa_ctrl_grant_daemon_access(ctrl->local.sun_path); ++ ++ dest_len = strlen(ctrl_path); ++ if (dest_len >= sizeof(ctrl->dest.sun_path)) { ++ goto fail; ++ } ++ ctrl->dest.sun_family = AF_UNIX; ++ memcpy(ctrl->dest.sun_path, ctrl_path, dest_len + 1); ++ if (connect(ctrl->s, (struct sockaddr *)&ctrl->dest, sizeof(ctrl->dest)) < 0) { ++ goto fail; ++ } ++ ++ /* The connection is never meant for processes BWL spawns. */ ++ flags = fcntl(ctrl->s, F_GETFD); ++ if (flags >= 0) { ++ (void)fcntl(ctrl->s, F_SETFD, flags | FD_CLOEXEC); ++ } ++ ++ return ctrl; ++ ++fail: ++ unlink(ctrl->local.sun_path); ++ close(ctrl->s); ++ free(ctrl); ++ return NULL; ++} ++ ++struct wpa_ctrl *wpa_ctrl_open(const char *ctrl_path) { return wpa_ctrl_open2(ctrl_path, NULL); } ++ ++void wpa_ctrl_close(struct wpa_ctrl *ctrl) ++{ ++ if (!ctrl) { ++ return; ++ } ++ unlink(ctrl->local.sun_path); ++ close(ctrl->s); ++ free(ctrl); ++} ++ ++int wpa_ctrl_request(struct wpa_ctrl *ctrl, const char *cmd, size_t cmd_len, char *reply, ++ size_t *reply_len, void (*msg_cb)(char *msg, size_t len)) ++{ ++ struct timeval tv; ++ fd_set rfds; ++ ssize_t res; ++ ++ if (!ctrl || !cmd || !reply || !reply_len || !*reply_len) { ++ return -1; ++ } ++ ++ if (send(ctrl->s, cmd, cmd_len, 0) < 0) { ++ return -1; ++ } ++ ++ for (;;) { ++ tv.tv_sec = WPA_CTRL_REPLY_TIMEOUT_SEC; ++ tv.tv_usec = 0; ++ FD_ZERO(&rfds); ++ FD_SET(ctrl->s, &rfds); ++ int sres = select(ctrl->s + 1, &rfds, NULL, NULL, &tv); ++ if (sres < 0) { ++ if (errno == EINTR) { ++ continue; ++ } ++ return -1; ++ } ++ if (!FD_ISSET(ctrl->s, &rfds)) { ++ return -2; ++ } ++ ++ res = recv(ctrl->s, reply, *reply_len, 0); ++ if (res < 0) { ++ if (errno == EINTR) { ++ continue; ++ } ++ return -1; ++ } ++ if (res > 0 && ++ (reply[0] == '<' || ((size_t)res > 7 && strncmp(reply, "IFNAME=", 7) == 0))) { ++ /* Unsolicited event, not the reply we are waiting for. */ ++ if (msg_cb) { ++ size_t event_len = (size_t)res; ++ if (event_len == *reply_len) { ++ event_len--; ++ } ++ reply[event_len] = '\0'; ++ msg_cb(reply, event_len); ++ } ++ continue; ++ } ++ *reply_len = (size_t)res; ++ return 0; ++ } ++} ++ ++static int wpa_ctrl_attach_helper(struct wpa_ctrl *ctrl, int attach) ++{ ++ char reply[10]; ++ size_t reply_len = sizeof(reply); ++ int ret; ++ ++ ret = wpa_ctrl_request(ctrl, attach ? "ATTACH" : "DETACH", 6, reply, &reply_len, NULL); ++ if (ret) { ++ return ret; ++ } ++ return (reply_len == 3 && memcmp(reply, "OK\n", 3) == 0) ? 0 : -1; ++} ++ ++int wpa_ctrl_attach(struct wpa_ctrl *ctrl) { return wpa_ctrl_attach_helper(ctrl, 1); } ++ ++int wpa_ctrl_detach(struct wpa_ctrl *ctrl) { return wpa_ctrl_attach_helper(ctrl, 0); } ++ ++int wpa_ctrl_recv(struct wpa_ctrl *ctrl, char *reply, size_t *reply_len) ++{ ++ ssize_t res; ++ ++ if (!ctrl || !reply || !reply_len) { ++ return -1; ++ } ++ res = recv(ctrl->s, reply, *reply_len, 0); ++ if (res < 0) { ++ return -1; ++ } ++ *reply_len = (size_t)res; ++ return 0; ++} ++ ++int wpa_ctrl_pending(struct wpa_ctrl *ctrl) ++{ ++ struct timeval tv = {0, 0}; ++ fd_set rfds; ++ ++ if (!ctrl) { ++ return -1; ++ } ++ FD_ZERO(&rfds); ++ FD_SET(ctrl->s, &rfds); ++ if (select(ctrl->s + 1, &rfds, NULL, NULL, &tv) < 0) { ++ return -1; ++ } ++ return FD_ISSET(ctrl->s, &rfds) ? 1 : 0; ++} ++ ++int wpa_ctrl_get_fd(struct wpa_ctrl *ctrl) { return ctrl ? ctrl->s : -1; } diff --git a/net/prplmesh/patches/130-skip-op-class-dm-cleanup-without-nbapi.patch b/net/prplmesh/patches/130-skip-op-class-dm-cleanup-without-nbapi.patch new file mode 100644 index 0000000000..02553df952 --- /dev/null +++ b/net/prplmesh/patches/130-skip-op-class-dm-cleanup-without-nbapi.patch @@ -0,0 +1,37 @@ +From 178d8d572457c4347f04fe106570781e3901fe0c Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Tue, 28 Jul 2026 22:45:00 +0300 +Subject: [PATCH] controller: skip op class DM cleanup without a data model + +Stock OpenWrt builds prplMesh without NBAPI, leaving radio data model +paths empty. The operating channel report handler already treats that +state as valid, but the follow-up cleanup still tries to update five +empty CurrentOperatingClassProfile paths per radio. This floods the +controller log even though the operational channel is updated. + +Mirror the existing guard in handle_current_op_class and skip the +data-model-only cleanup when the radio has no data model path. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + controller/src/beerocks/master/db/db.cpp | 4 ++++ + 1 file changed, 4 insertions(+) + +--- a/controller/src/beerocks/master/db/db.cpp ++++ b/controller/src/beerocks/master/db/db.cpp +@@ -7087,6 +7087,10 @@ bool db::dm_clear_empty_current_op_class + return false; + } + ++ if (radio->dm_path.empty()) { ++ return true; ++ } ++ + for (int i = 0; i < Agent::operatingClassProfileIndex::OPERATING_CLASS_MAX_BW; i++) { + if (radio->current_operating_class_profile[i].op_class == 0) { + set_dm_current_op_class(radio_mac, i, 0, 0, 0); diff --git a/net/prplmesh/patches/140-register-foreign-radios-from-topology.patch b/net/prplmesh/patches/140-register-foreign-radios-from-topology.patch new file mode 100644 index 0000000000..b81cee3709 --- /dev/null +++ b/net/prplmesh/patches/140-register-foreign-radios-from-topology.patch @@ -0,0 +1,83 @@ +From 678ee6b029f3e7a5e90363cb58e79412d899fdac Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Wed, 29 Jul 2026 09:40:29 +0300 +Subject: [PATCH] controller: register foreign radios from Topology Response + +Some stock agents run WSC onboarding only for a subset of their radios: +Mercusys Wi-Fi 7 units, for example, never send an AP-Autoconfiguration +Search (and therefore no WSC M1) for their 6 GHz radio, while still +reporting it in the AP Operational BSS TLV, Operating Channel Reports, +AP Metrics Responses and Channel Selection Responses. The controller +database only creates radio nodes in the WSC M1 flow, so every report +about such a radio was dropped and the radio, its BSSes and its clients +never appeared in the topology. + +Register unknown radios from the AP Operational BSS TLV of a Topology +Response instead of discarding them, and let handle_current_op_class +learn the radio band from the first reported operating class for radios +created this way (they start as FREQ_UNKNOWN). + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + controller/src/beerocks/master/db/db.cpp | 12 +++++++++-- + .../beerocks/master/tasks/topology_task.cpp | 21 +++++++++++++++++-- + 2 files changed, 29 insertions(+), 4 deletions(-) + +--- a/controller/src/beerocks/master/db/db.cpp ++++ b/controller/src/beerocks/master/db/db.cpp +@@ -6998,9 +6998,17 @@ bool db::handle_current_op_class(const s + return false; + } + +- if (radio->band != son::wireless_utils::which_freq_op_cls(op_class)) { ++ auto op_class_band = son::wireless_utils::which_freq_op_cls(op_class); ++ if (radio->band == beerocks::FREQ_UNKNOWN) { ++ // Radios registered from a Topology Response (agents that skip WSC ++ // onboarding for a band) start without a band; learn it from the ++ // first reported operating class. ++ LOG(INFO) << "Radio " << radio_mac << " band learned from op class " << int(op_class) ++ << ": " << op_class_band; ++ radio->band = op_class_band; ++ } else if (radio->band != op_class_band) { + LOG(ERROR) << "This should not happen. Radio band changed from " << radio->band << " to " +- << son::wireless_utils::which_freq_op_cls(op_class); ++ << op_class_band; + return false; + } + +--- a/controller/src/beerocks/master/tasks/topology_task.cpp ++++ b/controller/src/beerocks/master/tasks/topology_task.cpp +@@ -303,9 +303,26 @@ bool topology_task::handle_topology_resp + // Update BSSes in the Agent + auto radio = database.get_radio(src_mac, radio_entry.radio_uid()); + if (!radio) { ++ // Some stock agents never run WSC onboarding for one of their ++ // radios (e.g. 6 GHz on Wi-Fi 7 units), so the radio is absent ++ // from the database and every report about it would be dropped ++ // here. Register it from the Topology Response instead; the ++ // band and channel are learned from the next Operating Channel ++ // Report. + LOG(WARNING) << "OperationalBSS on unknown radio " << radio_entry.radio_uid() +- << " on " << src_mac; +- continue; ++ << " on " << src_mac << ", registering it"; ++ if (!database.add_radio(radio_entry.radio_uid(), src_mac)) { ++ continue; ++ } ++ radio = database.get_radio(src_mac, radio_entry.radio_uid()); ++ if (!radio) { ++ continue; ++ } ++ // The radio carries operational BSSes, so mark it active for ++ // the connection map right away; the WSC onboarding flow will ++ // never do it for radios it does not see. ++ son_actions::set_radio_active(database, tasks, ++ tlvf::mac_to_string(radio_entry.radio_uid()), true); + } + + radio->bsses.keep_new_prepare(); diff --git a/net/prplmesh/patches/150-nl80211-connected-clients-import.patch b/net/prplmesh/patches/150-nl80211-connected-clients-import.patch new file mode 100644 index 0000000000..741719a9db --- /dev/null +++ b/net/prplmesh/patches/150-nl80211-connected-clients-import.patch @@ -0,0 +1,735 @@ +From 50be87526568d93de752bdd220f6ef0095623784 Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Wed, 29 Jul 2026 13:20:19 +0300 +Subject: [PATCH] bwl: import and reconcile connected clients on nl80211 + +The nl80211 flavor left pre/generate_connected_clients_events() as +stubs that report success, so stations already associated when the +agent starts never reach the controller: BML shows only clients that +(re)associate afterwards, keeps stale parents and reports RSSI -127. + +Port the DWPAL station sweep to the nl80211 AP and monitor HALs: walk +every VAP with STA-FIRST/STA-NEXT over the hostapd control socket, +respect the iteration time budget, validate and deduplicate stations, +skip authenticated-but-not-associated entries, survive a station +vanishing mid-iteration (bounded VAP restart), and stop a failed VAP +without wedging the FSM. Station capabilities are parsed from the +hostapd station dump. + +The AP HAL keeps the sweep idempotent: it tracks currently connected +stations from runtime events, emits an association only for new or +moved stations, and reconciles stale clients only for VAPs whose +enumeration reached a clean end-of-list (an empty reply; FAIL past the +restart budget leaves the VAP partially walked). A pre-sweep snapshot, +invalidated by runtime events, prevents a concurrent association from +being undone. The new supports_connected_clients_reconciliation() +capability lets the AP manager re-run the sweep every 60 seconds; HALs +without that guarantee keep the previous run-once behavior. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + .../ap_manager/ap_manager.cpp | 14 + + .../fronthaul_manager/ap_manager/ap_manager.h | 2 + + common/beerocks/bwl/include/bwl/ap_wlan_hal.h | 8 + + .../bwl/nl80211/ap_wlan_hal_nl80211.cpp | 337 +++++++++++++++++- + .../bwl/nl80211/ap_wlan_hal_nl80211.h | 17 + + .../bwl/nl80211/base_wlan_hal_nl80211.cpp | 19 + + .../bwl/nl80211/base_wlan_hal_nl80211.h | 3 + + .../bwl/nl80211/mon_wlan_hal_nl80211.cpp | 129 ++++++- + .../bwl/nl80211/mon_wlan_hal_nl80211.h | 12 + + 9 files changed, 526 insertions(+), 15 deletions(-) + +--- a/agent/src/beerocks/fronthaul_manager/ap_manager/ap_manager.cpp ++++ b/agent/src/beerocks/fronthaul_manager/ap_manager/ap_manager.cpp +@@ -747,6 +747,20 @@ bool ApManager::ap_manager_fsm(bool &con + m_generate_connected_clients_events = !is_finished_all_clients; + } + ++ // Periodic reconciliation: HALs with an idempotent sweep re-run it to ++ // recover association events lost while the control socket was busy ++ // and to clean up stale clients. ++ if (!m_generate_connected_clients_events && ++ ap_wlan_hal->supports_connected_clients_reconciliation() && ++ now > m_next_client_reconciliation_time) { ++ m_next_client_reconciliation_time = now + std::chrono::seconds(60); ++ if (ap_wlan_hal->pre_generate_connected_clients_events()) { ++ m_generate_connected_clients_events = true; ++ } else { ++ LOG(WARNING) << "Failed to prepare connected clients reconciliation"; ++ } ++ } ++ + // Allow clients with expired blocking period timer + allow_expired_clients(); + break; +--- a/agent/src/beerocks/fronthaul_manager/ap_manager/ap_manager.h ++++ b/agent/src/beerocks/fronthaul_manager/ap_manager/ap_manager.h +@@ -300,6 +300,8 @@ private: + bool m_generate_connected_clients_events = false; + std::chrono::steady_clock::time_point m_next_generate_connected_events_time = + std::chrono::steady_clock::time_point::min(); ++ std::chrono::steady_clock::time_point m_next_client_reconciliation_time = ++ std::chrono::steady_clock::now() + std::chrono::seconds(60); + + //Timer for triggering a CSA notification + void start_csa_notification_timer( +--- a/common/beerocks/bwl/include/bwl/ap_wlan_hal.h ++++ b/common/beerocks/bwl/include/bwl/ap_wlan_hal.h +@@ -442,6 +442,14 @@ public: + virtual bool pre_generate_connected_clients_events() = 0; + + /** ++ * @brief Whether generate_connected_clients_events() is idempotent for ++ * this HAL (unchanged clients produce no duplicate events and stale ++ * clients are reported as disconnected), so the AP manager may re-run ++ * it periodically to reconcile missed association events. ++ */ ++ virtual bool supports_connected_clients_reconciliation() const { return false; } ++ ++ /** + * @brief Start WPS PBC procedure on a given VAP + * + * @param iface_name VAP interface on which to start WPS PBC +--- a/common/beerocks/bwl/nl80211/ap_wlan_hal_nl80211.cpp ++++ b/common/beerocks/bwl/nl80211/ap_wlan_hal_nl80211.cpp +@@ -22,6 +22,7 @@ + #include + #include + #include ++#include + #include + + ////////////////////////////////////////////////////////////////////////////// +@@ -1291,18 +1292,344 @@ bool ap_wlan_hal_nl80211::set_disabled_s + return true; + } + ++// IEEE 802.11 HT capability bits needed to interpret hostapd station dumps; ++// the stock build has no hostapd headers to take them from. ++static constexpr uint16_t NL80211_HT_CAP_SUPP_CHANNEL_WIDTH_SET = 0x0002; ++static constexpr uint16_t NL80211_HT_CAP_SMPS_MASK = 0x000C; ++static constexpr uint16_t NL80211_HT_CAP_SHORT_GI20MHZ = 0x0020; ++static constexpr uint16_t NL80211_HT_CAP_SHORT_GI40MHZ = 0x0040; ++ ++static void sta_caps_from_ht(const int *HT_MCS, const std::string &ht_cap_str, ++ beerocks::message::sRadioCapabilities &sta_caps) ++{ ++ sta_caps.ht_bw = beerocks::BANDWIDTH_UNKNOWN; ++ ++ if (ht_cap_str.empty()) { ++ sta_caps.ant_num = 1; ++ return; ++ } ++ ++ uint16_t ht_cap = uint16_t(std::strtoul(ht_cap_str.c_str(), nullptr, 16)); ++ sta_caps.ht_bw = (ht_cap & NL80211_HT_CAP_SUPP_CHANNEL_WIDTH_SET) ? beerocks::BANDWIDTH_40 ++ : beerocks::BANDWIDTH_20; ++ sta_caps.ht_sm_power_save = ((ht_cap & NL80211_HT_CAP_SMPS_MASK) >> 2) & 0x03; ++ sta_caps.ht_low_bw_short_gi = (ht_cap & NL80211_HT_CAP_SHORT_GI20MHZ) != 0; ++ sta_caps.ht_high_bw_short_gi = (ht_cap & NL80211_HT_CAP_SHORT_GI40MHZ) != 0; ++ ++ uint32_t ht_mcs = 0; ++ for (uint8_t i = 0; i < 4; i++) { ++ ht_mcs |= uint32_t(HT_MCS[i]) << (8 * i); ++ } ++ uint32_t mask = 0x80000000; ++ for (uint8_t i = 4; i > 0; i--) { // 4ss ++ for (int8_t j = 7; j >= 0; j--) { // 8 bits ++ if ((ht_mcs & mask) > 0) { ++ sta_caps.ht_ss = i; ++ sta_caps.ant_num = i; ++ sta_caps.ht_mcs = j; ++ return; ++ } ++ mask /= 2; ++ } ++ } ++} ++ ++static void sta_caps_from_vht(const int16_t *VHT_MCS, const std::string &vht_cap_str, ++ beerocks::message::sRadioCapabilities &sta_caps) ++{ ++ sta_caps.vht_bw = beerocks::BANDWIDTH_UNKNOWN; ++ ++ if (!vht_cap_str.empty()) { ++ uint32_t vht_cap = std::strtoul(vht_cap_str.c_str(), nullptr, 16); ++ uint8_t supported_bw_bits = (vht_cap >> 2) & 0x03; ++ ++ sta_caps.vht_bw = ++ (supported_bw_bits == 0) ? beerocks::BANDWIDTH_80 : beerocks::BANDWIDTH_160; ++ sta_caps.vht_low_bw_short_gi = (vht_cap >> 5) & 0x01; ++ sta_caps.vht_high_bw_short_gi = (vht_cap >> 6) & 0x01; ++ ++ uint16_t vht_mcs_rx = uint16_t(VHT_MCS[0]); ++ for (uint8_t i = 4; i > 0; i--) { // 4ss ++ uint16_t vht_mcs_temp = (vht_mcs_rx >> (2 * (i - 1))) & 0x03; ++ if (vht_mcs_temp != 0x3) { // 0x3 == not supported ++ sta_caps.vht_ss = i; ++ sta_caps.ant_num = i; ++ sta_caps.vht_mcs = vht_mcs_temp + 7; ++ break; ++ } ++ } ++ sta_caps.vht_su_beamformer = (vht_cap >> 11) & 0x01; ++ sta_caps.vht_mu_beamformer = (vht_cap >> 19) & 0x01; ++ } else if (sta_caps.ant_num == 0) { ++ sta_caps.ant_num = 1; ++ } ++ ++ if (sta_caps.vht_ss) { ++ sta_caps.wifi_standard = STANDARD_AC; ++ } else if (sta_caps.ht_ss) { ++ sta_caps.wifi_standard = STANDARD_N; ++ } else { ++ sta_caps.wifi_standard = STANDARD_A; ++ } ++} ++ ++static void sta_caps_from_rates(const int *supported_rates, ++ beerocks::message::sRadioCapabilities &sta_caps) ++{ ++ uint16_t max_rate = 0; ++ for (int i = 0; i < 16; i++) { ++ uint16_t temp_rate = (supported_rates[i] & 0x7F) * 5; // rate/2 * 10 ++ if (temp_rate > max_rate) { ++ max_rate = temp_rate; ++ } ++ } ++ son::wireless_utils::get_mcs_from_rate(max_rate, beerocks::ANT_MODE_1X1_SS1, ++ beerocks::BANDWIDTH_20, sta_caps.default_mcs, ++ sta_caps.default_short_gi); ++} ++ ++// Parse one hostapd STA-FIRST/STA-NEXT reply block into an association ++// notification. Returns nullptr on malformed input. A station that has not ++// completed association carries no connected_time line; it is reported ++// through @a associated so the caller can skip it but keep iterating. ++static std::shared_ptr parse_sta_block(const std::string &reply, int vap_id, bool radio_5G, ++ bool &associated, sMacAddr &mac_out) ++{ ++ std::istringstream stream(reply); ++ std::string line; ++ ++ if (!std::getline(stream, line)) { ++ return nullptr; ++ } ++ if (!line.empty() && line.back() == '\r') { ++ line.pop_back(); ++ } ++ if (!looks_like_mac(line)) { ++ return nullptr; ++ } ++ mac_out = tlvf::mac_from_string(line); ++ ++ std::unordered_map options; ++ while (std::getline(stream, line)) { ++ if (!line.empty() && line.back() == '\r') { ++ line.pop_back(); ++ } ++ auto separator = line.find('='); ++ if (separator == std::string::npos) { ++ continue; ++ } ++ options[line.substr(0, separator)] = line.substr(separator + 1); ++ } ++ ++ associated = (options.find("connected_time") != options.end()); ++ ++ auto msg_buff = ALLOC_SMART_BUFFER(sizeof(sACTION_APMANAGER_CLIENT_ASSOCIATED_NOTIFICATION)); ++ auto msg = reinterpret_cast(msg_buff.get()); ++ if (!msg) { ++ LOG(FATAL) << "Memory allocation failed"; ++ return nullptr; ++ } ++ memset(msg_buff.get(), 0, sizeof(sACTION_APMANAGER_CLIENT_ASSOCIATED_NOTIFICATION)); ++ ++ msg->params.vap_id = vap_id; ++ msg->params.mac = mac_out; ++ msg->params.capabilities = {}; ++ ++ int supported_rates[16] = {0}; ++ std::istringstream rates(options["supported_rates"]); ++ std::string rate; ++ for (int i = 0; i < 16 && (rates >> rate); i++) { ++ supported_rates[i] = int(std::strtoul(rate.c_str(), nullptr, 16)); ++ } ++ ++ int HT_MCS[16] = {0}; ++ const auto &ht_mcs_line = options["ht_mcs_bitmask"]; ++ for (size_t i = 0; i < 16 && 2 * i + 1 < ht_mcs_line.length(); i++) { ++ HT_MCS[i] = int(std::strtoul(ht_mcs_line.substr(2 * i, 2).c_str(), nullptr, 16)); ++ } ++ ++ int16_t VHT_MCS[1] = {0}; ++ const auto &vht_mcs_line = options["rx_vht_mcs_map"]; ++ if (vht_mcs_line.length() >= 4) { ++ VHT_MCS[0] = int16_t(std::strtoul(vht_mcs_line.substr(0, 4).c_str(), nullptr, 16)); ++ } ++ ++ msg->params.capabilities.max_tx_power = ++ uint8_t(std::strtoul(options["max_txpower"].c_str(), nullptr, 10)); ++ ++ sta_caps_from_ht(HT_MCS, options["ht_caps_info"], msg->params.capabilities); ++ if (radio_5G) { ++ sta_caps_from_vht(VHT_MCS, options["vht_caps_info"], msg->params.capabilities); ++ } ++ sta_caps_from_rates(supported_rates, msg->params.capabilities); ++ ++ return msg_buff; ++} ++ + bool ap_wlan_hal_nl80211::generate_connected_clients_events( + bool &is_finished_all_clients, std::chrono::steady_clock::time_point max_iteration_timeout) + { +- LOG(TRACE) << __func__ << " - NOT IMPLEMENTED!"; ++ auto next_unhandled_vap = [this]() { ++ for (const auto &vap : m_radio_info.available_vaps) { ++ if (m_completed_vaps.find(vap.first) == m_completed_vaps.end()) { ++ return vap.first; ++ } ++ } ++ return INVALID_VAP_ID; ++ }; ++ ++ is_finished_all_clients = false; ++ ++ if (m_vap_id_in_progress == INVALID_VAP_ID) { ++ m_vap_id_in_progress = next_unhandled_vap(); ++ m_vap_restart_count = 0; ++ } ++ ++ while (m_vap_id_in_progress != INVALID_VAP_ID) { ++ auto vap_it = m_radio_info.available_vaps.find(m_vap_id_in_progress); ++ if (vap_it == m_radio_info.available_vaps.end() || vap_it->second.bss.empty()) { ++ m_completed_vaps.insert(m_vap_id_in_progress); ++ m_vap_id_in_progress = next_unhandled_vap(); ++ continue; ++ } ++ // On stock OpenWrt the BSS name is the real netdev the control ++ // socket is registered under; wlanX.Y spellings do not exist here. ++ auto vap_iface = vap_it->second.bss; ++ ++ bool vap_done = false; ++ bool vap_enumerated = false; ++ while (!vap_done) { ++ if (std::chrono::steady_clock::now() > max_iteration_timeout) { ++ // Out of time budget: resume from the same cursor next wakeup. ++ return true; ++ } ++ ++ std::string cmd = m_queried_first ? "STA-NEXT " + tlvf::mac_to_string(m_prev_client_mac) ++ : "STA-FIRST"; ++ char placeholder = 0; ++ char *reply = &placeholder; ++ if (!wpa_ctrl_send_msg(cmd, &reply, vap_iface) || !reply) { ++ LOG(WARNING) << __func__ << ": '" << cmd << "' failed on " << vap_iface; ++ if (m_queried_first && m_vap_restart_count++ < 2) { ++ // The cursor station may just have disconnected; restart ++ // this VAP - the handled set keeps events deduplicated. ++ m_queried_first = false; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ return true; ++ } ++ // A dead control socket must not wedge the whole FSM. ++ vap_done = true; ++ break; ++ } ++ ++ std::string text(reply); ++ if (text.empty() || text.rfind("FAIL", 0) == 0) { ++ if (!text.empty() && m_queried_first && m_vap_restart_count++ < 2) { ++ // FAIL for a known cursor: the station disappeared ++ // between STA-NEXT calls - restart this VAP once. ++ m_queried_first = false; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ continue; ++ } ++ // Only an empty reply is a clean end-of-list; FAIL past the ++ // restart budget leaves the VAP only partially walked. ++ vap_enumerated = text.empty(); ++ vap_done = true; ++ break; ++ } ++ if (text.rfind("UNKNOWN", 0) == 0) { ++ LOG(WARNING) << "Station enumeration is unsupported on " << vap_iface; ++ vap_done = true; ++ break; ++ } ++ ++ bool associated = false; ++ sMacAddr sta_mac; ++ auto msg_buff = parse_sta_block(text, m_vap_id_in_progress, get_radio_info().is_5ghz, ++ associated, sta_mac); ++ if (!msg_buff) { ++ LOG(WARNING) << "Malformed station block on " << vap_iface ++ << ", skipping the rest of this VAP"; ++ vap_done = true; ++ break; ++ } ++ ++ m_queried_first = true; ++ m_prev_client_mac = sta_mac; ++ ++ if (!m_handled_clients.insert(sta_mac).second) { ++ continue; // seen earlier in this sweep ++ } ++ if (!associated) { ++ continue; // authenticated but not associated ++ } ++ ++ auto known = m_connected_clients.find(sta_mac); ++ if (known != m_connected_clients.end() && known->second == m_vap_id_in_progress) { ++ continue; // unchanged client: no duplicate event ++ } ++ m_connected_clients[sta_mac] = m_vap_id_in_progress; ++ LOG(DEBUG) << "Importing connected client " << sta_mac << " on " << vap_iface; ++ event_queue_push(Event::STA_Connected, msg_buff); ++ } ++ ++ m_completed_vaps.insert(m_vap_id_in_progress); ++ if (vap_enumerated) { ++ m_enumerated_vaps.insert(m_vap_id_in_progress); ++ } ++ m_queried_first = false; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ m_vap_id_in_progress = next_unhandled_vap(); ++ m_vap_restart_count = 0; ++ } ++ ++ // Reconcile only clients that existed before the sweep, on VAPs whose ++ // enumeration reached end-of-list. Runtime events remove their station ++ // from the snapshot, preventing a concurrent reconnect from being undone. ++ for (const auto &client : m_clients_before_sweep) { ++ if (m_enumerated_vaps.find(client.second) == m_enumerated_vaps.end() || ++ m_handled_clients.find(client.first) != m_handled_clients.end()) { ++ continue; ++ } ++ ++ auto current = m_connected_clients.find(client.first); ++ if (current == m_connected_clients.end() || current->second != client.second) { ++ continue; ++ } ++ ++ auto msg_buff = ++ ALLOC_SMART_BUFFER(sizeof(sACTION_APMANAGER_CLIENT_DISCONNECTED_NOTIFICATION)); ++ auto msg = ++ reinterpret_cast(msg_buff.get()); ++ if (!msg) { ++ continue; ++ } ++ memset(msg_buff.get(), 0, sizeof(sACTION_APMANAGER_CLIENT_DISCONNECTED_NOTIFICATION)); ++ msg->params.mac = client.first; ++ msg->params.vap_id = client.second; ++ LOG(DEBUG) << "Client " << client.first ++ << " no longer known to hostapd, reporting stale disconnect"; ++ event_queue_push(Event::STA_Disconnected, msg_buff); ++ m_connected_clients.erase(current); ++ } ++ ++ m_clients_before_sweep.clear(); ++ m_enumerated_vaps.clear(); ++ m_handled_clients.clear(); + is_finished_all_clients = true; + return true; + } + + bool ap_wlan_hal_nl80211::pre_generate_connected_clients_events() + { +- +- LOG(TRACE) << __func__ << " - NOT IMPLEMENTED!"; ++ m_vap_id_in_progress = INVALID_VAP_ID; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ m_queried_first = false; ++ m_vap_restart_count = 0; ++ m_completed_vaps.clear(); ++ m_enumerated_vaps.clear(); ++ m_handled_clients.clear(); ++ m_clients_before_sweep = m_connected_clients; + return true; + } + +@@ -1427,6 +1754,8 @@ bool ap_wlan_hal_nl80211::process_nl8021 + } + + // Add the message to the queue ++ m_connected_clients[msg->params.mac] = vap_id; ++ m_clients_before_sweep.erase(msg->params.mac); + event_queue_push(Event::STA_Connected, msg_buff); + + } break; +@@ -1453,6 +1782,8 @@ bool ap_wlan_hal_nl80211::process_nl8021 + msg->params.vap_id = vap_id; + msg->params.mac = tlvf::mac_from_string(parsed_obj[bwl::EVENT_KEYLESS_PARAM_MAC]); + ++ m_connected_clients.erase(msg->params.mac); ++ m_clients_before_sweep.erase(msg->params.mac); + // Add the message to the queue + event_queue_push(Event::STA_Disconnected, msg_buff); + +--- a/common/beerocks/bwl/nl80211/ap_wlan_hal_nl80211.h ++++ b/common/beerocks/bwl/nl80211/ap_wlan_hal_nl80211.h +@@ -12,6 +12,10 @@ + #include "base_wlan_hal_nl80211.h" + #include + ++#include ++#include ++#include ++ + namespace bwl { + namespace nl80211 { + +@@ -93,6 +97,7 @@ public: + * @see ap_wlan_hal::pre_generate_connected_clients_events + */ + virtual bool pre_generate_connected_clients_events() override; ++ virtual bool supports_connected_clients_reconciliation() const override { return true; } + + virtual bool start_wps_pbc() override; + virtual bool set_mbo_assoc_disallow(const std::string &bssid, bool enable) override; +@@ -140,6 +145,18 @@ protected: + } + + private: ++ // Connected-clients import and reconciliation (STA-FIRST/STA-NEXT sweep) ++ static constexpr int INVALID_VAP_ID = -1; ++ std::set m_completed_vaps; ++ std::set m_enumerated_vaps; ++ std::unordered_set m_handled_clients; ++ std::unordered_map m_connected_clients; ++ std::unordered_map m_clients_before_sweep; ++ sMacAddr m_prev_client_mac = {}; ++ bool m_queried_first = false; ++ int m_vap_id_in_progress = INVALID_VAP_ID; ++ int m_vap_restart_count = 0; ++ + // Unassociated measurement state variables + std::chrono::steady_clock::time_point m_unassoc_measure_start; + int m_unassoc_measure_window_size = 0; +--- a/common/beerocks/bwl/nl80211/base_wlan_hal_nl80211.cpp ++++ b/common/beerocks/bwl/nl80211/base_wlan_hal_nl80211.cpp +@@ -27,9 +27,28 @@ + #include + #include + ++#include ++ + namespace bwl { + namespace nl80211 { + ++bool looks_like_mac(const std::string &text) ++{ ++ if (text.length() != 17) { ++ return false; ++ } ++ for (size_t i = 0; i < text.length(); i++) { ++ if (i % 3 == 2) { ++ if (text[i] != ':') { ++ return false; ++ } ++ } else if (!std::isxdigit(static_cast(text[i]))) { ++ return false; ++ } ++ } ++ return true; ++} ++ + ////////////////////////////////////////////////////////////////////////////// + ///////////////////////// Local Module Definitions /////////////////////////// + ////////////////////////////////////////////////////////////////////////////// +--- a/common/beerocks/bwl/nl80211/base_wlan_hal_nl80211.h ++++ b/common/beerocks/bwl/nl80211/base_wlan_hal_nl80211.h +@@ -34,6 +34,9 @@ enum class nl80211_fsm_event { Attach, D + constexpr char global_iface[] = "global"; + constexpr char base_ctrl_path[] = "/var/run/"; + ++// Validate the canonical colon-separated MAC format returned by hostapd. ++bool looks_like_mac(const std::string &text); ++ + /*! + * Base class for the wav abstraction layer. + * Read more about virtual inheritance: https://en.wikipedia.org/wiki/Virtual_inheritance +--- a/common/beerocks/bwl/nl80211/mon_wlan_hal_nl80211.cpp ++++ b/common/beerocks/bwl/nl80211/mon_wlan_hal_nl80211.cpp +@@ -26,6 +26,7 @@ extern "C" { + #include + #include + #include ++#include + + namespace bwl { + namespace nl80211 { +@@ -457,24 +458,128 @@ bool mon_wlan_hal_nl80211::channel_scan_ + bool mon_wlan_hal_nl80211::generate_connected_clients_events( + bool &is_finished_all_clients, std::chrono::steady_clock::time_point max_iteration_timeout) + { +- LOG(TRACE) << __func__ << " - NOT IMPLEMENTED"; +- is_finished_all_clients = true; ++ auto next_unhandled_vap = [this]() { ++ for (const auto &vap : m_radio_info.available_vaps) { ++ if (m_completed_vaps.find(vap.first) == m_completed_vaps.end()) { ++ return vap.first; ++ } ++ } ++ return INVALID_VAP_ID; ++ }; ++ ++ is_finished_all_clients = false; ++ ++ if (m_vap_id_in_progress == INVALID_VAP_ID) { ++ m_vap_id_in_progress = next_unhandled_vap(); ++ m_vap_restart_count = 0; ++ } ++ ++ while (m_vap_id_in_progress != INVALID_VAP_ID) { ++ auto vap_it = m_radio_info.available_vaps.find(m_vap_id_in_progress); ++ if (vap_it == m_radio_info.available_vaps.end() || vap_it->second.bss.empty()) { ++ m_completed_vaps.insert(m_vap_id_in_progress); ++ m_vap_id_in_progress = next_unhandled_vap(); ++ continue; ++ } ++ // On stock OpenWrt the BSS name is the real netdev the control ++ // socket is registered under; wlanX.Y spellings do not exist here. ++ auto vap_iface = vap_it->second.bss; ++ ++ bool vap_done = false; ++ while (!vap_done) { ++ if (std::chrono::steady_clock::now() > max_iteration_timeout) { ++ // Out of time budget: resume from the same cursor next wakeup. ++ return true; ++ } ++ ++ std::string cmd = m_queried_first ? "STA-NEXT " + tlvf::mac_to_string(m_prev_client_mac) ++ : "STA-FIRST"; ++ char placeholder = 0; ++ char *reply = &placeholder; ++ if (!wpa_ctrl_send_msg(cmd, &reply, vap_iface) || !reply) { ++ LOG(WARNING) << __func__ << ": '" << cmd << "' failed on " << vap_iface; ++ if (m_queried_first && m_vap_restart_count++ < 2) { ++ m_queried_first = false; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ return true; ++ } ++ vap_done = true; ++ break; ++ } ++ ++ std::string text(reply); ++ if (text.empty() || text.rfind("FAIL", 0) == 0) { ++ if (!text.empty() && m_queried_first && m_vap_restart_count++ < 2) { ++ m_queried_first = false; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ continue; ++ } ++ vap_done = true; ++ break; ++ } ++ if (text.rfind("UNKNOWN", 0) == 0) { ++ LOG(WARNING) << "Station enumeration is unsupported on " << vap_iface; ++ vap_done = true; ++ break; ++ } + +- // TODO: implement the API (PPM-1152) +- // currently returning true even though not implemented in order not to break +- // the flow if this HAL is used by any flow, since the API return value is checked by +- // a common flow in the monitor. ++ std::istringstream stream(text); ++ std::string first_line; ++ std::getline(stream, first_line); ++ if (!first_line.empty() && first_line.back() == '\r') { ++ first_line.pop_back(); ++ } ++ if (!looks_like_mac(first_line)) { ++ LOG(WARNING) << "Malformed station block on " << vap_iface ++ << ", skipping the rest of this VAP"; ++ vap_done = true; ++ break; ++ } ++ ++ m_queried_first = true; ++ m_prev_client_mac = tlvf::mac_from_string(first_line); ++ ++ if (!m_handled_clients.insert(m_prev_client_mac).second) { ++ continue; // seen earlier in this sweep ++ } ++ if (text.find("connected_time=") == std::string::npos) { ++ continue; // authenticated but not associated ++ } ++ ++ auto msg_buff = ++ ALLOC_SMART_BUFFER(sizeof(sACTION_MONITOR_CLIENT_ASSOCIATED_NOTIFICATION)); ++ auto msg = ++ reinterpret_cast(msg_buff.get()); ++ if (!msg) { ++ LOG(FATAL) << "Memory allocation failed"; ++ return false; ++ } ++ memset(msg_buff.get(), 0, sizeof(sACTION_MONITOR_CLIENT_ASSOCIATED_NOTIFICATION)); ++ msg->vap_id = m_vap_id_in_progress; ++ msg->mac = m_prev_client_mac; ++ event_queue_push(Event::STA_Connected, msg_buff); ++ } ++ ++ m_completed_vaps.insert(m_vap_id_in_progress); ++ m_queried_first = false; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ m_vap_id_in_progress = next_unhandled_vap(); ++ m_vap_restart_count = 0; ++ } ++ ++ m_handled_clients.clear(); ++ is_finished_all_clients = true; + return true; + } + + bool mon_wlan_hal_nl80211::pre_generate_connected_clients_events() + { +- LOG(TRACE) << __func__ << " - NOT IMPLEMENTED"; +- +- // TODO: implement the API (PPM-1152) +- // currently returning true even though not implemented in order not to break +- // the flow if this HAL is used by any flow, since the API return value is checked by +- // a common flow in the monitor. ++ m_vap_id_in_progress = INVALID_VAP_ID; ++ m_prev_client_mac = beerocks::net::network_utils::ZERO_MAC; ++ m_queried_first = false; ++ m_vap_restart_count = 0; ++ m_completed_vaps.clear(); ++ m_handled_clients.clear(); + return true; + } + +--- a/common/beerocks/bwl/nl80211/mon_wlan_hal_nl80211.h ++++ b/common/beerocks/bwl/nl80211/mon_wlan_hal_nl80211.h +@@ -12,6 +12,9 @@ + #include "base_wlan_hal_nl80211.h" + #include + ++#include ++#include ++ + namespace bwl { + namespace nl80211 { + +@@ -88,6 +91,15 @@ protected: + + // Private data-members: + private: ++ // Connected-clients import (STA-FIRST/STA-NEXT sweep) ++ static constexpr int INVALID_VAP_ID = -1; ++ std::set m_completed_vaps; ++ std::unordered_set m_handled_clients; ++ sMacAddr m_prev_client_mac = {}; ++ bool m_queried_first = false; ++ int m_vap_id_in_progress = INVALID_VAP_ID; ++ int m_vap_restart_count = 0; ++ + std::shared_ptr m_temp_wav_value; + }; + diff --git a/net/prplmesh/patches/160-production-log-levels.patch b/net/prplmesh/patches/160-production-log-levels.patch new file mode 100644 index 0000000000..90c95f13de --- /dev/null +++ b/net/prplmesh/patches/160-production-log-levels.patch @@ -0,0 +1,51 @@ +From 6da943b44dee4dc3cc51d4b4b1dfc7c68069b9e5 Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Wed, 29 Jul 2026 21:05:00 +0300 +Subject: [PATCH] logging: use production levels by default + +Debug and trace logging make normal link-metrics traffic dominate the +controller log on stock OpenWrt. Keep informational, warning, error and +fatal diagnostics, while leaving verbose levels available for an +explicit troubleshooting session. + +Apply the same production default to file and syslog output for both the +controller and agent so enabling syslog later cannot reintroduce the +same amplification. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + agent/config/beerocks_agent.conf.in | 4 ++-- + controller/config/beerocks_controller.conf.in | 4 ++-- + 2 files changed, 4 insertions(+), 4 deletions(-) + +--- a/agent/config/beerocks_agent.conf.in ++++ b/agent/config/beerocks_agent.conf.in +@@ -92,8 +92,8 @@ enable_repeater_mode=0 + em_handle_third_party=0 + + [log] +-log_global_levels=error,info,warning,fatal,trace,debug +-log_global_syslog_levels=error,info,warning,fatal,trace,debug ++log_global_levels=error,info,warning,fatal ++log_global_syslog_levels=error,info,warning,fatal + log_global_size=@BEEROCKS_AGENT_LOG_SIZE@ + log_files_enabled=@BEEROCKS_LOG_FILES_ENABLED@ + log_files_path=@BEEROCKS_LOG_FILES_PATH@ +--- a/controller/config/beerocks_controller.conf.in ++++ b/controller/config/beerocks_controller.conf.in +@@ -56,8 +56,8 @@ fail_safe_5G_vht_frequency=5210 + use_dataelements_vap_configs=@BEEROCKS_CONTROLLER_DATAELEMENTS_VAP_CONFIGS@ + + [log] +-log_global_levels=error,info,warning,fatal,trace,debug +-log_global_syslog_levels=error,info,warning,fatal,trace,debug ++log_global_levels=error,info,warning,fatal ++log_global_syslog_levels=error,info,warning,fatal + log_global_size=@BEEROCKS_CONTROLLER_LOG_SIZE@ + log_files_enabled=@BEEROCKS_LOG_FILES_ENABLED@ + log_files_path=@BEEROCKS_LOG_FILES_PATH@ diff --git a/net/prplmesh/patches/161-openwrt-log-roll-helper.patch b/net/prplmesh/patches/161-openwrt-log-roll-helper.patch new file mode 100644 index 0000000000..5f966739c7 --- /dev/null +++ b/net/prplmesh/patches/161-openwrt-log-roll-helper.patch @@ -0,0 +1,30 @@ +From 4f7a47abe79317fa4be0cb5d4d46705dd70a6758 Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Thu, 30 Jul 2026 12:00:00 +0300 +Subject: [PATCH] scripts: accept the openwrt target platform in prplmesh_utils + +The helper's platform guard accepted only linux|rdkb, so a helper +generated with TARGET_PLATFORM=openwrt exited with "Unsupported +platform: openwrt" before roll_logs could enforce log retention. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + common/beerocks/scripts/prplmesh_utils.sh.in | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +--- a/common/beerocks/scripts/prplmesh_utils.sh.in ++++ b/common/beerocks/scripts/prplmesh_utils.sh.in +@@ -165,7 +165,7 @@ stop_func() { + + main() { + case "${TARGET_PLATFORM}" in +- linux|rdkb) ++ linux|rdkb|openwrt) + ;; + *) + echo "Unsupported platform: ${TARGET_PLATFORM}" >&2 diff --git a/net/prplmesh/patches/170-ignore-inactive-wireless-backhaul-links.patch b/net/prplmesh/patches/170-ignore-inactive-wireless-backhaul-links.patch new file mode 100644 index 0000000000..255bb72ba1 --- /dev/null +++ b/net/prplmesh/patches/170-ignore-inactive-wireless-backhaul-links.patch @@ -0,0 +1,46 @@ +From e362259e3b40c451410ae4d2a100d7750f4a2883 Mon Sep 17 00:00:00 2001 +From: Andrei Ovcharenko +Date: Wed, 29 Jul 2026 22:35:00 +0300 +Subject: [PATCH] agent: ignore inactive wireless backhaul links + +The backhaul link list stores credentials for every wireless-capable +radio, including radios that are not currently used for backhaul. Link +metrics reporting treated every entry as an active wireless backhaul +and warned whenever an AP-only radio had no backhaul interface. + +Skip those expected inactive entries without a warning. Keep the +warning when the selected active wireless backhaul is missing its +interface, and handle a stale radio entry without dereferencing it. + +The whole series is publicly reviewable at +https://gitlab.com/kreout/prpl-mesh-mercusys/-/merge_requests/1; the +upstream GitLab only accepts merge requests from project members, so +the upstream submission itself goes through the prpl Foundation Jira. + +Signed-off-by: Andrei Ovcharenko +--- + .../slave/tasks/link_metrics_collection_task.cpp | 12 +++++++++++- + 1 file changed, 11 insertions(+), 1 deletion(-) + +--- a/agent/src/beerocks/slave/tasks/link_metrics_collection_task.cpp ++++ b/agent/src/beerocks/slave/tasks/link_metrics_collection_task.cpp +@@ -1724,8 +1724,18 @@ bool LinkMetricsCollectionTask::get_neig + + for (const auto &bh_wifi_info : db->backhaul.backhaul_links) { + auto radio = db->radio(bh_wifi_info.iface_name); ++ if (!radio) { ++ LOG(WARNING) << "Backhaul radio " << bh_wifi_info.iface_name << " not found"; ++ continue; ++ } ++ + if (radio->back.iface_name.empty()) { +- LOG(WARNING) << "Found empty backhaul link name!"; ++ // backhaul_links also contains credentials for radios that are not currently ++ // used for wireless backhaul. An empty back interface is expected for them. ++ if (db->backhaul.connection_type == AgentDB::sBackhaul::eConnectionType::Wireless && ++ db->backhaul.selected_iface_name == bh_wifi_info.iface_name) { ++ LOG(WARNING) << "Found empty backhaul link name!"; ++ } + continue; + } + diff --git a/net/prplmesh/test-version.sh b/net/prplmesh/test-version.sh new file mode 100644 index 0000000000..0edd7c654e --- /dev/null +++ b/net/prplmesh/test-version.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# shellcheck shell=busybox + +set -eu + +case "$PKG_NAME" in +prplmesh) + grep -F "prplmesh_version=$PKG_VERSION" /usr/share/prplmesh/config/version + ;; +*) + echo "Untested package: $PKG_NAME" >&2 + exit 1 + ;; +esac diff --git a/net/prplmesh/test.sh b/net/prplmesh/test.sh new file mode 100644 index 0000000000..8c714185d2 --- /dev/null +++ b/net/prplmesh/test.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +# shellcheck shell=busybox + +set -eu + +case "$PKG_NAME" in +prplmesh) + for config in \ + /usr/share/prplmesh/config/beerocks_agent.conf \ + /usr/share/prplmesh/config/beerocks_controller.conf; do + grep -Fx 'log_global_levels=error,info,warning,fatal' "$config" + grep -Fx 'log_global_syslog_levels=error,info,warning,fatal' "$config" + done + # -h reaches "usage; exit 0" only after main()'s platform guard has + # accepted TARGET_PLATFORM=openwrt; an unpatched helper exits 1 with + # "Unsupported platform: openwrt" instead, so this self-tests patch + # 161. + /usr/libexec/prplmesh/scripts/prplmesh_utils.sh -h + ;; +*) + echo "Untested package: $PKG_NAME" >&2 + exit 1 + ;; +esac