From 4f2f078a5c01f0ea831302338723f49f0401787f Mon Sep 17 00:00:00 2001
From: Andrei Ovcharenko
Date: Sun, 26 Jul 2026 21:32:27 +0300
Subject: [PATCH 1/2] prplmesh-stock: add package
Add the prplMesh 6.0.1 EasyMesh controller and agent for stock OpenWrt.
The package keeps the installed hostapd/wpad stack and drives it over
the existing UCI, ubus, nl80211 and hostapd control sockets.
The hostapd control interface client is a small self-contained
implementation added by patch 120, so no hostapd source tree is
downloaded, bundled or duplicated at build time and the system hostapd
stays the only hostapd. Binaries, helper scripts and static data
install under /usr/libexec/prplmesh, shared libraries under /usr/lib,
configuration under /etc/config/prplmesh.
An earlier revision of this integration, differing only in the wpa_ctrl
client origin and the install prefix, runs on OpenWrt 25.12.5
mediatek/mt7622 with two Ethernet-connected EasyMesh agents.
Signed-off-by: Andrei Ovcharenko
---
net/prplmesh-stock/Makefile | 76 +++
net/prplmesh-stock/files/prplmesh.config | 68 ++
net/prplmesh-stock/files/prplmesh.init | 77 +++
.../patches/100-stock-platform.patch | 521 ++++++++++++++++
.../patches/110-stock-wifi-hardening.patch | 583 ++++++++++++++++++
.../120-bwl-bundled-wpa-ctrl-client.patch | 452 ++++++++++++++
6 files changed, 1777 insertions(+)
create mode 100644 net/prplmesh-stock/Makefile
create mode 100644 net/prplmesh-stock/files/prplmesh.config
create mode 100644 net/prplmesh-stock/files/prplmesh.init
create mode 100644 net/prplmesh-stock/patches/100-stock-platform.patch
create mode 100644 net/prplmesh-stock/patches/110-stock-wifi-hardening.patch
create mode 100644 net/prplmesh-stock/patches/120-bwl-bundled-wpa-ctrl-client.patch
diff --git a/net/prplmesh-stock/Makefile b/net/prplmesh-stock/Makefile
new file mode 100644
index 0000000000000..0ef4495d88ab4
--- /dev/null
+++ b/net/prplmesh-stock/Makefile
@@ -0,0 +1,76 @@
+include $(TOPDIR)/rules.mk
+
+PKG_NAME:=prplmesh-stock
+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
+
+PKG_BUILD_DEPENDS:=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
+
+EXTRA_CXXFLAGS += -Wno-error=deprecated-declarations
+
+define Package/prplmesh-stock
+ 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
+endef
+
+define Package/prplmesh-stock/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-stock/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-stock/install
+ $(INSTALL_DIR) $(1)/usr/libexec/prplmesh $(1)/usr/share/prplmesh $(1)/usr/lib $(1)/etc/init.d $(1)/etc/config
+ $(CP) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/bin $(1)/usr/libexec/prplmesh/
+ $(CP) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/scripts $(1)/usr/libexec/prplmesh/
+ $(CP) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/share $(1)/usr/share/prplmesh/
+ $(CP) $(PKG_INSTALL_DIR)/usr/libexec/prplmesh/config $(1)/usr/share/prplmesh/
+ $(LN) ../../share/prplmesh/share $(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-stock))
diff --git a/net/prplmesh-stock/files/prplmesh.config b/net/prplmesh-stock/files/prplmesh.config
new file mode 100644
index 0000000000000..c0262f626ea0d
--- /dev/null
+++ b/net/prplmesh-stock/files/prplmesh.config
@@ -0,0 +1,68 @@
+# 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 'REPLACE_WITH_SSID'
+ option mode_enabled 'WPA2-Personal'
+ option key_passphrase 'REPLACE_WITH_A_STRONG_PASSWORD'
+ 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 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 'REPLACE_WITH_SSID'
+ option key 'REPLACE_WITH_A_STRONG_PASSWORD'
+ option encryption 'sae-mixed'
+ option bands '2g 5g'
+ option fronthaul '1'
+ option backhaul '0'
+
+config bss_profile 'main_6'
+ option ssid 'REPLACE_WITH_SSID'
+ option key 'REPLACE_WITH_A_STRONG_PASSWORD'
+ option encryption 'sae'
+ option bands '6g'
+ option fronthaul '1'
+ option backhaul '0'
diff --git a/net/prplmesh-stock/files/prplmesh.init b/net/prplmesh-stock/files/prplmesh.init
new file mode 100644
index 0000000000000..901bb4ba3174b
--- /dev/null
+++ b/net/prplmesh-stock/files/prplmesh.init
@@ -0,0 +1,77 @@
+#!/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
+
+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
+}
+
+start_service() {
+ local mode need_agent=1 need_controller=1
+
+ enabled || return 0
+
+ [ -x "$PRPLMESH_DIR/bin/ieee1905_transport" ] || return 1
+
+ # 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
+
+ register_process transport "$PRPLMESH_DIR/bin/ieee1905_transport"
+
+ if [ "$need_controller" = 1 ]; then
+ [ -x "$PRPLMESH_DIR/bin/beerocks_controller" ] || return 1
+ register_process controller "$PRPLMESH_DIR/bin/beerocks_controller"
+ fi
+
+ if [ "$need_agent" = 1 ]; then
+ [ -x "$PRPLMESH_DIR/bin/beerocks_agent" ] || return 1
+ [ -n "$(uci -q get prplmesh.config.mandatory_interfaces)" ] || {
+ logger -t prplmesh "mandatory_interfaces not set; agent left stopped"
+ return 1
+ }
+ # 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-stock/patches/100-stock-platform.patch b/net/prplmesh-stock/patches/100-stock-platform.patch
new file mode 100644
index 0000000000000..f352b1d785a95
--- /dev/null
+++ b/net/prplmesh-stock/patches/100-stock-platform.patch
@@ -0,0 +1,521 @@
+From 77f2a8f29088e1c2af45b64a4158c57e45b1108a Mon Sep 17 00:00:00 2001
+From: Andrei Ovcharenko
+Date: Tue, 21 Jul 2026 22:33:14 +0300
+Subject: [PATCH 1/2] 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.
+
+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
+@@ -7,6 +7,7 @@
+ */
+
+ #include "bpl_cfg_uci.h"
++#include "bpl_cfg_helper.h"
+ #include "../../common/utils/utils.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-stock/patches/110-stock-wifi-hardening.patch b/net/prplmesh-stock/patches/110-stock-wifi-hardening.patch
new file mode 100644
index 0000000000000..0c029ee5c230b
--- /dev/null
+++ b/net/prplmesh-stock/patches/110-stock-wifi-hardening.patch
@@ -0,0 +1,583 @@
+From 90a541b420a5461adaaa102d8583de97aeebc855 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: psk2/sae
+profiles without an 8..64 character network key are rejected.
+
+Also avoid building platform tests in non-test builds and make the
+association-frame bitfields portable across the OpenWrt toolchain.
+---
+ .../platform/bpl/cfg/uci/bpl_cfg_wifi.cpp | 205 +++++++++++-------
+ .../platform/bpl/cfg/uci/bpl_cfg_wifi_utils.h | 144 ++++++++++++
+ framework/platform/bpl/test/CMakeLists.txt | 8 +
+ .../bpl/test/bpl_cfg_wifi_utils_test.cpp | 69 ++++++
+ .../assoc_frame_bitfields.h | 1 +
+ 5 files changed, 346 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,102 @@
+
+ #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"];
++ 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 &&
++ (configuration.network_key.length() < 8 ||
++ configuration.network_key.length() > 64)) {
++ 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 +122,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 +185,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 +200,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 +210,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 +293,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 +317,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 +338,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 +384,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 +398,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 +424,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 +439,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 +519,18 @@ 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,144 @@
++/* 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,69 @@
++/* SPDX-License-Identifier: BSD-2-Clause-Patent */
++
++#include "../cfg/uci/bpl_cfg_wifi_utils.h"
++
++#include
++
++namespace {
++
++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)
++{
++ 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(WSC::eWscAuth(WSC::eWscAuth::WSC_AUTH_WPA2PSK |
++ WSC::eWscAuth::WSC_AUTH_SAE),
++ 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)
++{
++ std::string selected;
++ EXPECT_FALSE(select_unique_wireless_section({}, selected));
++ EXPECT_TRUE(selected.empty());
++
++ EXPECT_TRUE(select_unique_wireless_section({"default_radio0"}, selected));
++ EXPECT_EQ("default_radio0", selected);
++
++ EXPECT_FALSE(
++ select_unique_wireless_section({"default_radio0", "guest_radio0"}, selected));
++ EXPECT_TRUE(selected.empty());
++}
++
++} // namespace
+--- 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-stock/patches/120-bwl-bundled-wpa-ctrl-client.patch b/net/prplmesh-stock/patches/120-bwl-bundled-wpa-ctrl-client.patch
new file mode 100644
index 0000000000000..75b6786b8b89b
--- /dev/null
+++ b/net/prplmesh-stock/patches/120-bwl-bundled-wpa-ctrl-client.patch
@@ -0,0 +1,452 @@
+From ecae575f0456f5f15d59032649e569a511bba649 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.
+
+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 | 267 ++++++++++++++++++
+ 3 files changed, 378 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,267 @@
++/* 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;
++ * when even that fails the socket keeps working for a root daemon. */
++ if (lchown(path, (uid_t)-1, gid) != 0 || 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;
++ }
++
++ /* Permissions are inherited by the socket node bind() creates, so set
++ * them up front and there is no window without daemon access. */
++ (void)fchmod(ctrl->s, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
++
++ 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;
++ int sres;
++
++ 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);
++ 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; }
From 16d3d3f64ecc4063907b4ee122b13f70b5472518 Mon Sep 17 00:00:00 2001
From: Andrei Ovcharenko
Date: Mon, 27 Jul 2026 21:33:28 +0300
Subject: [PATCH 2/2] ci: drop external FormalityCheck wait for fork run
---
.github/workflows/multi-arch-test-build.yml | 13 -------------
1 file changed, 13 deletions(-)
diff --git a/.github/workflows/multi-arch-test-build.yml b/.github/workflows/multi-arch-test-build.yml
index c8c70872221dd..aa45eb4bf830b 100644
--- a/.github/workflows/multi-arch-test-build.yml
+++ b/.github/workflows/multi-arch-test-build.yml
@@ -9,19 +9,6 @@ permissions:
checks: read
jobs:
- wait-for-formalities:
- name: Wait for FormalityCheck
- runs-on: ubuntu-slim
- steps:
- - name: Wait for Check Run
- uses: lewagon/wait-on-check-action@v1.8.1
- with:
- ref: ${{ github.event.pull_request.head.sha }}
- check-name: 'FormalityCheck / Git & Commits'
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- wait-interval: 5
-
build:
name: Feeds Package Test Build
- needs: wait-for-formalities
uses: openwrt/actions-shared-workflows/.github/workflows/multi-arch-test-build.yml@main