]> git.ipfire.org Git - thirdparty/openwrt.git/commitdiff
hostapd: only follow a station channel on radios that hold a link
authorFelix Fietkau <nbd@nbd.name>
Fri, 31 Jul 2026 01:19:42 +0000 (03:19 +0200)
committerFelix Fietkau <nbd@nbd.name>
Sat, 1 Aug 2026 08:55:13 +0000 (10:55 +0200)
A station MLD is configured across every radio it may use, which marks all
of those radios as channel following. Only the radios the station actually
holds a link on have a channel to follow; the rest waited for one that never
arrived, so they ignored their configured channel and kept whatever ACS had
picked at start up. Later channel changes were dropped as well, because the
pending follow was treated as authoritative.

Track the frequency reported through apsta_state per radio and let a radio
without one apply its own configuration. The supplicant marks the radios
holding no link when the station completes an association, and repeats the
notification on link reconfiguration, so radios are handed back and forth as
the station adds or drops links rather than staying stuck on the first
association.

Signed-off-by: Felix Fietkau <nbd@nbd.name>
package/network/services/hostapd/files/hostapd.uc
package/network/services/hostapd/files/wpa_supplicant.uc

index 919c05fcc2e31e30600bc38204f69d28a9fcf52b..a7fbc6ec057310fa58947fe456f8f07183831b01 100644 (file)
@@ -16,6 +16,7 @@ libubus.guard(ex_handler);
 
 hostapd.data.config = {};
 hostapd.data.pending_config = {};
+hostapd.data.apsta_freq = {};
 
 hostapd.data.file_fields = {
        vlan_file: true,
@@ -617,8 +618,13 @@ function iface_channel_switch(name, config)
         * interface (STA/mesh/adhoc) via apsta_state; the AP never picks its
         * own channel here. Adopt the new fallback channel without touching the
         * running BSSes.
+        *
+        * A station MLD marks every radio it spans as channel following, yet it
+        * only drives the channel of the radios it holds a link on. Radios
+        * without a link have no channel to follow, so let them apply their own
+        * configuration instead of deferring to a station that never reports one.
         */
-       if (radio.channel_follow)
+       if (radio.channel_follow && hostapd.data.apsta_freq[name])
                return true;
 
        /*
@@ -1001,6 +1007,7 @@ function iface_check_mld(phydev, name, config)
 
 function iface_config_remove(name, old_config)
 {
+       delete hostapd.data.apsta_freq[name];
        hostapd.remove_iface(name);
        return iface_remove(old_config);
 }
@@ -1436,6 +1443,7 @@ let main_obj = {
                        sec_chan_offset: 0,
                        csa: true,
                        csa_count: 0,
+                       no_link: true,
                },
                call: function(req) {
                        let phy = phy_name(req.args.phy, req.args.radio);
@@ -1451,10 +1459,32 @@ let main_obj = {
                                return 0;
 
                        if (!req.args.up) {
+                               delete hostapd.data.apsta_freq[phy];
                                iface.stop();
                                return 0;
                        }
 
+                       if (req.args.frequency)
+                               hostapd.data.apsta_freq[phy] = req.args.frequency;
+                       else if (req.args.no_link && hostapd.data.apsta_freq[phy]) {
+                               /*
+                                * The station gave up the link that dictated this radio's
+                                * channel. Hand the radio back to its own configuration
+                                * rather than leaving the AP on the channel the station
+                                * happened to use last.
+                                */
+                               delete hostapd.data.apsta_freq[phy];
+
+                               if (iface_channel_switch(phy, config))
+                                       return 0;
+
+                               let phydev = phy_open(config.phy, config.radio_idx);
+                               if (phydev)
+                                       iface_restart(phydev, config, config);
+
+                               return 0;
+                       }
+
                        let freq_info;
                        if (req.args.frequency) {
                                freq_info = iface_freq_info(iface, config, req.args);
index 9effed7017a402cce306579cb50df9642d03dfb5..212fc637ac5bf6254fc5cd461933138e062cc52c 100644 (file)
@@ -906,9 +906,17 @@ function iface_hostapd_notify(ifname, iface, state)
                        radio: i,
                };
 
-               if (state == "COMPLETED")
+               if (state == "COMPLETED") {
                        iface_status_fill_radio(mld, i, radio_msg, status);
 
+                       // A station MLD spans every radio it was configured for, but only
+                       // drives the channel of the radios it holds a link on. Tell the AP
+                       // side which radios are left without one, so they can fall back to
+                       // their own channel configuration.
+                       if (!radio_msg.frequency)
+                               radio_msg.no_link = true;
+               }
+
                ubus.call("hostapd", "apsta_state", radio_msg);
        }
 }
@@ -1048,6 +1056,18 @@ return {
        },
        ctrl_event: function(name, iface, ev) {
                iface_ubus_notify(name, ev);
+
+               // The set of links of an associated MLD can change without a state
+               // transition, which would leave the AP side following a link that no
+               // longer exists, or ignoring one that just appeared.
+               if (index(ev, "CTRL-EVENT-LINK-RECONFIG") != 0)
+                       return;
+
+               try {
+                       iface_hostapd_notify(name, iface, "COMPLETED");
+               } catch (e) {
+                       wpas.printf(`Error handling link reconfiguration: ${e}`);
+               }
        },
        state: function(ifname, iface, state) {
                let event_data = iface.status();