From c2b67785b96122e1af4200dbfc7a76a53018d607 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 11:09:11 +0200 Subject: [PATCH 01/53] sta: StationSm::link_lost for a caller's liveness check (library) A station cannot see the AP's side of an association. An Association Response the station received but whose acknowledgement the AP never saw leaves the AP without the station while StationSm sits Connected, and on an open BSS nothing in the protocol ever says so (seen on air with an 8822C station against an mt76 AP: the AP's status for the response stayed pending, no deauthentication followed). What proves the AP's side is a unicast reply to the station's own traffic, which only the data plane sees, so the check belongs to the caller. link_lost() is its way back into the ordinary failure path: from Connected it fails the association with the new Failure::Unconfirmed (no AID, queue cleared, as any failure); anywhere else it changes nothing. on_assoc_resp also counts Association Responses that arrive when none is awaited (rx_assoc_repeat) instead of dropping them silently - an AP retransmitting one is the visible half of a lost acknowledgement. station_sm: test_link_lost. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- src/sta/CLAUDE.md | 1 + src/sta/StationSm.h | 19 ++++++++++++++++++- tests/station_sm_selftest.cpp | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/sta/CLAUDE.md b/src/sta/CLAUDE.md index c225ed13..72f4562b 100644 --- a/src/sta/CLAUDE.md +++ b/src/sta/CLAUDE.md @@ -55,6 +55,7 @@ and the cell, never here. | Received frames: beacons, deauth, auth and (re)assoc responses, no-data subtypes | `StationSm::on_rx`, `on_auth`, `on_assoc_resp` | station_sm: `test_header_only_beacons_do_not_hold_off_loss`, `test_short_deauth_is_malformed`, `test_deauth_during_handshake`, `test_reassoc_resp_does_not_complete_a_join`, `test_truncated_auth_and_assoc_are_malformed`, `test_qos_null_is_ignored_and_alive` | | The handshake deadline | `StationSm::eapol_reply`, `on_eapol` | station_sm: `test_dropped_reply_does_not_move_the_deadline` | | The TX queue: its bound, what `pop_tx` refuses | `StationSm::queue`, `pop_tx`, `kMaxTxQueue` | station_sm: `test_transmit_queue_is_bounded`, `test_join_clears_the_transmit_queue`, `test_pop_tx_refuses_null` | +| The caller's liveness check back into the failure path; repeated Association Responses counted | `StationSm::link_lost`, `rx_assoc_repeat` | station_sm: `test_link_lost` | | Duplicate cache (consumer: `tests/sta_client.cpp`) | `DupDetector` | dot11_frames: `test_dup_detector`; sta_client_headless: `test_a_retransmission_is_a_duplicate` | ## Tests diff --git a/src/sta/StationSm.h b/src/sta/StationSm.h index 91ee0910..5e489cb0 100644 --- a/src/sta/StationSm.h +++ b/src/sta/StationSm.h @@ -59,6 +59,7 @@ class StationSm { NoChannel, /* the BSS entry carries no channel: the band is unknown */ NotInfrastructure, /* the BSS is an IBSS (or claims no ESS): no AP */ SsidMismatch, /* the entry is not the configured network */ + Unconfirmed, /* link_lost(): the caller's liveness check failed */ }; /* WHICH KIND OF BSS THIS STATION IS CONFIGURED FOR. @@ -565,6 +566,19 @@ class StationSm { bool has_pmk() const { return have_pmk_; } bool keyed() const { return state_ == State::Connected && sup_.ptk_valid(); } Security security() const { return security_; } + /* THE CALLER'S OWN LIVENESS CHECK FAILED: the association this machine + * holds is not one the AP holds. A station cannot see the AP's side of an + * association - an Association Response the station received but the AP + * never saw acknowledged leaves the AP without the station while this + * machine is Connected, and on an open BSS nothing in the protocol ever + * says so. What proves the AP's side (a unicast reply to the station's + * traffic) is the data plane's to judge, so the check is the caller's; + * this is its way back into the ordinary failure and re-join path. + * Connected only; anywhere else it changes nothing. */ + void link_lost() { + if (state_ == State::Connected) fail(Failure::Unconfirmed, 0); + } + /* Associated and able to carry data. On a WPA2 BSS that is keyed(); on an * open one there is no key, so a data plane gated on keyed() would never * transmit at all. This is the predicate a caller wants. */ @@ -586,6 +600,9 @@ class StationSm { uint32_t rx_protected = 0; uint32_t rx_malformed = 0; uint32_t tx_dropped = 0; + /* Association Responses received when none was awaited - typically the + * AP retransmitting one whose acknowledgement it did not see. */ + uint32_t rx_assoc_repeat = 0; private: /* WHAT AN UNPROTECTED EAPOL-KEY FRAME MAY BE. The clear carries the @@ -681,7 +698,7 @@ class StationSm { void on_assoc_resp(const uint8_t* frame, size_t len, uint32_t now_ms) { AssocRespFields r; - if (state_ != State::Associating) return; + if (state_ != State::Associating) { rx_assoc_repeat++; return; } if (!parse_assoc_resp(frame, len, &r)) { rx_malformed++; return; } if (r.status != 0) { fail(Failure::AssocRefused, r.status); return; } /* AID 0 is not a valid association identifier; an AP that answers success diff --git a/tests/station_sm_selftest.cpp b/tests/station_sm_selftest.cpp index feac445b..fac814b0 100644 --- a/tests/station_sm_selftest.cpp +++ b/tests/station_sm_selftest.cpp @@ -2201,7 +2201,42 @@ void test_qos_null_is_ignored_and_alive() { "qos null: ...and a stream of them keeps the link alive"); } +/* link_lost(): the caller's liveness check ends an association through the + * ordinary failure path, and only an association. A repeated Association + * Response after it is counted, not taken as a second success. */ +void test_link_lost() { + BssTable table; + StationSm sm; + FixtureAp ap; + + ap.sends_msg1 = false; + sm.configure_open(kSsid, kOwn); + sm.link_lost(); + check(sm.state() == StationSm::State::Idle, "link_lost() before a join changes nothing"); + discovered(table, 6, /*rsn=*/false); + const BssEntry* bss = table.select_open(kSsid); + if (!bss) { check(false, "an open BSS to join"); return; } + sm.join(*bss, nullptr, 0); + pump(sm, ap, 0); + check(sm.state() == StationSm::State::Connected, "connected"); + + std::vector again = ap.mgmt(devourer::sta::kFcAssocResp); + devourer::sta::put_le16(again, 0x0011); + devourer::sta::put_le16(again, 0); + devourer::sta::put_le16(again, (uint16_t)(0xc000 | ap.aid)); + sm.on_rx(again.data(), again.size(), 10); + check(sm.rx_assoc_repeat == 1 && sm.state() == StationSm::State::Connected, + "a repeated Association Response is counted and changes nothing"); + + sm.link_lost(); + check(sm.state() == StationSm::State::Failed && + sm.fail_reason() == StationSm::Failure::Unconfirmed, + "link_lost() fails a Connected association as Unconfirmed"); + check(sm.aid() == 0, "...and drops the AID"); +} + int main() { + test_link_lost(); test_cleartext_eapol_after_keying(); test_join_on_a_live_association(); test_beacon_interval_is_capped(); From 3b1f870cae5d4444105b8fce63423e5f27583582 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:00:02 +0200 Subject: [PATCH 02/53] mt7612u: the station arm owns the receive filter (managed 0x00015f97) SetStationIdentity now installs MT_RX_FILTR_CFG_MANAGED, the managed filter every station cell measured, in place of the monitor filter the RX loop installs, and reads it back before the arm counts. ClearStationIdentity puts the replaced value back and returns true only once it reads back; a failed clear keeps the arm recorded so a second clear retries. While armed, mt7612u_set_monitor_rx() records its request instead of installing it, so the arm is order-independent against StartRxLoop. A port-identity drop (beacon / ACK responder) restores the pre-arm filter and a restore re-installs the managed one; both writes are read back and a miss is WARNed (kept out of the caller's I/O-error accumulator). Refusals write nothing. Stop() clears a still-armed station before closing, best effort - the chip keeps the managed filter across a close otherwise - and logs a failure only after the stop and close. mt7612u_clear_station_identity() now returns int (0 restored or nothing armed, -1 not verified). IRadio.h / AdapterCaps.h change comments only. The policy is pure (StationIdentity.h) and covered by ctest mt7612u_station_identity, including the bit decode of 0x00015f97 (regs.h names the bits); the mt7612uprobe staid gate checks the filter across arm / re-request / refusal / clear / drop. Addresses the managed-filter item of #461. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 96 ++++++++++++++++++++++---- src/AdapterCaps.h | 13 ++-- src/IRadio.h | 5 +- src/mt7612u/CLAUDE.md | 11 ++- src/mt7612u/Mt7612uRadio.cpp | 26 +++++-- src/mt7612u/Mt7612uRadio.h | 16 +++-- src/mt7612u/StationIdentity.h | 35 +++++++++- src/mt7612u/beacon.cpp | 6 ++ src/mt7612u/include/mt7612u/mt7612u.h | 18 ++++- src/mt7612u/init.cpp | 23 +++++-- src/mt7612u/regs.h | 24 +++++++ src/mt7612u/station.cpp | 99 ++++++++++++++++++++++----- src/mt7612u/tools/bringup.cpp | 71 +++++++++++++++---- src/sta/StationSm.h | 9 +-- tests/mt7612u_station_selftest.cpp | 81 ++++++++++++++++++++-- 15 files changed, 445 insertions(+), 88 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index 75d0f56e..c71f267e 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -27,8 +27,11 @@ arm. That function writes `MT_RX_FILTR_CFG = PHY_ERR|CRC_ERR` and nothing else — every address and BSS drop bit off — so all six arms ran promiscuous and were identical by construction. Its null result is withdrawn. The reasoning that let it through was also wrong: in the managed filter `0x00015f97`, bit 3 -(`OTHER_BSS`) is clear but bit **2** (`PROMISC`) is set, and mt76 maps bit 2 -to `FIF_OTHER_BSS`. The gate now leaves the managed value `mt_mac_start()` +(`OTHER_BSS`) is clear but bit **2** (`PROMISC`) is set, and bit 2 is the +address drop (mt76x2 sets it whenever the phy is not in monitor mode; an +earlier version of this page said mt76 maps it to `FIF_OTHER_BSS`, which is +not what `mt76x2u_config()` does - the decode is in the managed-filter section +below). The gate now leaves the managed value `mt_mac_start()` programs, prints it per arm, and flags an arm that is not running it. Also withdrawn: a "0.8% retried vs 98% control" auto-ACK figure from the @@ -281,20 +284,85 @@ Against it, and against the comparison: first-arm excess every run shows. - Two units, one peer model, one channel, near field, one run per arm. +## The managed receive filter belongs to the armed station + +Every cell above ran the managed filter `0x00015f97`, while +`Mt7612uRadio::StartRxLoop` installs the monitor filter (`PHY_ERR|CRC_ERR`). +So a station driven through `IRadio` used to run promiscuous, and the +property that justifies the seam's refusal - "moving `MT_MAC_ADDR` makes a +station deaf" - did not hold for it: under the monitor filter it keeps +receiving and only stops acknowledging (issue #461). + +**The role is selected by the arm, with no new API.** A successful +`SetStationIdentity` reads `MT_RX_FILTR_CFG`, writes +`MT_RX_FILTR_CFG_MANAGED` and reads it back; `ClearStationIdentity` writes the +recorded value back and returns true only once that reads back (a failure +keeps the arm recorded, so a second clear retries). A refusal returns before +the filter is read, so it writes nothing; a managed write that does not read +back is undone and refused. While armed, `mt7612u_set_monitor_rx()` - which +`StartRxLoop` calls after every MAC start - keeps the managed filter and only +records the request, so the arm is order-independent. A beacon or ACK +responder that moves the port identity drops the arm and puts the pre-arm +filter back (the AP and responder paths depend on the monitor filter's `DUP` +clear); a failed beacon start that restores the arm reinstalls the managed +filter. All of it runs under `Mt7612uRadio::_mu`, the lock the existing +filter write and every channel change already take, and the RX loop never +writes the filter. The drop and restore writes are read back and a miss is +logged (the clear re-verifies). `Stop()` clears a still-armed station before +closing, best effort, because the chip keeps its registers across a close; +every bring-up also rewrites the filter in `mt_mac_start()`. The +policy half is `mt7612u_sta_rx_filter_request()` / `mt7612u_sta_arm()` in +`src/mt7612u/StationIdentity.h`, covered by ctest `mt7612u_station_identity`. + +**The value is the measured one, unchanged.** These are DROP bits +(`regs.h`, from mt76's `mt76x02_regs.h`): + +| bit | name | `0x00015f97` | what a station gets | +|---|---|---|---| +| 0 | CRC_ERR | drop | no FCS failures (`rx.keep_corrupted` applies to the monitor filter only; FCS-bad frames are dropped while armed) | +| 1 | PHY_ERR | drop | | +| 2 | PROMISC | **drop** | unicast whose addr1 is not `MT_MAC_ADDR` is dropped - the deaf-on-move property | +| 3 | OTHER_BSS | keep | frames of every BSS still arrive | +| 4 | VER_ERR | drop | | +| 5 | MCAST | keep | group-addressed data | +| 6 | BCAST | keep | beacons of every BSS, broadcast probe responses, broadcast data | +| 7 | DUP | drop | hardware duplicate drop, as mt76 runs a station (sta_client's `DupDetector` stays) | +| 8-12 | CFACK, CFEND, ACK, CTS, RTS | drop | control frames a station has no use for | +| 13 | PSPOLL | keep | (mt76's `configure_filter` would drop it; immaterial to a station) | +| 14 | BA | drop | | +| 15 | BAR | keep | | +| 16 | CTRL_RSV | drop | | + +What `tests/sta_client.cpp` needs while armed is all kept: beacons and probe +responses from every BSS (broadcast, or unicast to `own`) for a scan and a +re-join, authentication / association / EAPOL / data addressed to `own`, and +group-addressed data. What it loses is only what `StationSm::on_rx` already +refused: another station's unicast (`not-for-us`) and probe responses to +other stations. No disarm-while-scanning is needed. + +**Witness.** `tests/mt7612u_sta_onair.sh` injects two plaintext unicast +streams from the AP's BSSID while the station is associated: one at an address +nobody holds, one at the station's own address. The own stream is the positive +witness that the injection reaches the DUT - the station counts it as +`plaintext refused` (a WPA2 link), and it must reach half of what was +injected or the check is INCONCLUSIVE. Then armed (`wpa2`), `not-for-us` must +stay under 1% of the foreign stream; unarmed (`noarm`, the monitor filter) at +least half of it must arrive. Hardware gate: +`mt7612uprobe staid` checks the filter value across arm, re-request, refusal, +clear and drop. On-air numbers: TBD. + ## What is not established -- **The library's own RX path does not run the managed filter.** - `Mt7612uRadio::StartRxLoop` installs the monitor filter unconditionally, so - a station driven through `IRadio` runs promiscuous. Acknowledgement holds - there (the monitor-filter auto-ACK run above), but "moving `MT_MAC_ADDR` - makes a station deaf" is a managed-filter property: under the monitor filter - it would keep receiving and stop acknowledging. A role-selected managed - filter is not implemented. -- **No cell drove `SetStationIdentity` through `IRadio`.** The seam writes no - register - `mt7612uprobe staid` reads `MT_MAC_ADDR`, `MT_MAC_BSSID` and - all eight APC slots before and after arming and clearing and checks them - unchanged - so the measured state is what a successful arm leaves behind, - but "arm the seam, then measure" is unexercised here. +- **The managed filter under a live association is not yet measured on + air** (TBD, above). The cells that measured it ran unassociated, through + the bring-up tool. +- **No BSSID/auto-ACK cell drove `SetStationIdentity` through `IRadio`.** The + seam writes no identity register - `mt7612uprobe staid` reads + `MT_MAC_ADDR`, `MT_MAC_BSSID` and all eight APC slots before and after + arming and clearing and checks them unchanged - and installs the managed + filter those cells ran, so the measured state is what a successful arm + leaves behind, but "arm the seam, then measure" is unexercised by those + cells. - **Every cell is an unassociated station** receiving traffic it did not negotiate: power save, TIM parsing, cross-BSS duplicate detection and hardware key lookup are untested. diff --git a/src/AdapterCaps.h b/src/AdapterCaps.h index d0d5a00c..e2646e17 100644 --- a/src/AdapterCaps.h +++ b/src/AdapterCaps.h @@ -267,16 +267,11 @@ struct AdapterCaps { * negotiated, so power save, TIM parsing, cross-BSS duplicate detection * and hardware key lookup are untested; * - those cells did not drive SetStationIdentity itself. On this part the - * seam writes no register, so the measured hardware state is the state - * a successful arm leaves behind, but the literal "arm through IRadio, + * seam writes no identity register and installs the managed receive + * filter the cells ran (0x00015f97; the RX loop's monitor filter comes + * back on clear), so the measured hardware state is the state a + * successful arm leaves behind, but the literal "arm through IRadio, * then measure" path is not what the cells ran; - * - the cells ran the MANAGED receive filter, and the library's own RX - * path does not: Mt7612uRadio::StartRxLoop calls - * mt7612u_set_monitor_rx() unconditionally, so a station driven through - * IRadio runs PROMISCUOUS. Acknowledgement does not depend on it (a - * monitor-filter run of the same auto-ACK cell also read 100%), but the - * "moving the port identity makes a station deaf" half of the rationale - * is specific to the managed filter; * - two units, one peer model, one channel, near field, no soak; the * second unit reproduced the acknowledgement and uplink cells (its * uplink at 1.9 mean retries against the first unit's 0.0), not the diff --git a/src/IRadio.h b/src/IRadio.h index 801a9be0..801dfae3 100644 --- a/src/IRadio.h +++ b/src/IRadio.h @@ -263,8 +263,9 @@ class IRadio { * * ORDERING. Call after the RX loop is running, not before. This is not a * style preference: a backend may program the receive filter when the RX - * loop starts and overwrite anything an earlier call wrote (MT7612U does - * exactly this - see Mt7612uRadio::StartRxLoop). An implementation that + * loop starts and overwrite anything an earlier call wrote (MT7612U's RX + * loop start rewrites the filter, though it keeps an armed station's - see + * Mt7612uRadio::SetStationIdentity). An implementation that * cannot detect being called too early must say so at its declaration; * one that can should refuse and log rather than arm something that will * be silently undone. diff --git a/src/mt7612u/CLAUDE.md b/src/mt7612u/CLAUDE.md index e008b964..f340e5fd 100644 --- a/src/mt7612u/CLAUDE.md +++ b/src/mt7612u/CLAUDE.md @@ -69,5 +69,14 @@ Where each piece lives: NOACK vs. ACK-requesting radiotap - Contract: `mt7612u_set_station_identity` (`include/mt7612u/mt7612u.h`) and `IRadio::SetStationIdentity` (`src/IRadio.h`). Measurements, the receive-filter -caveat and the retractions: `docs/mt7612u-station-identity.md`. Headless cell +decision and the retractions: `docs/mt7612u-station-identity.md`. Headless cell `mt7612u_station_identity`; hardware gate `mt7612uprobe staid`. + +The arm OWNS `MT_RX_FILTR_CFG`: it installs `MT_RX_FILTR_CFG_MANAGED` +(`0x00015f97`, `regs.h` spells the bits) and records what it replaced; the +clear and a port-identity drop put that back. While armed, +`mt7612u_set_monitor_rx()` only records its request +(`mt7612u_sta_rx_filter_request`, `StationIdentity.h`), so an RX loop +(re)started under a station does not knock it promiscuous. Anything new that +writes the filter must go through the same request, or it silently disarms +the station's receive half. diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 3231c994..82a9ed1e 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -715,16 +715,31 @@ void Mt7612uRadio::Stop() { * teardown lock is what keeps a concurrent StopRxLoop from using the pointer * after this steals it. */ struct mt7612u_dev *dev = nullptr; + bool filter_left = false; { std::lock_guard teardown(_teardown_mu); std::lock_guard lock(_mu); dev = _dev; _dev = nullptr; - if (dev) + if (dev) { + /* Best effort: the chip keeps its registers across a close, so a + * station still armed here would leave the managed receive filter for + * whoever opens the adapter next. Put the pre-arm filter back first; + * a no-op with nothing armed. Only a flag here - the logger can throw, + * and nothing may skip the stop and the close below. */ + filter_left = mt7612u_clear_station_identity(dev) != 0; mt7612u_stop(dev); + } } if (dev) mt7612u_close(dev); + if (filter_left) { + try { + _logger->warn("MT7612U: closed with the station's managed receive " + "filter possibly still in force"); + } catch (...) { + } + } } void Mt7612uRadio::SetTxPower(uint8_t power) { @@ -1035,7 +1050,7 @@ void Mt7612uRadio::ClearAckResponder() { } /* Thin, like the rest of the control plane: on this part a station identity - * is a check, not a configuration (station.cpp, + * is a check plus one register, the managed receive filter (station.cpp, * docs/mt7612u-station-identity.md). The ordering note IRadio requires is at * the declaration (Mt7612uRadio.h). */ bool Mt7612uRadio::SetStationIdentity(const devourer::MacAddr &own, @@ -1069,10 +1084,9 @@ bool Mt7612uRadio::ClearStationIdentity() { * holds (IRadio's contract for a clear with nothing to undo). */ if (!_dev) return true; - mt7612u_clear_station_identity(_dev); - /* True without qualification because the arm writes no hardware state on - * this part: there is nothing to restore, so nothing to verify. */ - return true; + /* The arm installed the managed receive filter; true only once the + * pre-arm filter reads back. A failure keeps the arm, so a retry works. */ + return mt7612u_clear_station_identity(_dev) == 0; } /* The beacon plane. Thin on purpose: the sequence these wrap is the one the diff --git a/src/mt7612u/Mt7612uRadio.h b/src/mt7612u/Mt7612uRadio.h index 77d7ef0d..d47aa851 100644 --- a/src/mt7612u/Mt7612uRadio.h +++ b/src/mt7612u/Mt7612uRadio.h @@ -43,7 +43,9 @@ * drain (mt7612u_rx_quiesce, then mt7612u_rx_stop). * * 2. The monitor filter goes on AFTER mt7612u_start(), which rewrites - * MT_RX_FILTR_CFG to mt76's managed-station value. Measured against the + * MT_RX_FILTR_CFG to mt76's managed-station value (unless a station + * identity is armed: then the managed filter is the station's and + * stays). Measured against the * bring-up harness in the same minute on the same silicon: 0 OFDM frames * of 244 with the managed filter, 103 of 402 with the monitor filter. A * single-path test would have called 244 beacons a working receiver. @@ -102,11 +104,13 @@ class Mt7612uRadio : public IRadio { devourer::ChannelBusy GetChannelBusy() override; uint32_t ArmChannelBusy(uint32_t window_us) override; bool SetAckResponder(const devourer::MacAddr &mac) override; - /* IRadio's ORDERING clause, answered here as it requires: this CANNOT - * detect being called before the RX loop. It writes no filter and no - * identity (a check, not a configuration), so it is order-independent as - * implemented - but StartRxLoop reprograms the receive filter after - * mt7612u_start(), so call it after StartRxLoop as the interface says. */ + /* IRadio's ORDERING clause, answered here as it requires: order- + * independent. The arm installs the managed receive filter + * (MT_RX_FILTR_CFG_MANAGED) and writes no identity; a StartRxLoop under a + * live arm keeps the managed filter (mt7612u_set_monitor_rx only records + * its request), and the clear - or a beacon / ACK responder taking the port + * identity - puts the monitor filter back. Calling it after StartRxLoop, + * as the interface says, is still the documented order. */ bool SetStationIdentity(const devourer::MacAddr &own, const devourer::MacAddr &bssid) override; bool ClearStationIdentity() override; diff --git a/src/mt7612u/StationIdentity.h b/src/mt7612u/StationIdentity.h index c43a2433..258357fa 100644 --- a/src/mt7612u/StationIdentity.h +++ b/src/mt7612u/StationIdentity.h @@ -130,6 +130,10 @@ struct mt7612u_sta_state { int armed; /* Dropped by a port-identity move, own/bssid kept for a restore. */ int lost; + /* The receive filter the station took MT_RX_FILTR_CFG from, and puts back + * when it lets go of it (clear, or a drop). Meaningful while `armed` or + * `lost`. */ + uint32_t rx_filtr_restore; }; /* `port` as read from MT_MAC_ADDR (DW0 + the low half of DW1) against `own`. @@ -143,9 +147,16 @@ mt7612u_port_compare(const uint8_t *port, int read_ok, const uint8_t *own) : MT7612U_PORT_DIFFERENT; } +/* `cur_filtr` is what MT_RX_FILTR_CFG held when this arm was asked for. A + * RE-arm keeps the value the first arm recorded: the register then holds the + * managed filter that arm installed, and restoring THAT on clear would leave + * the receiver managed after the station is gone. */ static inline void mt7612u_sta_arm(struct mt7612u_sta_state *s, - const uint8_t *own, const uint8_t *bssid) + const uint8_t *own, const uint8_t *bssid, + uint32_t cur_filtr) { + if (!s->armed) + s->rx_filtr_restore = cur_filtr; memcpy(s->own, own, 6); memcpy(s->bssid, bssid, 6); s->armed = 1; @@ -179,6 +190,28 @@ mt7612u_sta_port_observed(struct mt7612u_sta_state *s, return MT7612U_STA_EV_NONE; } +/* + * The receive filter, as owned by the station role. + * + * An armed station runs the managed filter (MT_RX_FILTR_CFG_MANAGED); every + * other state runs whatever the consumer asked for. A consumer asks through + * mt7612u_set_monitor_rx(), which Mt7612uRadio::StartRxLoop calls after every + * MAC start - so a receiver (re)started under a live station must not knock + * it back to the monitor filter, and a request made then is only RECORDED, + * to be installed when the station lets go. Returns the value to write. + * + * `lost` records too: a dropped arm comes back on a restore, and its clear + * must then put back the consumer's latest request, not a stale one. + */ +static inline uint32_t +mt7612u_sta_rx_filter_request(struct mt7612u_sta_state *s, uint32_t want, + uint32_t managed) +{ + if (s->armed || s->lost) + s->rx_filtr_restore = want; + return s->armed ? managed : want; +} + #ifdef __cplusplus } #endif diff --git a/src/mt7612u/beacon.cpp b/src/mt7612u/beacon.cpp index 434de47b..b898c20c 100644 --- a/src/mt7612u/beacon.cpp +++ b/src/mt7612u/beacon.cpp @@ -516,6 +516,12 @@ int mt7612u_beacon_start(struct mt7612u_dev *dev, const void *buf, size_t len, * out would switch duplicate filtering on in a session that deliberately * had it off - destroying the retry=0 evidence the AP harness measures. * Both were here for one round; neither belongs. + * + * One filter write does happen on this path, and not here: a station + * arm this start drops (mt7612u_station_identity_check) gives the + * receiver back the filter it replaced - the monitor filter, under + * Mt7612uRadio - so a station armed first does not leave the AP running + * the managed filter with DUP set. */ mt_beacon_init(dev); /* diff --git a/src/mt7612u/include/mt7612u/mt7612u.h b/src/mt7612u/include/mt7612u/mt7612u.h index 68922476..b642d1bb 100644 --- a/src/mt7612u/include/mt7612u/mt7612u.h +++ b/src/mt7612u/include/mt7612u/mt7612u.h @@ -249,6 +249,10 @@ int mt7612u_rx_quiesce(struct mt7612u_dev *dev); * station - on ambient 2.4 GHz traffic that is the difference between seeing * the whole mix and seeing almost nothing but beacons. Call this after * mt7612u_start(), which rewrites the register. + * + * While a station identity is armed (mt7612u_set_station_identity) the + * managed filter stays in force and this request is recorded instead; the + * station's clear installs it. */ int mt7612u_set_monitor_rx(struct mt7612u_dev *dev, int keep_corrupted); @@ -314,15 +318,25 @@ int mt7612u_set_retry_limit(struct mt7612u_dev *dev, int limit); * The BSSID is recorded for the host (it is addr3 on every frame a station * sends) and retrievable with mt7612u_station_bssid(). * - It verifies MT_AUTO_RSP_EN, since the measured auto-ACK depends on it. + * - It DOES write MT_RX_FILTR_CFG: the managed filter 0x00015f97 + * (MT_RX_FILTR_CFG_MANAGED), the receiver every station cell measured, + * read back before the arm counts. What the register held is kept and put + * back by the clear, and by a drop (below). A refusal writes nothing; a + * managed write that does not read back is undone and refused. * * Returns 0 when armed, -1 when refused - including when something else (a * beacon, an ACK responder) owns the port identity. A beacon or ACK responder * armed LATER that moves the port identity drops the station arm with a - * warning; re-arm once it has been given back. + * warning and puts the pre-arm filter back; re-arm once it has been given + * back. + * + * The clear returns 0 once the pre-arm filter reads back (or nothing was + * armed), -1 when it does not - the arm then stays recorded, so a second + * clear retries the restore. */ int mt7612u_set_station_identity(struct mt7612u_dev *dev, const uint8_t own[6], const uint8_t bssid[6]); -void mt7612u_clear_station_identity(struct mt7612u_dev *dev); +int mt7612u_clear_station_identity(struct mt7612u_dev *dev); /* The BSSID last armed; -1 if no station identity is armed. */ int mt7612u_station_bssid(struct mt7612u_dev *dev, uint8_t out[6]); diff --git a/src/mt7612u/init.cpp b/src/mt7612u/init.cpp index 48b57cb4..f1ea117b 100644 --- a/src/mt7612u/init.cpp +++ b/src/mt7612u/init.cpp @@ -266,6 +266,10 @@ void mt_rx_flush(struct mt7612u_dev *d) } } +/* The value every station cell measured; the named bits must spell it. */ +static_assert(MT_RX_FILTR_CFG_MANAGED == 0x00015f97u, + "MT_RX_FILTR_CFG_MANAGED must stay the measured 0x00015f97"); + int mt_mac_start(struct mt7612u_dev *d, int enable_rx) { /* Refuse rather than wedge. The receiver running with nothing draining @@ -287,7 +291,7 @@ int mt_mac_start(struct mt7612u_dev *d, int enable_rx) ERR("mac_start: WPDMA stayed busy"); return -1; } - mt_wr(d, MT_RX_FILTR_CFG, 0x00015f97); + mt_wr(d, MT_RX_FILTR_CFG, MT_RX_FILTR_CFG_MANAGED); /* Only turn the receiver on when the caller will actually drain EP 4. * mt76's mac_start always sets both bits, but mt76 also keeps RX URBs * permanently queued; a TX-only injector that never reads has no such @@ -549,13 +553,18 @@ int mt7612u_stop(struct mt7612u_dev *d) /* * Monitor receive filter. * - * mt_mac_start() leaves MT_RX_FILTR_CFG at 0x00015f97, which is what mt76 - * programs for a managed station: control frames, other-BSS frames and - * frames not addressed here are all dropped. A monitor consumer wants the - * opposite, so this clears everything except the two error classes. + * mt_mac_start() leaves MT_RX_FILTR_CFG at MT_RX_FILTR_CFG_MANAGED + * (0x00015f97), which is what mt76 programs for a managed station: control + * frames and unicast not addressed here are dropped (regs.h has the bits). A + * monitor consumer wants the opposite, so this clears everything except the + * two error classes. * * DUP deliberately stays clear: duplicate suppression would hide the * retransmissions an ACK-responder test counts. + * + * While a station identity is armed the station owns the filter: this then + * keeps the managed filter in place and only records the request, which the + * station's clear (or a drop) installs (mt7612u_sta_rx_filter_request). */ int mt7612u_set_monitor_rx(struct mt7612u_dev *d, int keep_corrupted) { @@ -564,7 +573,9 @@ int mt7612u_set_monitor_rx(struct mt7612u_dev *d, int keep_corrupted) if (!d) return -1; if (!keep_corrupted) filtr |= MT_RX_FILTR_CFG_CRC_ERR; - mt_wr(d, MT_RX_FILTR_CFG, filtr); + mt_wr(d, MT_RX_FILTR_CFG, + mt7612u_sta_rx_filter_request(&d->sta, filtr, + MT_RX_FILTR_CFG_MANAGED)); return 0; } diff --git a/src/mt7612u/regs.h b/src/mt7612u/regs.h index 9fd10136..3d579920 100644 --- a/src/mt7612u/regs.h +++ b/src/mt7612u/regs.h @@ -257,8 +257,32 @@ static inline uint32_t mt_retry_cfg_with_limit(uint32_t cur, uint32_t limit) #define MT_RX_FILTR_CFG_PROMISC BIT(2) #define MT_RX_FILTR_CFG_OTHER_BSS BIT(3) #define MT_RX_FILTR_CFG_VER_ERR BIT(4) +#define MT_RX_FILTR_CFG_MCAST BIT(5) +#define MT_RX_FILTR_CFG_BCAST BIT(6) #define MT_RX_FILTR_CFG_DUP BIT(7) +#define MT_RX_FILTR_CFG_CFACK BIT(8) +#define MT_RX_FILTR_CFG_CFEND BIT(9) +#define MT_RX_FILTR_CFG_ACK BIT(10) +#define MT_RX_FILTR_CFG_CTS BIT(11) +#define MT_RX_FILTR_CFG_RTS BIT(12) +#define MT_RX_FILTR_CFG_PSPOLL BIT(13) +#define MT_RX_FILTR_CFG_BA BIT(14) +#define MT_RX_FILTR_CFG_BAR BIT(15) #define MT_RX_FILTR_CFG_CTRL_RSV BIT(16) +/* Every bit is a DROP bit. The managed-station filter: the initvals value + * mt_mac_start() programs, and what every station cell in + * docs/mt7612u-station-identity.md measured. Drops FCS and PLCP failures, + * unicast whose addr1 is not MT_MAC_ADDR (PROMISC - mt76x2u_config() sets it + * whenever the phy is not in monitor mode), bad protocol versions, hardware-detected + * duplicates and the control frames a station has no use for. KEEPS + * broadcast, multicast, other-BSS frames (so beacons and group traffic from + * every BSS, for a re-scan), PS-Poll and BAR. */ +#define MT_RX_FILTR_CFG_MANAGED \ + (MT_RX_FILTR_CFG_CRC_ERR | MT_RX_FILTR_CFG_PHY_ERR | \ + MT_RX_FILTR_CFG_PROMISC | MT_RX_FILTR_CFG_VER_ERR | \ + MT_RX_FILTR_CFG_DUP | MT_RX_FILTR_CFG_CFACK | MT_RX_FILTR_CFG_CFEND | \ + MT_RX_FILTR_CFG_ACK | MT_RX_FILTR_CFG_CTS | MT_RX_FILTR_CFG_RTS | \ + MT_RX_FILTR_CFG_BA | MT_RX_FILTR_CFG_CTRL_RSV) #define MT_AUTO_RSP_CFG 0x1404 #define MT_AUTO_RSP_EN BIT(0) #define MT_AUTO_RSP_PREAMB_SHORT BIT(4) diff --git a/src/mt7612u/station.cpp b/src/mt7612u/station.cpp index c3648a9e..c0674491 100644 --- a/src/mt7612u/station.cpp +++ b/src/mt7612u/station.cpp @@ -39,7 +39,13 @@ * needs ACK-requesting radiotap and a nonzero tx.retry_limit - see * Mt7612uRadio::SetStationIdentity. * - * So the useful work here is refusal and verification, not configuration. + * THE ONE REGISTER IT WRITES is the receive filter. Every cell above ran the + * managed filter, while Mt7612uRadio's RX loop installs the monitor filter; + * left there, an armed station received promiscuously, and "moving the port + * identity makes a station deaf" did not hold for it. So the arm installs + * MT_RX_FILTR_CFG_MANAGED and the clear (or a drop) puts back what it found. + * + * So the useful work here is refusal and verification, plus that one filter. */ #include @@ -72,11 +78,22 @@ static int sta_read_port_identity(struct mt7612u_dev *d, uint8_t out[6]) return 0; } +/* Write the receive filter and read it back. */ +static int sta_write_filter(struct mt7612u_dev *d, uint32_t v) +{ + uint32_t got = 0; + + if (mt_wr_chk(d, MT_RX_FILTR_CFG, v) != 0 || + mt_rr_chk(d, MT_RX_FILTR_CFG, &got) != 0) + return -1; + return got == v ? 0 : -1; +} + int mt7612u_set_station_identity(struct mt7612u_dev *dev, const uint8_t own[6], const uint8_t bssid[6]) { uint8_t port[6] = { 0 }; - uint32_t rsp = 0; + uint32_t rsp = 0, filtr = 0; int port_ok, rsp_ok; enum mt7612u_sta_verdict v; @@ -145,17 +162,44 @@ int mt7612u_set_station_identity(struct mt7612u_dev *dev, * station transmits. Power save, TIM parsing and per-BSS key lookup, * which could give it a hardware use, are untested on this part. */ - mt7612u_sta_arm(&dev->sta, own, bssid); + /* + * The managed receive filter - the receiver the cells measured, not the + * monitor one the RX loop installs. Read first, so the clear can put it + * back and so a failure leaves the register as found; nothing above this + * line writes, so every refusal before it leaves the filter untouched. + */ + if (mt_rr_chk(dev, MT_RX_FILTR_CFG, &filtr) != 0) { + WARN("station identity refused: MT_RX_FILTR_CFG unreadable, so the " + "filter the clear must restore is unknown"); + return -1; + } + if (sta_write_filter(dev, MT_RX_FILTR_CFG_MANAGED) != 0) { + /* Best effort: put back what it held. A previous arm, if any, + * stands - this call changed nothing it can confirm. */ + mt_wr(dev, MT_RX_FILTR_CFG, filtr); + WARN("station identity refused: the managed receive filter " + "%08x did not read back", MT_RX_FILTR_CFG_MANAGED); + return -1; + } + mt7612u_sta_arm(&dev->sta, own, bssid, filtr); return 0; } -void mt7612u_clear_station_identity(struct mt7612u_dev *dev) +int mt7612u_clear_station_identity(struct mt7612u_dev *dev) { if (!dev) - return; - /* Nothing to undo in hardware - this seam never wrote any. That is a - * property of this part and not a promise of the interface. */ + return 0; + /* Re-written for a lost arm too: its drop restored the filter best + * effort, and this is where that gets verified. */ + if ((dev->sta.armed || dev->sta.lost) && + sta_write_filter(dev, dev->sta.rx_filtr_restore) != 0) { + WARN("station identity clear: the pre-arm receive filter %08x did " + "not read back - the arm stays recorded so a second clear " + "retries it", dev->sta.rx_filtr_restore); + return -1; + } mt7612u_sta_clear(&dev->sta); + return 0; } /* @@ -175,30 +219,49 @@ void mt7612u_station_identity_check(struct mt7612u_dev *dev, const char *who, { uint8_t port[6] = { 0 }; unsigned io; - int port_ok; + int port_ok, filtr_rc = 0; + uint32_t filtr_want = 0; + enum mt7612u_sta_event ev; if (!dev || (!dev->sta.armed && !dev->sta.lost)) return; - /* Kept out of the I/O-error accumulator: this read must not fail an - * operation (a beacon start counts io errors) that did its own job. */ + /* Kept out of the I/O-error accumulator: this read, and the filter + * write below, must not fail an operation (a beacon start counts io + * errors) that did its own job. */ io = mt_io_errors(dev); port_ok = sta_read_port_identity(dev, port) == 0; + ev = mt7612u_sta_port_observed(&dev->sta, + mt7612u_port_compare(port, port_ok, dev->sta.own), allow_restore); + /* The filter follows the arm: a dropped station gives the receiver back + * to the pre-arm filter (a beacon or responder that took the identity + * wants the monitor filter, DUP clear - beacon.cpp relies on it); a + * restored one takes it again. Read back, and a miss is said - it does + * not fail the caller's operation, and the clear re-writes and + * verifies. */ + if (ev == MT7612U_STA_EV_DROPPED || ev == MT7612U_STA_EV_RESTORED) { + filtr_want = ev == MT7612U_STA_EV_DROPPED ? dev->sta.rx_filtr_restore + : MT_RX_FILTR_CFG_MANAGED; + filtr_rc = sta_write_filter(dev, filtr_want); + } mt_io_restore(dev, io); + if (filtr_rc != 0) + WARN("station identity %s after %s: the receive filter %08x did " + "not read back - the receiver may still run the %s filter", + ev == MT7612U_STA_EV_DROPPED ? "drop" : "restore", who, + filtr_want, + ev == MT7612U_STA_EV_DROPPED ? "managed (DUP set)" : "pre-arm"); - switch (mt7612u_sta_port_observed(&dev->sta, - mt7612u_port_compare(port, port_ok, dev->sta.own), - allow_restore)) { + switch (ev) { case MT7612U_STA_EV_NONE: break; case MT7612U_STA_EV_DROPPED: WARN("station identity DROPPED: %s moved MT_MAC_ADDR to " "%02x:%02x:%02x:%02x:%02x:%02x, away from this station's own " "address. The MAC no longer acknowledges the AP's unicast to " - "the station; under the MANAGED receive filter it no longer " - "receives it either (measured: reception goes to zero). Under " - "the monitor filter Mt7612uRadio's RX loop installs, frames " - "still arrive but go unacknowledged. Re-arm the station " - "identity after %s releases it.", + "the station, and the receive filter is back to its pre-arm " + "value (the monitor filter, under Mt7612uRadio's RX loop): " + "frames still arrive but go unacknowledged. Re-arm the " + "station identity after %s releases it.", who, port[0], port[1], port[2], port[3], port[4], port[5], who); break; case MT7612U_STA_EV_RESTORED: diff --git a/src/mt7612u/tools/bringup.cpp b/src/mt7612u/tools/bringup.cpp index 26559f00..ff57d910 100644 --- a/src/mt7612u/tools/bringup.cpp +++ b/src/mt7612u/tools/bringup.cpp @@ -4277,9 +4277,10 @@ static int gate_tsfwrap(int gap, int wrap_bits, double max_min) * base matters, not what mt76 would program. * * The receive filter is what makes this a question at all. The managed value - * mt_mac_start() programs, 0x00015f97, has bit 2 (PROMISC) SET, and in mt76 - * bit 2 is the one mapped to FIF_OTHER_BSS (init.cpp describes that value as - * dropping other-BSS frames). Bit 3 (OTHER_BSS) is clear. Do not reason about + * mt_mac_start() programs, 0x00015f97, has bit 2 (PROMISC) SET - mt76x2 + * sets it whenever the phy is not in monitor mode, and it drops unicast not + * addressed to MT_MAC_ADDR (mt76x2u_config()). Bit 3 (OTHER_BSS) is clear + * (regs.h has the full decode). Do not reason about * this register from one bit: the gate prints the full value per arm for the * reader, and flags an arm whose PROMISC drop bit is clear (the monitor * filter). @@ -5039,11 +5040,12 @@ static int gate_staack(uint8_t chan, int secs, const char *bssid_str) * bringup staid */ /* - * Every register a station identity could plausibly write: the port identity - * (MT_MAC_ADDR), the MBSS base (MT_MAC_BSSID) and both words of all eight APC - * slots. The seam's defining property is that it writes none of them; - * gate_staid compares a snapshot before and after. Returns -1 on any failed - * read - an unreadable register cannot be shown unchanged. + * Every identity register a station identity could plausibly write: the port + * identity (MT_MAC_ADDR), the MBSS base (MT_MAC_BSSID) and both words of all + * eight APC slots. The seam writes none of them; gate_staid compares a + * snapshot before and after. Returns -1 on any failed read - an unreadable + * register cannot be shown unchanged. The one register it DOES write, the + * receive filter, is checked separately (staid_filtr_is). */ struct staid_regs { uint32_t w[20]; }; @@ -5064,6 +5066,17 @@ static int staid_snapshot(struct staid_regs *r) return 0; } +/* 1 when MT_RX_FILTR_CFG reads back as `want`. */ +static int staid_filtr_is(uint32_t want) +{ + uint32_t v = 0; + + if (mt_rr_chk(&dev, MT_RX_FILTR_CFG, &v)) return 0; + if (v != want) + printf(" (MT_RX_FILTR_CFG = %08x, expected %08x)\n", v, want); + return v == want; +} + /* 1 when both snapshots were taken and are identical. */ static int staid_unchanged(const struct staid_regs *a, int a_ok, const struct staid_regs *b, int b_ok) @@ -5080,8 +5093,13 @@ static int gate_staid(void) int pass = 0, fail = 0, snap0_ok, snap1_ok; struct staid_regs snap0, snap1; + /* The monitor filter Mt7612uRadio's RX loop installs - the pre-arm + * state every filter check below is measured against. */ + const uint32_t mon = MT_RX_FILTR_CFG_CRC_ERR | MT_RX_FILTR_CFG_PHY_ERR; + if (mt_eeprom_init(&dev)) return 1; if (mt_init_hardware(&dev, NULL)) return 1; + mt7612u_set_monitor_rx(&dev, 0); memcpy(own, dev.macaddr, 6); printf("=== GATE STAID: the SetStationIdentity contract on hardware ===\n"); @@ -5095,8 +5113,10 @@ static int gate_staid(void) } while (0) /* 1. the ordinary case - and the seam's defining property: arming - * writes nothing (MT_MAC_ADDR, MT_MAC_BSSID and all eight APC slots - * read the same before and after). */ + * writes no identity register (MT_MAC_ADDR, MT_MAC_BSSID and all eight + * APC slots read the same before and after), and installs the managed + * receive filter in place of the monitor one. */ + CHK(staid_filtr_is(mon), "pre-arm: the monitor receive filter"); snap0_ok = staid_snapshot(&snap0) == 0; CHK(mt7612u_set_station_identity(&dev, own, bssid) == 0, "arms with the factory address as own"); @@ -5104,8 +5124,16 @@ static int gate_staid(void) CHK(mt7612u_station_bssid(&dev, got) == 0 && memcmp(got, bssid, 6) == 0, "records the BSSID it was given"); CHK(staid_unchanged(&snap0, snap0_ok, &snap1, snap1_ok), - "arming writes no register (MT_MAC_ADDR, MT_MAC_BSSID, APC slots " - "read back unchanged)"); + "arming writes no identity register (MT_MAC_ADDR, MT_MAC_BSSID, " + "APC slots read back unchanged)"); + CHK(staid_filtr_is(MT_RX_FILTR_CFG_MANAGED), + "arming installs the managed receive filter 00015f97"); + + /* 1b. a receiver (re)started under the arm: the monitor request is + * recorded, not installed. */ + mt7612u_set_monitor_rx(&dev, 0); + CHK(staid_filtr_is(MT_RX_FILTR_CFG_MANAGED), + "a monitor-filter request while armed keeps the managed filter"); /* 2. an address this MAC is not holding */ CHK(mt7612u_set_station_identity(&dev, foreign, bssid) != 0, @@ -5123,14 +5151,25 @@ static int gate_staid(void) CHK(mt7612u_set_station_identity(&dev, own, own) != 0, "refuses own == bssid"); - /* 4. clear - which also writes nothing */ + CHK(staid_filtr_is(MT_RX_FILTR_CFG_MANAGED), + "the refusals left the (still armed) managed filter alone"); + + /* 4. clear - no identity register; the monitor filter back */ snap0_ok = staid_snapshot(&snap0) == 0; - mt7612u_clear_station_identity(&dev); + CHK(mt7612u_clear_station_identity(&dev) == 0, + "clear reports the pre-arm filter restored"); snap1_ok = staid_snapshot(&snap1) == 0; CHK(mt7612u_station_bssid(&dev, got) != 0, "reports no BSSID once cleared"); CHK(staid_unchanged(&snap0, snap0_ok, &snap1, snap1_ok), - "clearing writes no register (same registers read back unchanged)"); + "clearing writes no identity register (same registers read back " + "unchanged)"); + CHK(staid_filtr_is(mon), "clearing restores the monitor receive filter"); + + /* 4b. a refusal with nothing armed writes no filter either. */ + CHK(mt7612u_set_station_identity(&dev, foreign, bssid) != 0 && + staid_filtr_is(mon), + "a refused arm leaves the monitor filter untouched"); /* * 5. THE ONE THAT MATTERS. Arm an ACK responder on a foreign address - @@ -5164,6 +5203,8 @@ static int gate_staid(void) mt7612u_set_ack_responder(&dev, foreign) == 0) { CHK(mt7612u_station_bssid(&dev, got) != 0, "drops the armed station when a responder takes the identity"); + CHK(staid_filtr_is(mon), + "the drop gives the receiver back the monitor filter"); mt7612u_clear_ack_responder(&dev); } else { printf(" SKIP could not set up case 6\n"); diff --git a/src/sta/StationSm.h b/src/sta/StationSm.h index 5e489cb0..55bd3de3 100644 --- a/src/sta/StationSm.h +++ b/src/sta/StationSm.h @@ -322,10 +322,11 @@ class StationSm { * this station (or broadcast). Without the addr2 check, any frame from any * AP on the channel drives this machine. * - * THE DROPS ARE COUNTED. On real hardware this is the only address filter - * in the system - the MT7612U RX path runs promiscuous - so most of a busy - * channel lands here, and a station that connects to nothing has to be - * able to say whether it heard its AP at all. */ + * THE DROPS ARE COUNTED. On real hardware this is the main address + * filter - the MT7612U RX path runs promiscuous until a station identity + * is armed, and even its managed filter passes every BSS's broadcast - + * so most of a busy channel lands here, and a station that connects to + * nothing has to be able to say whether it heard its AP at all. */ if (std::memcmp(a2, bssid_, 6) != 0) { rx_not_our_bss++; return; } const bool to_us = std::memcmp(a1, own_, 6) == 0; const bool bcast = (a1[0] & 0x01) != 0; diff --git a/tests/mt7612u_station_selftest.cpp b/tests/mt7612u_station_selftest.cpp index 58f2ce1f..25db895c 100644 --- a/tests/mt7612u_station_selftest.cpp +++ b/tests/mt7612u_station_selftest.cpp @@ -21,6 +21,7 @@ #include #include "StationIdentity.h" +#include "regs.h" static int failures = 0; @@ -35,6 +36,9 @@ static int failures = 0; namespace { constexpr uint32_t kAutoRspEn = 1u << 0; /* MT_AUTO_RSP_EN */ +/* The monitor filter Mt7612uRadio's RX loop asks for (keep_corrupted off). */ +constexpr uint32_t kMonitor = MT_RX_FILTR_CFG_CRC_ERR | MT_RX_FILTR_CFG_PHY_ERR; +constexpr uint32_t kManaged = MT_RX_FILTR_CFG_MANAGED; const uint8_t kOwn[6] = {0x40, 0xa5, 0xef, 0x5a, 0x32, 0xf8}; const uint8_t kBssid[6] = {0x02, 0x42, 0x75, 0x05, 0xd6, 0xaa}; @@ -125,7 +129,7 @@ void test_ownership_handoff() { * not produce a diagnostic. */ CHECK(observe(&s, kOther, 1, 0) == MT7612U_STA_EV_NONE); - mt7612u_sta_arm(&s, kOwn, kBssid); + mt7612u_sta_arm(&s, kOwn, kBssid, kMonitor); CHECK(s.armed == 1); CHECK(std::memcmp(s.bssid, kBssid, 6) == 0); CHECK(std::memcmp(s.own, kOwn, 6) == 0); @@ -157,7 +161,7 @@ void test_ownership_handoff() { /* Re-arming after the responder gives it back works, and the recorded * BSSID is the new one rather than a survivor of the previous arm. */ - mt7612u_sta_arm(&s, kOwn, kOther); + mt7612u_sta_arm(&s, kOwn, kOther, kMonitor); CHECK(s.armed == 1); CHECK(s.lost == 0); CHECK(std::memcmp(s.bssid, kOther, 6) == 0); @@ -171,7 +175,7 @@ void test_ownership_handoff() { * back, and a cleared station is never resurrected. */ void test_failed_start_restores_the_arm() { mt7612u_sta_state s{}; - mt7612u_sta_arm(&s, kOwn, kBssid); + mt7612u_sta_arm(&s, kOwn, kBssid, kMonitor); CHECK(observe(&s, kOther, 1, 0) == MT7612U_STA_EV_DROPPED); /* The unwind did not land (still elsewhere) or cannot be read: no. */ @@ -195,7 +199,7 @@ void test_failed_start_restores_the_arm() { * callers, and a stale value would name a BSS this station is not on. */ void test_clear_wipes_the_bssid() { mt7612u_sta_state s{}; - mt7612u_sta_arm(&s, kOwn, kBssid); + mt7612u_sta_arm(&s, kOwn, kBssid, kMonitor); mt7612u_sta_clear(&s); CHECK(std::memcmp(s.bssid, kZero, 6) == 0); CHECK(std::memcmp(s.own, kZero, 6) == 0); @@ -209,6 +213,73 @@ void test_check_args_needs_no_device() { CHECK(mt7612u_sta_check_args(kOwn, kOwn) == MT7612U_STA_SAME_ADDR); } +/* The managed filter, bit by bit (these are DROP bits). What a station needs + * to keep hearing must stay clear; what the cells measured must stay set. */ +void test_managed_filter_bits() { + CHECK(kManaged == 0x00015f97u); + /* kept: every BSS's beacons and group traffic (a re-scan, a re-join), + * broadcast and multicast, PS-Poll and BAR */ + CHECK(!(kManaged & MT_RX_FILTR_CFG_OTHER_BSS)); + CHECK(!(kManaged & MT_RX_FILTR_CFG_BCAST)); + CHECK(!(kManaged & MT_RX_FILTR_CFG_MCAST)); + CHECK(!(kManaged & MT_RX_FILTR_CFG_PSPOLL)); + CHECK(!(kManaged & MT_RX_FILTR_CFG_BAR)); + /* dropped: unicast not addressed to MT_MAC_ADDR - the bit that makes a + * moved port identity a deaf station - and the rest */ + CHECK(kManaged & MT_RX_FILTR_CFG_PROMISC); + CHECK(kManaged & MT_RX_FILTR_CFG_CRC_ERR); + CHECK(kManaged & MT_RX_FILTR_CFG_DUP); + CHECK(kManaged & MT_RX_FILTR_CFG_ACK); + /* and the monitor filter is the other extreme: no address or BSS drop */ + CHECK(!(kMonitor & (MT_RX_FILTR_CFG_PROMISC | MT_RX_FILTR_CFG_OTHER_BSS | + MT_RX_FILTR_CFG_DUP))); +} + +/* Who owns MT_RX_FILTR_CFG, and what goes back when the station lets go. */ +void test_rx_filter_ownership() { + mt7612u_sta_state s{}; + + /* No station: a request is installed as asked, and nothing is recorded. */ + CHECK(mt7612u_sta_rx_filter_request(&s, kMonitor, kManaged) == kMonitor); + CHECK(s.rx_filtr_restore == 0); + + /* The arm records what the register held. */ + mt7612u_sta_arm(&s, kOwn, kBssid, kMonitor); + CHECK(s.rx_filtr_restore == kMonitor); + + /* A RE-arm reads the managed filter the first arm installed; recording + * THAT would leave the receiver managed after the clear. */ + mt7612u_sta_arm(&s, kOwn, kOther, kManaged); + CHECK(s.rx_filtr_restore == kMonitor); + + /* A receiver restarted under the arm asks for the monitor filter again + * (keep_corrupted on this time): the managed filter stays, and the new + * request is what the clear will put back. */ + const uint32_t keep = MT_RX_FILTR_CFG_PHY_ERR; + CHECK(mt7612u_sta_rx_filter_request(&s, keep, kManaged) == kManaged); + CHECK(s.rx_filtr_restore == keep); + + /* Dropped by a port move: requests are installed again, and still + * recorded, because a restore re-arms and its clear must put back the + * latest. */ + CHECK(mt7612u_sta_port_observed(&s, MT7612U_PORT_DIFFERENT, 0) == + MT7612U_STA_EV_DROPPED); + CHECK(mt7612u_sta_rx_filter_request(&s, kMonitor, kManaged) == kMonitor); + CHECK(s.rx_filtr_restore == kMonitor); + CHECK(mt7612u_sta_port_observed(&s, MT7612U_PORT_SAME, 1) == + MT7612U_STA_EV_RESTORED); + CHECK(mt7612u_sta_rx_filter_request(&s, kMonitor, kManaged) == kManaged); + + /* Cleared: back to installing requests as asked. */ + mt7612u_sta_clear(&s); + CHECK(mt7612u_sta_rx_filter_request(&s, kMonitor, kManaged) == kMonitor); + CHECK(s.rx_filtr_restore == 0); + + /* An arm after a clear records afresh. */ + mt7612u_sta_arm(&s, kOwn, kBssid, keep); + CHECK(s.rx_filtr_restore == keep); +} + } // namespace int main() { @@ -222,6 +293,8 @@ int main() { test_failed_start_restores_the_arm(); test_clear_wipes_the_bssid(); test_check_args_needs_no_device(); + test_managed_filter_bits(); + test_rx_filter_ownership(); if (failures) { std::fprintf(stderr, "mt7612u_station_selftest: %d failure(s)\n", failures); From 7553dc54aa6e0e59fa17daff6371e5947b6fca1e Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 09:34:10 +0200 Subject: [PATCH 03/53] tests: generic adapter record / hand-back in the station lib tests/realtek_station_onair.sh carried its own rt_record / rt_opened / rt_handback for adapters devourer opens over libusb, and the lib carried the same three for PEER_SYSFS only. They are now one set in tests/mt7612u_sta_lib.sh, kept per NAME in files in OUT (POSIX, and a process started in a command substitution can still mark a device opened): sta_dev_record NAME SYSFS VID PID refuse a hub or the wrong VID:PID, note idVendor:idProduct:serial sta_dev_opened NAME just before a process opens it sta_dev_handback NAME SYSFS authorized 0/1 toggle, only when this run opened it and SYSFS still names the recorded device; idempotent sta_peer_record / _opened / _handback stay as thin wrappers, so the mt7612u_sta_* harnesses are unchanged. sta_dev_unbind_wifi SYSFS unbinds the kernel driver from every interface of a device that carries a wireless netdev (rtw88, an out-of-tree rtl88x2*, mt76x2u; not a composite adapter's Bluetooth interface) and refuses if one is left bound. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_lib.sh | 122 +++++++++++++++++++++------------ tests/realtek_station_onair.sh | 53 +++----------- 2 files changed, 88 insertions(+), 87 deletions(-) diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 45c8a338..e300fae2 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -1,7 +1,8 @@ # shellcheck shell=sh # mt7612u_sta_lib.sh - shared plumbing for the station harnesses -# (tests/mt7612u_sta_identity.sh, _autoack.sh, _uplink.sh, _onair.sh; the -# generic helpers also serve tests/realtek_station_onair.sh). Sourced, not run. +# (tests/mt7612u_sta_identity.sh, _autoack.sh, _uplink.sh; the generic helpers +# also serve tests/sta_client_onair.sh and tests/realtek_station_onair.sh). +# Sourced, not run. # # Four rules these scripts run as root under: # @@ -18,9 +19,10 @@ # - Kill only what this run started, by recorded PID. No pattern kills, and # no PID read from a file an earlier run left behind: sta_pid_init() # removes stale PID files before anything is started. -# - Hand every adapter back: the Realtek peer through sta_peer_handback() -# (below), the AP in tests/mt7612u_sta_identity.sh's cleanup, and the DUT -# here. The harnesses unbind the MT7612U from mt76x2u so +# - Hand every adapter back: a devourer-opened adapter through +# sta_dev_handback() (below; sta_peer_handback() is its PEER_SYSFS +# form), the AP in tests/mt7612u_sta_identity.sh's cleanup, and the +# MT7612U DUT here. The harnesses unbind the MT7612U from mt76x2u so # mt7612uprobe can claim it; sta_dut_handback() re-enumerates it with an # `authorized` 0/1 toggle so the kernel driver binds again, exactly as # tests/mt7612u_ap_onair.sh does - and only after confirming the path @@ -191,57 +193,87 @@ sta_usb_id() { "$(cat "/sys/bus/usb/devices/$1/serial" 2>/dev/null)" } -# The Realtek peer (PEER_SYSFS) is opened by txdemo / rxdemo, whose libusb -# open detaches its kernel driver and never re-attaches it. sta_peer_record() -# checks and notes the peer's identity before the run; sta_peer_opened() marks -# it touched (a file in OUT, because the peer is started inside a command -# substitution whose variables the parent never sees) just before a peer -# process starts; sta_peer_handback() re-enumerates it with an `authorized` -# 0/1 toggle so its driver binds again - only when this run did open it, and -# only while PEER_SYSFS still reports the recorded identity, so a device that -# replaced it at the same path is left alone. -STA_PEER_ID="" -# The peer must be the adapter the run was told about: PEER_VID:PEER_PID at -# PEER_SYSFS, not a hub, not the DUT's path. Checked before anything runs. -sta_peer_record() { - _sta_pd="/sys/bus/usb/devices/$PEER_SYSFS" - if [ "$PEER_SYSFS" = "$DUT_SYSFS" ]; then - echo "refusing PEER_SYSFS=$PEER_SYSFS - it is the DUT's path"; return 1 +# An adapter devourer opens over libusb (a Realtek DUT or peer): the libusb +# open detaches its kernel driver and nothing re-attaches it. Each is kept +# under a NAME, in files in OUT (a process started inside a command +# substitution sets them too, and its variables never reach the parent): +# sta_dev_record NAME SYSFS VID PID - before the run: refuse a hub and any +# device that is not VID:PID, and note its idVendor:idProduct:serial; +# sta_dev_opened NAME - just before a process opens it; +# sta_dev_handback NAME SYSFS - re-enumerate it with an `authorized` 0/1 +# toggle so its kernel driver binds again - only when this run opened it, +# and only while SYSFS still reports the recorded identity, so a device +# that replaced it at the same path is left alone. Idempotent. +sta_dev_record() { + _sta_dd="/sys/bus/usb/devices/$2" + if [ "$(cat "$_sta_dd/bDeviceClass" 2>/dev/null)" = "09" ]; then + echo "refusing $1 at $2 - a hub"; return 1 fi - if [ "$(cat "$_sta_pd/bDeviceClass" 2>/dev/null)" = "09" ]; then - echo "refusing PEER_SYSFS=$PEER_SYSFS - a hub"; return 1 - fi - _sta_want=$(printf '%04x:%04x' "$((PEER_VID))" "$((PEER_PID))" 2>/dev/null) - _sta_have="$(cat "$_sta_pd/idVendor" 2>/dev/null):$(cat "$_sta_pd/idProduct" 2>/dev/null)" + _sta_want=$(printf '%04x:%04x' "$(($3))" "$(($4))" 2>/dev/null) + _sta_have="$(cat "$_sta_dd/idVendor" 2>/dev/null):$(cat "$_sta_dd/idProduct" 2>/dev/null)" if [ "$_sta_have" != "$_sta_want" ]; then - echo "refusing PEER_SYSFS=$PEER_SYSFS - it reports $_sta_have, not" \ - "PEER_VID:PEER_PID $_sta_want" - return 1 + echo "refusing $1 at $2 - it reports $_sta_have, not $_sta_want"; return 1 fi - STA_PEER_ID=$(sta_usb_id "$PEER_SYSFS") - rm -f "$OUT/.peer_opened" + sta_usb_id "$2" > "$OUT/.id_$1" + rm -f "$OUT/.opened_$1" return 0 } -sta_peer_opened() { : > "$OUT/.peer_opened"; } +sta_dev_opened() { : > "$OUT/.opened_$1"; } -sta_peer_handback() { - [ -n "$STA_PEER_ID" ] || return 0 - _sta_peer_id=$STA_PEER_ID - STA_PEER_ID="" - if [ ! -e "$OUT/.peer_opened" ]; then +sta_dev_handback() { + [ -f "$OUT/.id_$1" ] || return 0 + _sta_id=$(cat "$OUT/.id_$1" 2>/dev/null) + rm -f "$OUT/.id_$1" + [ -e "$OUT/.opened_$1" ] || return 0 + rm -f "$OUT/.opened_$1" + if [ "$(sta_usb_id "$2")" != "$_sta_id" ]; then + echo "$1 path $2 no longer names the recorded device ($_sta_id) -" \ + "not re-enumerating it" return 0 fi - rm -f "$OUT/.peer_opened" - if [ "$(sta_usb_id "$PEER_SYSFS")" != "$_sta_peer_id" ]; then - echo "PEER_SYSFS=$PEER_SYSFS no longer names the recorded peer" \ - "($_sta_peer_id) - not re-enumerating it" - return 0 - fi - echo 0 > "/sys/bus/usb/devices/$PEER_SYSFS/authorized" 2>/dev/null + echo 0 > "/sys/bus/usb/devices/$2/authorized" 2>/dev/null sleep 2 - echo 1 > "/sys/bus/usb/devices/$PEER_SYSFS/authorized" 2>/dev/null + echo 1 > "/sys/bus/usb/devices/$2/authorized" 2>/dev/null +} + +# Unbind the kernel driver from every interface of the USB device at $1 that +# carries a wireless netdev (rtw88, an out-of-tree rtl88x2*, mt76x2u - and +# not a composite adapter's Bluetooth interface), then require that none is +# left: a driver still bound would own the chip under devourer. Nothing +# bound is fine. Hand the device back with sta_dev_handback. +sta_dev_unbind_wifi() { + for _sta_if in "/sys/bus/usb/devices/$1:"*; do + [ -e "$_sta_if/driver" ] || continue + for _sta_n in "$_sta_if/net/"*; do + [ -e "$_sta_n/phy80211" ] || continue + basename "$_sta_if" > "$_sta_if/driver/unbind" 2>/dev/null + break + done + done + sleep 2 + for _sta_if in "/sys/bus/usb/devices/$1:"*; do + for _sta_n in "$_sta_if/net/"*; do + if [ -e "$_sta_n/phy80211" ]; then + echo "could not free $1: $(basename "$_sta_if") still carries" \ + "$(basename "$_sta_n") ($(basename "$(readlink -f "$_sta_if/driver")"))" + return 1 + fi + done + done + return 0 +} + +# The Realtek peer of the MT7612U harnesses: the sta_dev_* helpers on +# PEER_SYSFS / PEER_VID:PEER_PID, which must not be the DUT's path. +sta_peer_record() { + if [ "$PEER_SYSFS" = "$DUT_SYSFS" ]; then + echo "refusing PEER_SYSFS=$PEER_SYSFS - it is the DUT's path"; return 1 + fi + sta_dev_record peer "$PEER_SYSFS" "$PEER_VID" "$PEER_PID" } +sta_peer_opened() { sta_dev_opened peer; } +sta_peer_handback() { sta_dev_handback peer "$PEER_SYSFS"; } # mt7612uprobe loads its firmware from ./firmware. sta_fw_link() creates # $ROOT/firmware -> FW_DIR only when nothing is there - not even a dangling diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index 3da2613c..2ae7b644 100755 --- a/tests/realtek_station_onair.sh +++ b/tests/realtek_station_onair.sh @@ -161,48 +161,17 @@ sta_pid_init dut peer hostapd # --- adapter identity and hand-back ----------------------------------------- # The DUT and the PEER are opened by rxdemo / txdemo, whose libusb open -# detaches the kernel driver and never re-attaches it. Each is recorded -# (idVendor:idProduct:serial) before anything runs, marked touched just -# before a process opens it, and handed back with an `authorized` 0/1 toggle -# only when this run touched it AND the path still names the recorded device. -declare -A RT_ID=() -rt_record() { # $1 name, $2 sysfs, $3 vid, $4 pid - local d="/sys/bus/usb/devices/$2" want have - if [ "$(cat "$d/bDeviceClass" 2>/dev/null)" = "09" ]; then - echo "refusing $1 at $2 - a hub"; return 1 - fi - want=$(printf '%04x:%04x' "$(($3))" "$(($4))" 2>/dev/null) - have="$(cat "$d/idVendor" 2>/dev/null):$(cat "$d/idProduct" 2>/dev/null)" - if [ "$have" != "$want" ]; then - echo "refusing $1 at $2 - it reports $have, not $want"; return 1 - fi - RT_ID[$1]=$(sta_usb_id "$2") - rm -f "$OUT/.opened_$1" -} -rt_opened() { : > "$OUT/.opened_$1"; } -rt_handback() { # $1 name, $2 sysfs - [ -n "${RT_ID[$1]:-}" ] || return 0 - local id=${RT_ID[$1]} - RT_ID[$1]="" - [ -e "$OUT/.opened_$1" ] || return 0 - rm -f "$OUT/.opened_$1" - if [ "$(sta_usb_id "$2")" != "$id" ]; then - echo "$1 path $2 no longer names the recorded device ($id) - not re-enumerating it" - return 0 - fi - echo 0 > "/sys/bus/usb/devices/$2/authorized" 2>/dev/null - sleep 2 - echo 1 > "/sys/bus/usb/devices/$2/authorized" 2>/dev/null -} +# detaches the kernel driver and never re-attaches it: each goes through the +# lib's sta_dev_record / sta_dev_opened / sta_dev_handback. if [ "$DUT_SYSFS" = "${PEER_SYSFS:-x}" ] || [ "$DUT_SYSFS" = "${AP_SYSFS:-x}" ] || { [ -n "$PEER_SYSFS" ] && [ "$PEER_SYSFS" = "${AP_SYSFS:-x}" ]; }; then echo "DUT_SYSFS, PEER_SYSFS and AP_SYSFS must be three different adapters" sta_lock_release; exit 2 fi -rt_record dut "$DUT_SYSFS" "$DUT_VID" "$DUT_PID" || { sta_lock_release; exit 2; } +sta_dev_record dut "$DUT_SYSFS" "$DUT_VID" "$DUT_PID" || { sta_lock_release; exit 2; } if [ "$HALF" != up ]; then - rt_record peer "$PEER_SYSFS" "$PEER_VID" "$PEER_PID" || { sta_lock_release; exit 2; } + sta_dev_record peer "$PEER_SYSFS" "$PEER_VID" "$PEER_PID" || { sta_lock_release; exit 2; } fi AP_IF="" @@ -219,9 +188,9 @@ cleanup() { sta_pid_kill hostapd # Only once a process has really exited: re-enumerating an adapter still # inside its de-init is what the hand-back must not do. - if [ "$dut_gone" = 0 ]; then rt_handback dut "$DUT_SYSFS" + if [ "$dut_gone" = 0 ]; then sta_dev_handback dut "$DUT_SYSFS" else echo "DUT still running - not re-enumerating $DUT_SYSFS"; fi - if [ "$peer_gone" = 0 ]; then rt_handback peer "${PEER_SYSFS:-}" + if [ "$peer_gone" = 0 ]; then sta_dev_handback peer "${PEER_SYSFS:-}" else echo "peer still running - not re-enumerating $PEER_SYSFS"; fi if [ "$AP_REENUM" = yes ]; then # hostapd's `bssid=` leaves the interface carrying that address after it @@ -376,7 +345,7 @@ down_arm() { armed) sta_env="DEVOURER_STA_IDENTITY=self,$BSSID" ;; cleared) sta_env="DEVOURER_STA_IDENTITY=self,$BSSID DEVOURER_STA_CLEAR_AFTER_MS=$CLEAR_AFTER_MS" ;; esac - rt_opened dut + sta_dev_opened dut # shellcheck disable=SC2046,SC2086 # word-split assignments on purpose env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" $(sel_env "$DUT_SYSFS") \ DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info \ @@ -410,7 +379,7 @@ down_arm() { fi t0=$(date +%s) - rt_opened peer + sta_dev_opened peer # shellcheck disable=SC2046 # word-split assignments on purpose env DEVOURER_VID="$PEER_VID" DEVOURER_PID="$PEER_PID" $(sel_env "$PEER_SYSFS") \ DEVOURER_CHANNEL="$CH" \ @@ -460,7 +429,7 @@ up_arm() { # $1 tag, $2 RA, $3 armed | unarmed (default armed) : > "$res" [ "$mode" = armed ] && sta_env="DEVOURER_STA_IDENTITY=$OWN,$BSSID" t0=$(date +%s) - rt_opened dut + sta_dev_opened dut # shellcheck disable=SC2046,SC2086 # word-split assignments on purpose env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" $(sel_env "$DUT_SYSFS") \ DEVOURER_CHANNEL="$CH" $sta_env \ @@ -530,7 +499,7 @@ OWN="${DUT_MAC:-}" # learned from a short armed rxdemo run's sta.arm event - which also proves, # before any arm is scored, that the seam arms on this DUT at all. if [ -z "$OWN" ]; then - rt_opened dut + sta_dev_opened dut # shellcheck disable=SC2046 # word-split assignments on purpose env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" $(sel_env "$DUT_SYSFS") \ DEVOURER_CHANNEL="$CH" DEVOURER_LOG_LEVEL=info \ @@ -620,7 +589,7 @@ if [ "$HALF" != up ]; then inc "CLEAR: arm E (or D) aborted or carried under $MIN_REPORTS reports" fi # The DOWN half no longer needs the peer: hand it back now. - rt_handback peer "$PEER_SYSFS" + sta_dev_handback peer "$PEER_SYSFS" fi # =============================== UP ========================================= From 6299728a62076eddc3bf6d09cf73f25b3adaeb17 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 09:34:24 +0200 Subject: [PATCH 04/53] tests: station on-air cell for Realtek DUTs, a reconnect cell, ping windows tests/mt7612u_sta_onair.sh becomes tests/sta_client_onair.sh: the DUT is an MT7612U, an RTL8812CU (8822C) or an RTL8812BU (8822B) - the dies whose station_mode_ok is true - and the AP any netns-movable in-kernel adapter, e.g. the MT7612U on mt76x2u. - DUT take / hand-back by die: the MT7612U keeps sta_dut_take; a Realtek DUT goes through the lib's sta_dev_record / sta_dev_opened / sta_dev_unbind_wifi / sta_dev_handback, with the same rule - never re-enumerated while sta_client is alive. sta_client gets the die by DEVOURER_VID / DEVOURER_PID from the DUT's sysfs identity. - noarm is a real control on Realtek, where the arm writes the port registers and unarmed the MAC does not ACK own-addressed unicast: scored that the station tried (beacons seen, authentication sent) and the AP did NOT complete the four-way; a completed one FAILs. On MT7612U the arm writes nothing and the link stays information. - The clear is scored as verified on Realtek, information on MT7612U. - reconnect: hostapd stopped for DOWN_S and restarted; scored the lost link reported, a second four-way within REJOIN_S, ping 0% over a PING_S window, ledger 2 associations / 1 reconnect, exactly one arm across the re-join (the arm is per BSSID and stays in place), the clear. noreconnect: the same with DEVOURER_STA_RECONNECT=0 - lost link reported, no re-join, ledger ends Failed after 1 association. - noarm and retry0 report their link over a PING_S ping window (2/s) instead of six pings. sta_client logs each association ("station connected (association N)") and each failure ("station link lost: " / "station join failed: "), so a re-join is visible while the run lasts. docs/station-client.md documents the cells, the per-die arm scoring and the re-join policy, including DEVOURER_STA_RECONNECT=0. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- CMakeLists.txt | 2 +- docs/station-client.md | 56 ++- tests/sta_client.cpp | 18 +- ...7612u_sta_onair.sh => sta_client_onair.sh} | 319 +++++++++++++++--- tests/sta_client_selftest.inc | 2 +- 5 files changed, 318 insertions(+), 79 deletions(-) rename tests/{mt7612u_sta_onair.sh => sta_client_onair.sh} (61%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 393d7c7e..736dfeb8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1050,7 +1050,7 @@ if(OpenSSL_FOUND) # tests/sta_client.cpp - the station client over IRadio + src/sta: scan, # join, the WPA2-PSK four-way, CCMP and a TAP data plane. Built under its - # real name for tests/mt7612u_sta_onair.sh; the target name ends in + # real name for tests/sta_client_onair.sh; the target name ends in # "Selftest" so the `selftests` aggregate collects it, and `--self-test` # runs its headless cells before libusb is touched (no adapter, no root). # Linux-only: the data plane is a TAP device (). diff --git a/docs/station-client.md b/docs/station-client.md index 8e7b917b..aa3ad3ce 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -50,7 +50,16 @@ SIGINT/SIGTERM (handled from the start of `main`, so a stop during bring-up ends the run once the bring-up returns) leave the BSS, clear the identity and print the ledger. The ledger is printed at every exit once `sta_client up:` has printed, and separates "heard nothing", "heard another BSS" and "our AP -refused us". +refused us". While it runs, the station also logs each association +(`station connected (association N)`) and each failure (`station link lost: +` or `station join failed: `). + +Re-join policy: after a lost link or a failed join the station waits +`DEVOURER_STA_BACKOFF_MS` and joins again, for as long as the run lasts. +With `DEVOURER_STA_RECONNECT=0` the first failure - a lost link, or a first +join that fails - ends the attempts: the station logs it, stays +unassociated until its time is up, and the ledger ends `Failed` with the +reason. Exit status: 0 the run completed; 1 setup failed; 2 refused (`station_mode_ok` false, or the duration, `DEVOURER_CHANNEL`, @@ -68,33 +77,44 @@ first line then reads `fault=1`. policy, key selection by key id, replay and duplicate windows, PTK/GTK rekeys, plaintext/fragment/A-MSDU refusal, the FCS trim, the ledger's identities. No device, no root. -- **On air** - `tests/mt7612u_sta_onair.sh` against hostapd in a network - namespace, MT7612U as the station: +- **On air** - `tests/sta_client_onair.sh` against hostapd in a network + namespace. The station (DUT) is an MT7612U, an RTL8812CU (8822C) or an + RTL8812BU (8822B); the AP is any adapter whose in-kernel driver supports AP + mode and can change network namespace (`iw phy info` lists + `set_wiphy_netns`: mt76, rtw88 - not the out-of-tree rtl88x2cu / 88x2bu, + which the cell refuses). | Cell | Scored | |---|---| - | `open` | AP associates our address; ping 0% loss over the TAP; ledger plaintext only; armed; the clear ran on exit | - | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; MIC failures <= PTK installs; armed; the clear ran on exit; no `tx.retry_limit=0` warning | - | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran (link outcome reported, not scored) | - | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear ran on exit (link outcome reported, not scored) | - - The clear's result is printed as information, not scored: on MT7612U - `ClearStationIdentity` is trivially true. + | `open` | AP associates our address; ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning | + | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs. MT7612U: the link over a 30 s ping window is reported, not scored | + | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | + | `reconnect` | hostapd stopped and restarted: the station reports the lost link; second four-way within the bound; ping 0% loss over a 30 s window; ledger 2 associations, 1 reconnect; one arm across the re-join; the clear | + | `noreconnect` | as `reconnect` with `DEVOURER_STA_RECONNECT=0`: the lost link reported; no re-join; the ledger ends Failed after 1 association | + + The arm differs by die. On MT7612U it writes no register, so an unarmed + link may work and `ClearStationIdentity` is trivially true: the clear is + scored as having run and its result is information. On a Realtek die the + arm writes the port registers and unarmed the MAC does not acknowledge + own-addressed unicast (`docs/realtek-station-arm.md`), so `noarm` is a + control that can fail and the clear must verify. + + The arm is per BSSID: a re-join to the same BSSID keeps it rather than + arming again, and on Realtek the second association is the proof it still + holds. Exit 0 pass, 1 fail (including a station fault, exit 3, with its cause named), 2 inconclusive (rig refused, AP not up, route not through the TAP, the station exited or stalled before `sta_client up:`, station out of - time), 3 interrupted. `FW_DIR` must hold the decompressed MT7612U blobs. The AP's phy must be able to change - network namespace (`iw phy info` lists `set_wiphy_netns`): an - in-kernel cfg80211 driver such as rtw88 or mt76. Out-of-tree drivers such - as rtl88x2cu / 88x2bu cannot, and the cell refuses them. + time), 3 interrupted. `FW_DIR` (an MT7612U DUT) must hold the decompressed + MT7612U blobs. ## What it does not do -- The on-air cell takes an MT7612U only (`sta_dut_take`) until a generic - DUT take / hand-back exists. The client itself arms a Realtek 8822C / - 8822B selected with `DEVOURER_VID` / `DEVOURER_PID`; that path is not - covered by an on-air cell here. +- The on-air cell covers the dies that report `station_mode_ok` (MT7612U, + 8822C, 8822B). Another Realtek die can be named with `DUT_VID` / `DUT_PID`; + `sta_client` refuses it (exit 2) unless it reports `station_mode_ok`. - Software CCMP only; no PMF/802.11w, WPA2-PSK/CCMP or open only. - No fragment reassembly and no A-MSDU: both are refused and counted. - One BSS at a time, chosen by SSID; no roaming and no background scan while diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 0da22a64..bfdf5a68 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -58,7 +58,7 @@ * DEVOURER_STA_TAP=dvsta0 build/sta_client 60 * Headless (no device, no root, no airtime): * build/sta_client --self-test - * On air: tests/mt7612u_sta_onair.sh. + * On air: tests/sta_client_onair.sh. */ #include #include @@ -296,7 +296,11 @@ void on_association() { g_rx_dup.reset(); g_failed_noted = false; g_was_associated = true; - g_associations.fetch_add(1); + const uint64_t n = g_associations.fetch_add(1) + 1; + /* One line per association, so a re-join is visible while the run lasts + * and not only in the exit ledger. */ + std::fprintf(stderr, " station connected (association %llu)\n", + (unsigned long long)n); } /* A REKEY RESTARTS A PN SPACE, and the windows restart with it - both @@ -612,8 +616,12 @@ void probe(uint8_t chan) { enqueue(std::move(m)); } +const char* fail_name(StationSm::Failure f); + /* The join and re-join policy. Returns the channel the radio should be tuned - * to. Caller must NOT hold g_mu. */ + * to. Caller must NOT hold g_mu. With DEVOURER_STA_RECONNECT=0 the first + * failure - a lost link or a failed first join - ends the attempts: the run + * then idles, unassociated, until its time is up. */ uint8_t supervise(uint32_t now) { std::lock_guard l(g_mu); @@ -626,6 +634,10 @@ uint8_t supervise(uint32_t now) { /* The transition INTO Failed, handled once. */ if (st == StationSm::State::Failed && !g_failed_noted) { g_failed_noted = true; + std::fprintf(stderr, " station %s: %s%s\n", + g_was_associated ? "link lost" : "join failed", + fail_name(g_sm.fail_reason()), + g_reconnect ? "" : " - DEVOURER_STA_RECONNECT=0, not re-joining"); /* Counted apart from a first join, once per lost link. */ if (g_was_associated) { g_was_associated = false; diff --git a/tests/mt7612u_sta_onair.sh b/tests/sta_client_onair.sh similarity index 61% rename from tests/mt7612u_sta_onair.sh rename to tests/sta_client_onair.sh index f4bc009d..06db5ae8 100755 --- a/tests/mt7612u_sta_onair.sh +++ b/tests/sta_client_onair.sh @@ -1,13 +1,15 @@ #!/usr/bin/env bash -# mt7612u_sta_onair.sh - tests/sta_client.cpp joining a real hostapd AP, end +# sta_client_onair.sh - tests/sta_client.cpp joining a real hostapd AP, end # to end, with the station identity armed through IRadio::SetStationIdentity. # -# The MT7612U (DUT_SYSFS) runs sta_client: scan, authenticate, associate, the -# WPA2-PSK four-way and CCMP over src/sta/, a TAP device for the host. A -# kernel-driven adapter (AP_SYSFS) runs hostapd - an independent -# implementation on independent silicon, which is what makes its log a -# witness: "EAPOL-4WAY-HS-COMPLETED" means the AUTHENTICATOR verified our -# message 4's MIC. +# The DUT (DUT_SYSFS) runs sta_client: scan, authenticate, associate, the +# WPA2-PSK four-way and CCMP over src/sta/, a TAP device for the host. It is +# an MT7612U (0e8d:7612) or a Realtek die whose AdapterCaps::station_mode_ok +# is true - the 8822C (RTL8812CU, 0bda:c812) and the 8822B (RTL8812BU, +# 0bda:b812). A kernel-driven adapter (AP_SYSFS) runs hostapd - an +# independent implementation on independent silicon, which is what makes its +# log a witness: "EAPOL-4WAY-HS-COMPLETED" means the AUTHENTICATOR verified +# our message 4's MIC. The AP need not be a Realtek. # # THE AP LIVES IN A NETWORK NAMESPACE. Both radios are on one host; with both # addresses in the root namespace the kernel routes the ping locally and it @@ -15,29 +17,47 @@ # every data-plane check first asserts that `ip route get` leaves through the # station's TAP. # +# THE ARM DIFFERS BY DIE. On MT7612U the arm writes no register (it checks +# MT_MAC_ADDR and the auto-responder), so DEVOURER_STA_ARM=0 changes nothing +# and ClearStationIdentity is trivially true: both are information there. On +# a Realtek die the arm WRITES the port registers (docs/realtek-station-arm.md) +# and unarmed the MAC does not acknowledge own-addressed unicast, so the AP's +# authentication response is never ACKed and hostapd never lets the station +# in: `noarm` is a real control, and the clear's verification is scored. +# # Cells (each scored against its own witness): # open hostapd open: the AP associates OUR address; ping 0% loss over # the TAP; the ledger shows plaintext and no decryption; the arm -# line, and the clear ran on exit. +# line; the clear ran on exit (verified, on Realtek). # wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both -# rekeys completed at the AP, ping 0% loss, ONE association -# throughout, MIC failures <= PTK installs (a pairwise rekey has a -# one-frame switchover window, see rx_frame() in sta_client.cpp), -# the arm line, the clear ran on exit, and NO tx.retry_limit=0 -# warning (the station default is nonzero). +# rekeys completed at the AP, ping 0% loss before and after them, +# ONE association throughout, MIC failures <= PTK installs (a +# pairwise rekey has a one-frame switchover window, see rx_frame() +# in sta_client.cpp), the arm line, the clear (as for open), and +# NO tx.retry_limit=0 warning (the station default is nonzero). # noarm the control: wpa2 with DEVOURER_STA_ARM=0 - nothing else -# changes. Scored: no SetStationIdentity and no clear ran. -# Reported, not scored: whether it associated and carried the -# ping (the MT7612U arm writes no register - docs/station-client.md). +# changes. Scored everywhere: no SetStationIdentity and no clear +# ran. On Realtek also scored: the station tried (beacons seen, +# authentication sent) and the AP did NOT complete the four-way +# for it within 30 s - a completed four-way is a FAIL, because then +# the arm is not what makes the wpa2 cell work. On MT7612U the link +# is reported over a PING_S ping window, not scored. # retry0 wpa2 with DEVOURER_TX_RETRY_LIMIT=0. Scored: the library's # arm-time warning about tx.retry_limit=0 (logged inside a # successful SetStationIdentity, so just before sta_client's -# "armed" line), and the clear ran on exit. -# Reported, not scored: the link outcome with a single-shot uplink. -# -# The clear is scored as having RUN, not by its result: on MT7612U -# ClearStationIdentity is trivially true (the arm wrote nothing), so the -# result is printed as information. +# "armed" line), and the clear. Reported, not scored: the link +# over a PING_S ping window with a single-shot uplink. +# reconnect hostapd WPA2-PSK, stopped for DOWN_S and restarted with the +# same configuration. Scored: the station reports the lost link; +# the AP completes a second four-way for it within REJOIN_S of +# coming back; ping 0% loss over a PING_S window after the +# re-join; the ledger counts 2 associations and 1 reconnect; +# exactly ONE arm (the arm is per BSSID and stays in place across +# a re-join to the same BSSID - on Realtek the second association +# is itself the proof that it still holds); the clear on exit. +# noreconnect reconnect with DEVOURER_STA_RECONNECT=0: the station reports +# the lost link, the AP sees NO second four-way within REJOIN_S, +# and the ledger ends Failed with 1 association. # # Liveness: a data-plane check runs only while sta_client is alive, and again # checks it afterwards - a ping that straddles the station's exit reports @@ -45,30 +65,37 @@ # READY_TIMEOUT, before printing `sta_client up:` is a rig / bring-up problem # (INCONCLUSIVE, whatever its status). After `up:`, an exit with status 0 # ran out of SECS (INCONCLUSIVE); 3 is a FAULT the station caught (an -# exception or a failed TAP; `fault=1` in its ledger) and is a FAIL with the -# cause named, wherever in the cell it happens; any other status is a FAIL. +# exception, a failed TAP or an unverified clear; `fault=1` in its ledger) +# and is a FAIL with the cause named, wherever in the cell it happens; any +# other status is a FAIL. # # Exit status: 0 every scored check passed; 1 a check failed; 2 INCONCLUSIVE # (the rig was refused, the station did not come up, the AP did not come up, # the route did not leave through the TAP, or a cell was cut short); # 3 interrupted. # -# sudo DUT_SYSFS=1-1 AP_SYSFS=5-1 tests/mt7612u_sta_onair.sh -# sudo DUT_SYSFS=1-1 AP_SYSFS=5-1 CH=6 tests/mt7612u_sta_onair.sh wpa2 retry0 +# sudo DUT_SYSFS=1-1 AP_SYSFS=8-1 tests/sta_client_onair.sh +# sudo DUT_SYSFS=5-1 AP_SYSFS=1-1 CH=6 tests/sta_client_onair.sh wpa2 noarm # -# Rig: DUT_SYSFS an MT7612U (0e8d:7612), unbound from mt76x2u here and -# re-enumerated at the end; AP_SYSFS an adapter whose kernel driver supports -# AP mode AND lets its phy change network namespace (`iw phy` lists -# set_wiphy_netns): an in-kernel cfg80211 driver such as rtw88 or mt76. +# Rig: DUT_SYSFS an MT7612U, an RTL8812CU or an RTL8812BU (another die: set +# DUT_VID / DUT_PID; sta_client refuses it unless its station_mode_ok is +# true). Its kernel driver (mt76x2u, rtw88, or an out-of-tree rtl88x2*) is +# unbound here and the device re-enumerated at the end, never while +# sta_client is alive. AP_SYSFS an adapter whose kernel driver supports AP +# mode AND lets its phy change network namespace (`iw phy` lists +# set_wiphy_netns): an in-kernel cfg80211 driver such as mt76 or rtw88. # Out-of-tree drivers such as rtl88x2cu / 88x2bu cannot, and are refused. -# Read AP_SYSFS from `lsusb -t` after its driver has loaded (it can move). -# FW_DIR must hold the DECOMPRESSED MT7612U blobs (mt7662*.bin); a host that -# ships only mt7662*.bin.zst gets INCONCLUSIVE (rig/bring-up). +# Read both from `lsusb -t` after the drivers have loaded (they can move). +# FW_DIR (an MT7612U DUT only) must hold the DECOMPRESSED MT7612U blobs +# (mt7662*.bin); a host that ships only mt7662*.bin.zst gets INCONCLUSIVE +# (rig/bring-up). # Build first: cmake --build build --target StaClientSelftest (build/sta_client). # -# Env: DUT_SYSFS, AP_SYSFS, CH, SSID, PSK, SECS, REKEY_S, PTK_REKEY_S, FW_DIR, -# NS, TAP, READY_TIMEOUT, OUT, BUILD. -# Cells: open | wpa2 | noarm | retry0 | all (default: all four). +# Env: DUT_SYSFS, AP_SYSFS, DUT_VID, DUT_PID, CH, SSID, PSK, SECS, REKEY_S, +# PTK_REKEY_S, PING_S, DOWN_S, REJOIN_S, FW_DIR, NS, TAP, READY_TIMEOUT, +# OUT, BUILD. +# Cells: open | wpa2 | noarm | retry0 | reconnect | noreconnect | all +# (default: all six). # The cells are reached as "cell_$c" and cleanup through the traps. # shellcheck disable=SC2317 @@ -77,6 +104,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" BUILD="${BUILD:-$ROOT/build}" DUT_SYSFS="${DUT_SYSFS:-}" AP_SYSFS="${AP_SYSFS:-}" +DUT_VID="${DUT_VID:-}" +DUT_PID="${DUT_PID:-}" CH="${CH:-6}" SSID="${SSID:-devourerSTA}" PSK="${PSK:-devourer123}" @@ -88,6 +117,13 @@ SECS="${SECS:-90}" # four-way alone never exercises. REKEY_S="${REKEY_S:-20}" PTK_REKEY_S="${PTK_REKEY_S:-25}" +# The ping window behind every reported link line and the reconnect cell's +# post-re-join check: PING_S seconds at 2 pings a second. +PING_S="${PING_S:-30}" +# reconnect: how long the AP is away, and the bound on the re-join once it is +# back (loss noticed, re-join backoff, authentication, association, four-way). +DOWN_S="${DOWN_S:-8}" +REJOIN_S="${REJOIN_S:-30}" FW_DIR="${FW_DIR:-/lib/firmware/mediatek}" NS="${NS:-staonair}" TAP="${TAP:-dvsta0}" @@ -97,15 +133,17 @@ APIP=192.168.98.1 STAIP=192.168.98.2 CELLS="${*:-all}" -[ "$CELLS" = all ] && CELLS="open wpa2 noarm retry0" +[ "$CELLS" = all ] && CELLS="open wpa2 noarm retry0 reconnect noreconnect" for c in $CELLS; do - case "$c" in open|wpa2|noarm|retry0) ;; *) echo "unknown cell '$c'"; exit 2 ;; esac + case "$c" in open|wpa2|noarm|retry0|reconnect|noreconnect) ;; + *) echo "unknown cell '$c'"; exit 2 ;; esac done [ "$(id -u)" = 0 ] || { echo "must run as root"; exit 2; } -for v in CH SECS REKEY_S PTK_REKEY_S READY_TIMEOUT; do +for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S READY_TIMEOUT; do case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac done +[ "$PING_S" -ge 1 ] || { echo "PING_S must be at least 1"; exit 2; } [ -n "$DUT_SYSFS" ] || { echo "DUT_SYSFS is required (lsusb -t)"; exit 2; } [ -n "$AP_SYSFS" ] || { echo "AP_SYSFS is required (lsusb -t)"; exit 2; } [ "$DUT_SYSFS" != "$AP_SYSFS" ] || { echo "DUT_SYSFS and AP_SYSFS must differ"; exit 2; } @@ -121,6 +159,25 @@ if ns_exists; then exit 2 fi +# --- which die the DUT is ----------------------------------------------------- +# DUT_KIND decides the take / hand-back and what the arm scores. +dut_have="$(cat "/sys/bus/usb/devices/$DUT_SYSFS/idVendor" 2>/dev/null):$(cat "/sys/bus/usb/devices/$DUT_SYSFS/idProduct" 2>/dev/null)" +if [ -n "$DUT_VID" ] || [ -n "$DUT_PID" ]; then + dut_want=$(printf '%04x:%04x' "$((DUT_VID))" "$((DUT_PID))" 2>/dev/null) + [ "$dut_have" = "$dut_want" ] || { + echo "refusing DUT_SYSFS=$DUT_SYSFS - it reports '$dut_have', not DUT_VID:DUT_PID $dut_want"; exit 2; } +fi +case "$dut_have" in + 0e8d:7612) DUT_KIND=mt7612u ;; + 0bda:c812|0bda:b812) DUT_KIND=realtek ;; + *:*) + if [ -n "$DUT_VID" ] && [ "$dut_have" != ":" ]; then DUT_KIND=realtek + else echo "refusing DUT_SYSFS=$DUT_SYSFS ('$dut_have') - not an MT7612U, RTL8812CU or RTL8812BU (set DUT_VID / DUT_PID to name another die)"; exit 2 + fi ;; +esac +DUT_VID="0x${dut_have%%:*}" +DUT_PID="0x${dut_have#*:}" + # shellcheck source=tests/mt7612u_sta_lib.sh . "$ROOT/tests/mt7612u_sta_lib.sh" sta_out_prepare || exit 2 @@ -175,14 +232,26 @@ cleanup() { [ "$NM_AP" = yes ] && nmcli device set "$AP_IF" managed yes >/dev/null 2>&1 # The DUT is re-enumerated only once sta_client has really exited: a # re-enumeration inside its teardown is what the hand-back must not do. - if [ "$sta_gone" = 0 ] && [ "$STA_HUNG" = no ]; then sta_dut_handback - else echo "sta_client still running - not re-enumerating $DUT_SYSFS"; fi + if [ "$sta_gone" = 0 ] && [ "$STA_HUNG" = no ]; then + if [ "$DUT_KIND" = mt7612u ]; then sta_dut_handback + else sta_dev_handback dut "$DUT_SYSFS"; fi + else + echo "sta_client still running - not re-enumerating $DUT_SYSFS" + fi sta_lock_release } trap cleanup EXIT trap 'cleanup; exit 3' INT TERM -sta_dut_take || exit 2 +if [ "$DUT_KIND" = mt7612u ]; then + sta_dut_take || exit 2 +else + # Marked opened BEFORE the unbind, so the hand-back re-binds its driver + # whatever happens after this point. + sta_dev_record dut "$DUT_SYSFS" "$DUT_VID" "$DUT_PID" || exit 2 + sta_dev_opened dut + sta_dev_unbind_wifi "$DUT_SYSFS" || exit 2 +fi if command -v nmcli >/dev/null 2>&1; then case "$(nmcli -t -f DEVICE,STATE device 2>/dev/null | grep "^$AP_IF:")" in @@ -197,7 +266,7 @@ iw phy "$AP_PHY" set netns name "$NS" || { echo "could not move $AP_PHY into $NS sleep 2 ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null -echo "DUT MT7612U at $DUT_SYSFS ($(sta_usb_id "$DUT_SYSFS"))" +echo "DUT $DUT_KIND $dut_have at $DUT_SYSFS ($(sta_usb_id "$DUT_SYSFS"))" echo "AP $AP_IF ($AP_PHY) at $AP_SYSFS, in netns $NS" echo "ch$CH ssid '$SSID' tap $TAP cells: $CELLS" echo "logs: $OUT" @@ -228,13 +297,15 @@ wait_for() { # $1 file, $2 regex, $3 seconds # hostapd runs in the foreground (backgrounded here) with its event stream on # stdout: AP-STA-CONNECTED, EAPOL-4WAY-HS-COMPLETED and the rekey lines are # read from that file. AP up is judged by the interface type, not the log. -ap_up() { # $1 open | wpa2, $2 cell +ap_up() { # $1 open | wpa2 | wpa2norekey, $2 log tag { printf 'interface=%s\ndriver=nl80211\nssid=%s\n' "$AP_IF" "$SSID" if [ "$CH" -le 14 ]; then printf 'hw_mode=g\n'; else printf 'hw_mode=a\n'; fi printf 'channel=%s\nieee80211n=1\nauth_algs=1\nwmm_enabled=1\n' "$CH" - if [ "$1" = wpa2 ]; then + if [ "$1" != open ]; then printf 'wpa=2\nwpa_passphrase=%s\nwpa_key_mgmt=WPA-PSK\nrsn_pairwise=CCMP\n' "$PSK" + fi + if [ "$1" = wpa2 ]; then printf 'wpa_group_rekey=%s\nwpa_ptk_rekey=%s\n' "$REKEY_S" "$PTK_REKEY_S" fi } > "$OUT/hostapd_$2.conf" @@ -279,7 +350,7 @@ ap_up() { # $1 open | wpa2, $2 cell # 0 up; 1 exited before `sta_client up:`; 2 still not up after READY_TIMEOUT. sta_up() { # $1 cell, $2 seconds, $3.. extra env local cell="$1" secs="$2"; shift 2 - env DEVOURER_VID=0x0e8d DEVOURER_PID=0x7612 \ + env DEVOURER_VID="$DUT_VID" DEVOURER_PID="$DUT_PID" \ DEVOURER_USB_BUS="${DUT_SYSFS%%-*}" DEVOURER_USB_PORT="${DUT_SYSFS#*-}" \ DEVOURER_MT7612U_FW_DIR="$FW_DIR" DEVOURER_LOG_LEVEL=info \ DEVOURER_CHANNEL="$CH" DEVOURER_STA_SSID="$SSID" DEVOURER_STA_TAP="$TAP" \ @@ -357,14 +428,22 @@ led() { sed -n "s/.*$2=\\([0-9][0-9]*\\).*/\\1/p" "$OUT/sta_$1.log" | tail -1; } # Ping the AP over the air. 0 = 0% loss, 1 = loss, 2 = the station was not # alive for the whole measurement (no verdict on the link). -ping_ap() { # $1 cell +ping_ap() { # $1 tag proc_running "$STA_PID" || return 2 ping -c 1 -W 3 -I "$TAP" "$APIP" >/dev/null 2>&1 # warm ARP ping -c 6 -W 1 -I "$TAP" "$APIP" > "$OUT/ping_$1.txt" 2>&1 proc_running "$STA_PID" || return 2 grep -q ' 0% packet loss' "$OUT/ping_$1.txt" } -loss() { grep -oE '[0-9.]+% packet loss' "$OUT/ping_$1.txt" 2>/dev/null | head -1; } +# The same over a real window: PING_S seconds, two pings a second. +ping_window() { # $1 tag + proc_running "$STA_PID" || return 2 + ping -c 1 -W 3 -I "$TAP" "$APIP" >/dev/null 2>&1 # warm ARP + ping -c $(( PING_S * 2 )) -i 0.5 -W 1 -I "$TAP" "$APIP" > "$OUT/ping_$1.txt" 2>&1 + proc_running "$STA_PID" || return 2 + grep -q ' 0% packet loss' "$OUT/ping_$1.txt" +} +loss() { grep -oE '[0-9]+ packets transmitted, [0-9]+ received.*packet loss' "$OUT/ping_$1.txt" 2>/dev/null | head -1; } # The station exited after `sta_client up:` but before its measurement: # status 0 ran out of SECS (INCONCLUSIVE); anything else is a FAIL. @@ -382,16 +461,23 @@ fault_cause() { grep -m1 'FAULT\|threw' "$OUT/sta_$1.log" 2>/dev/null | sed 's/^ *//' } -# The clear ran on exit (scored); its result, which is trivially true on -# MT7612U, is information. +# The clear ran on exit. Its result is scored on a Realtek die, where the +# clear writes registers and verifies them; on MT7612U it is trivially true +# (the arm wrote nothing) and is information. (An unverified clear is also a +# station FAULT, exit 3, scored by sta_stop.) check_cleared() { # $1 cell local line line=$(grep -m1 'station identity clear:' "$OUT/sta_$1.log" | sed 's/^ *//') - if [ -n "$line" ]; then + if [ -z "$line" ]; then + bad "$1: ClearStationIdentity did not run on exit" + elif [ "$DUT_KIND" = realtek ]; then + case "$line" in + *"restored (verified)"*) ok "$1: ClearStationIdentity ran and verified on exit" ;; + *) bad "$1: ClearStationIdentity did not verify: $line" ;; + esac + else ok "$1: ClearStationIdentity ran on exit" info "$1: $line (trivially true on MT7612U: the arm wrote nothing)" - else - bad "$1: ClearStationIdentity did not run on exit" fi } @@ -442,7 +528,8 @@ cell_open() { # --- wpa2 and its two variants -------------------------------------------------- # $1 cell (wpa2 | noarm | retry0), $2.. extra station env. Returns after the -# station has stopped; the caller scores the arm-specific lines. +# station has stopped; the caller scores the arm-specific lines. WPA2_LINK is +# "no four-way" or "four-way completed, ping ...", ending in OK on 0% loss. WPA2_LINK="" run_wpa2() { local cell="$1"; shift @@ -462,7 +549,8 @@ run_wpa2() { return 0 fi local p=0 - ping_ap "$cell" || p=$? + if [ "$cell" = wpa2 ]; then ping_ap "$cell" || p=$? + else ping_window "$cell" || p=$?; fi if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill hostapd; return 1; fi WPA2_LINK="four-way completed, ping $(loss "$cell")" [ "$p" = 0 ] && WPA2_LINK="$WPA2_LINK OK" @@ -530,7 +618,24 @@ cell_noarm() { else bad "noarm: an arm or clear ran with DEVOURER_STA_ARM=0 ($(grep -m1 'station identity' "$OUT/sta_noarm.log"))" fi - info "noarm: link unarmed: ${WPA2_LINK:-no result} (the MT7612U arm writes no register; a difference from wpa2 here is worth a look)" + if [ "$DUT_KIND" != realtek ]; then + info "noarm: link unarmed: ${WPA2_LINK:-no result} (the MT7612U arm writes no register; a difference from wpa2 here is worth a look)" + return + fi + # Realtek: unarmed, the MAC does not ACK own-addressed unicast, so hostapd + # never sees its authentication response acknowledged and never lets the + # station in. Meaningful only if the station tried. + local beacons auth noack + beacons=$(led noarm 'beacons observed'); auth=$(led noarm 'auth_tx') + noack=$(grep -c 'did not acknowledge' "$OUT/hostapd_noarm.log" 2>/dev/null) + if [ "${beacons:-0}" = 0 ] || [ "${auth:-0}" = 0 ]; then + inc "noarm: the unarmed station never tried (beacons observed=${beacons:-?}, auth_tx=${auth:-?}) - not a control" + elif [ "$WPA2_LINK" = "no four-way" ]; then + ok "noarm: unarmed, the AP never completed the four-way (auth_tx=$auth) - the arm is what makes the wpa2 link" + else + bad "noarm: the UNARMED station got in (${WPA2_LINK}) - the arm is not what makes the wpa2 link, or this die answers unarmed" + fi + info "noarm: hostapd 'did not acknowledge' lines: ${noack:-0}" } cell_retry0() { @@ -550,6 +655,108 @@ cell_retry0() { info "retry0: single-shot uplink: ${WPA2_LINK:-no result}" } +# --- reconnect and its no-re-join variant --------------------------------------- +# $1 cell, $2 DEVOURER_STA_RECONNECT (1 | 0). +run_reconnect() { + local cell="$1" rc="$2" + CELL="$cell" + # No rekeys: this cell measures the re-join, and a rekey in the window + # would be a second thing happening. + ap_up wpa2norekey "$cell" || { inc "$cell: hostapd did not come up - see $OUT/hostapd_$cell.log"; cell_end; return; } + local secs=$(( SECS + DOWN_S + REJOIN_S + PING_S + 30 )) + local up=0 + sta_up "$cell" "$secs" DEVOURER_STA_PSK="$PSK" DEVOURER_STA_RECONNECT="$rc" || up=$? + [ "$up" = 0 ] || { station_not_up "$cell" "$up"; cell_end; return; } + local own; own=$(own_of "$cell") + tap_up || { inc "$cell: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return; } + if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then + if proc_running "$STA_PID"; then + inc "$cell: the first association never completed - nothing to reconnect"; cell_end + else station_gone "$cell"; sta_pid_kill hostapd; fi + return + fi + ping_ap "$cell"; case $? in + 0) ;; + 1) inc "$cell: ping $(loss "$cell") before the loss - nothing to compare against"; cell_end; return ;; + *) station_gone "$cell"; sta_pid_kill hostapd; return ;; + esac + + # THE AP GOES AWAY (hostapd deauthenticates its stations on the way out, + # and its beacons stop), then comes back on the same BSSID. + echo " stopping hostapd for ${DOWN_S}s" + sta_pid_kill hostapd + sleep "$DOWN_S" + if ! grep -q '^ station link lost:' "$OUT/sta_$cell.log"; then + proc_running "$STA_PID" || { station_gone "$cell"; return; } + fi + ap_up wpa2norekey "${cell}2" || { inc "$cell: hostapd did not come back - see $OUT/hostapd_${cell}2.log"; cell_end; return; } + local back; back=$(date +%s) + + if [ "$rc" = 1 ]; then + if wait_for "$OUT/hostapd_${cell}2.log" "EAPOL-4WAY-HS-COMPLETED $own" "$REJOIN_S"; then + ok "$cell: re-joined and re-keyed $(( $(date +%s) - back ))s after the AP came back (bound ${REJOIN_S}s)" + else + if proc_running "$STA_PID"; then + bad "$cell: no second four-way within ${REJOIN_S}s of the AP coming back"; cell_end + else station_gone "$cell"; sta_pid_kill hostapd; fi + return + fi + ping_window "${cell}_after"; case $? in + 0) ok "$cell: ping after the re-join, $(loss "${cell}_after")" ;; + 1) bad "$cell: ping after the re-join, $(loss "${cell}_after")" ;; + *) station_gone "$cell"; sta_pid_kill hostapd; return ;; + esac + else + if wait_for "$OUT/hostapd_${cell}2.log" "EAPOL-4WAY-HS-COMPLETED $own" "$REJOIN_S"; then + bad "$cell: re-joined with DEVOURER_STA_RECONNECT=0" + else + proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill hostapd; return; } + ok "$cell: no re-join within ${REJOIN_S}s with DEVOURER_STA_RECONNECT=0" + fi + fi + cell_end + + if grep -q '^ station link lost:' "$OUT/sta_$cell.log"; then + ok "$cell: the station reported the lost link ($(grep -m1 '^ station link lost:' "$OUT/sta_$cell.log" | sed 's/^ *station link lost: //'))" + else + bad "$cell: the station never reported losing the link" + fi + local assoc reconn + assoc=$(led "$cell" 'associations'); reconn=$(led "$cell" 'reconnects') + if [ "$rc" = 1 ]; then + if [ "${assoc:-0}" = 2 ] && [ "${reconn:-0}" = 1 ]; then + ok "$cell: ledger associations=2, reconnects=1" + else + bad "$cell: ledger associations=${assoc:-?}, reconnects=${reconn:-?} (expected 2 and 1)" + fi + else + if [ "${assoc:-0}" = 1 ] && grep -q '^fault=0 state=Failed' "$OUT/sta_$cell.log"; then + ok "$cell: ledger ends Failed after 1 association" + else + bad "$cell: ledger associations=${assoc:-?}, final state $(grep -m1 '^fault=' "$OUT/sta_$cell.log" | cut -d' ' -f2) (expected 1 and Failed)" + fi + fi + # ONE arm for the run: the arm is per BSSID, and a re-join to the same + # BSSID keeps it (nothing between the two associations touches it). + local arms; arms=$(grep -c '^ station identity armed for BSSID' "$OUT/sta_$cell.log") + if [ "${arms:-0}" = 1 ]; then + ok "$cell: armed once for the BSSID, across the re-join" + else + bad "$cell: ${arms:-0} arm lines (expected exactly 1)" + fi + check_cleared "$cell" +} + +cell_reconnect() { + echo; echo "== reconnect: hostapd away for ${DOWN_S}s, re-join within ${REJOIN_S}s ==" + run_reconnect reconnect 1 +} + +cell_noreconnect() { + echo; echo "== noreconnect: as reconnect, with DEVOURER_STA_RECONNECT=0 ==" + run_reconnect noreconnect 0 +} + for c in $CELLS; do "cell_$c"; done echo diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 5b65e331..0409c09f 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -24,7 +24,7 @@ * WHAT IT DOES NOT COVER, stated rather than implied: * - USB, the radio, SetStationIdentity, the channel retune, and everything * below send_packet(). Those need a device and stay in - * tests/mt7612u_sta_onair.sh. + * tests/sta_client_onair.sh. * - The four-way's INTEROPERABILITY. The fixture below and the code it * tests share an author, so a shared misreading is invisible to it. * tests/eapol_kernel_vectors.h (ctest `supplicant`) is what pins that, From a8b658e622d00d8a688495138893822893c02dea Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 10:05:35 +0200 Subject: [PATCH 05/53] tests: sta_client's ledger reports the state the run ended in The ledger's first line read the state machine after the teardown's leave(), which always returns it to Idle, so every run - including one that gave up with DEVOURER_STA_RECONNECT=0 - reported state=Idle with no reason. The end state (state, failure reason and status, AID, keyed) is now taken under g_mu just before leave(), and report() prints that: Connected for a run that ended associated, Failed reason= for one that gave up. test_reconnect_can_be_disabled now also checks that leave() returns to Idle while the recorded end state is Failed, reason beacon-lost. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client.cpp | 35 +++++++++++++++++++++++++++++------ tests/sta_client_selftest.inc | 13 +++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index bfdf5a68..890f9d03 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -782,15 +782,37 @@ const char* fail_name(StationSm::Failure f) { * whether the run worked or not: "we heard * nothing", "we heard the wrong AP" and "we heard our AP and it said no" are * different lines here. */ +/* THE STATE THE RUN ENDED IN, taken before the teardown's leave() - which + * always returns the machine to Idle, and would otherwise make every ledger + * read Idle: a run that gave up (DEVOURER_STA_RECONNECT=0) must say Failed + * and why. Caller holds g_mu. */ +struct RunEnd { + bool taken = false; + StationSm::State state = StationSm::State::Idle; + StationSm::Failure reason = StationSm::Failure::None; + unsigned status = 0, aid = 0; + bool keyed = false; +}; +RunEnd g_end; +void take_run_end() { + g_end.taken = true; + g_end.state = g_sm.state(); + g_end.reason = g_sm.fail_reason(); + g_end.status = g_sm.status(); + g_end.aid = g_sm.aid(); + g_end.keyed = g_sm.keyed(); +} + void report() { std::lock_guard l(g_mu); + if (!g_end.taken) take_run_end(); std::fprintf(stderr, "fault=%d state=%s", g_fault.load(), - state_name(g_sm.state())); - if (g_sm.state() == StationSm::State::Failed) - std::fprintf(stderr, " reason=%s status=%u", fail_name(g_sm.fail_reason()), - g_sm.status()); - std::fprintf(stderr, " aid=%u keyed=%d bss_known=%d\n", g_sm.aid(), - (int)g_sm.keyed(), g_bss.count()); + state_name(g_end.state)); + if (g_end.state == StationSm::State::Failed) + std::fprintf(stderr, " reason=%s status=%u", fail_name(g_end.reason), + g_end.status); + std::fprintf(stderr, " aid=%u keyed=%d bss_known=%d\n", g_end.aid, + (int)g_end.keyed, g_bss.count()); std::fprintf(stderr, " join: beacons observed=%llu, probes sent=%llu, joins=%llu," " associations=%llu, reconnects=%llu\n", @@ -1277,6 +1299,7 @@ int main(int argc, char** argv) { * here, so an exception must not leave main. */ try { std::lock_guard l(g_mu); + take_run_end(); g_sm.leave(); std::vector f; while (g_sm.pop_tx(&f)) { diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 0409c09f..16e5517a 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -106,6 +106,7 @@ void reset_all() { g_fault = 0; g_stop = 0; g_tap_read_err = 0; + g_end = RunEnd{}; } /* Drain whatever the station queued, stripping the radiotap prefix the real @@ -1640,6 +1641,18 @@ void test_reconnect_can_be_disabled() { supervise(now); } check(g_joins.load() == joins_before, "no re-join with reconnect disabled"); + + /* And the ledger says so: the teardown's leave() returns the machine to + * Idle, but the run ENDED Failed, and why. */ + { + std::lock_guard l(g_mu); + take_run_end(); + g_sm.leave(); + } + check(g_sm.state() == StationSm::State::Idle, "leave() returns to Idle"); + check(g_end.state == StationSm::State::Failed && + g_end.reason == StationSm::Failure::BeaconLost, + "...but the run's end state is Failed, reason beacon-lost"); } /* A frame from the host claiming somebody else's source address. A real AP From 2b2cf0f94551950e8196b46f9cff618aae1fd83e Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 10:05:35 +0200 Subject: [PATCH 06/53] tests: sta_client_onair review fixes, and an OFDM-only AP knob - The lib clears .id_* / .opened_* when a run takes OUT's lock: a reused OUT no longer carries an earlier run's device records into this run's hand-back (realtek_station_onair.sh HALF=up never records `peer`). - reconnect measures its re-join bound, and the printed time, from the moment hostapd is started again, not from ap_up returning. - The Realtek noarm control has a positive control: unless the ARMED wpa2 cell of the same run completed a four-way against the same hostapd configuration, a silent AP proves nothing and noarm is INCONCLUSIVE. - wpa2 scores the two MIC counters for what they are: the four-way's (mic_failures=) must be 0, the data plane's (MIC failures=) may reach one per pairwise rekey. - AP_OFDM_ONLY=1 (2.4 GHz) makes hostapd advertise and use OFDM rates only, so its authentication and association responses go out at 6 Mb/s instead of 1 Mb/s CCK - the first experiment on the 8822C association issue, which docs/station-client.md now records as a known issue. - noreconnect's "ends Failed" check now holds against the client's end state; the doc says the ledger reports the state the run ended in. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 22 ++++++++++--- tests/mt7612u_sta_lib.sh | 3 ++ tests/sta_client_onair.sh | 67 +++++++++++++++++++++++++++++---------- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index aa3ad3ce..58e03ed9 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -50,7 +50,9 @@ SIGINT/SIGTERM (handled from the start of `main`, so a stop during bring-up ends the run once the bring-up returns) leave the BSS, clear the identity and print the ledger. The ledger is printed at every exit once `sta_client up:` has printed, and separates "heard nothing", "heard another BSS" and "our AP -refused us". While it runs, the station also logs each association +refused us". Its first line is the state the run ENDED in, before the +teardown's leave: `Connected`, or `Failed reason=` for a run that gave +up. While it runs, the station also logs each association (`station connected (association N)`) and each failure (`station link lost: ` or `station join failed: `). @@ -87,10 +89,10 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| | `open` | AP associates our address; ping 0% loss over the TAP; ledger plaintext only; armed; the clear | - | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning | - | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs. MT7612U: the link over a 30 s ping window is reported, not scored | + | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning | + | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | - | `reconnect` | hostapd stopped and restarted: the station reports the lost link; second four-way within the bound; ping 0% loss over a 30 s window; ledger 2 associations, 1 reconnect; one arm across the re-join; the clear | + | `reconnect` | hostapd stopped and restarted: the station reports the lost link; second four-way within the bound, measured from hostapd being started again; ping 0% loss over a 30 s window; ledger 2 associations, 1 reconnect; one arm across the re-join; the clear | | `noreconnect` | as `reconnect` with `DEVOURER_STA_RECONNECT=0`: the lost link reported; no re-join; the ledger ends Failed after 1 association | The arm differs by die. On MT7612U it writes no register, so an unarmed @@ -110,6 +112,18 @@ first line then reads `fault=1`. time), 3 interrupted. `FW_DIR` (an MT7612U DUT) must hold the decompressed MT7612U blobs. +## Known issue: RTL8812CU (8822C) association on 2.4 GHz + +Against an MT7612U AP on channel 6 the armed 8822C station authenticates +(hostapd: "authenticated", so the authentication response was +acknowledged) and receives the association response, but hostapd does not +get that response acknowledged: the AP-side status stays pending until the +station is removed ("handle_assoc_cb: STA ... not found"), and association +mostly fails; with `DEVOURER_TX_RETRY_LIMIT=0` it sometimes succeeds after +seconds. The 8822B and the MT7612U associate with the same AP. The cause is +not settled; `AP_OFDM_ONLY=1` (management frames at 6 Mb/s OFDM instead of +1 Mb/s CCK) and a 5 GHz channel are the first two experiments. + ## What it does not do - The on-air cell covers the dies that report `station_mode_ok` (MT7612U, diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index e300fae2..e5caae45 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -86,6 +86,9 @@ sta_lock_take() { fi echo "$$ $(sta_proc_start "$$")" > "$OUT/.lock/pid" STA_LOCKED=yes + # A reused OUT starts with no device records: a marker an earlier run left + # would make this run hand back a device it never recorded or opened. + rm -f "$OUT"/.id_* "$OUT"/.opened_* return 0 } diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 06db5ae8..ec1f9558 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -31,16 +31,20 @@ # line; the clear ran on exit (verified, on Realtek). # wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both # rekeys completed at the AP, ping 0% loss before and after them, -# ONE association throughout, MIC failures <= PTK installs (a -# pairwise rekey has a one-frame switchover window, see rx_frame() -# in sta_client.cpp), the arm line, the clear (as for open), and +# ONE association throughout, no four-way MIC failure, and +# data-plane MIC failures <= PTK installs (a pairwise rekey has a +# one-frame switchover window in which the AP still sends under +# the old key - see rx_frame() in sta_client.cpp), the arm line, the clear (as for open), and # NO tx.retry_limit=0 warning (the station default is nonzero). # noarm the control: wpa2 with DEVOURER_STA_ARM=0 - nothing else # changes. Scored everywhere: no SetStationIdentity and no clear # ran. On Realtek also scored: the station tried (beacons seen, # authentication sent) and the AP did NOT complete the four-way # for it within 30 s - a completed four-way is a FAIL, because then -# the arm is not what makes the wpa2 cell work. On MT7612U the link +# the arm is not what makes the wpa2 cell work. Its positive +# control is the wpa2 cell of the SAME run: without an armed +# four-way against the same hostapd configuration, a silent AP +# proves nothing and noarm is INCONCLUSIVE. On MT7612U the link # is reported over a PING_S ping window, not scored. # retry0 wpa2 with DEVOURER_TX_RETRY_LIMIT=0. Scored: the library's # arm-time warning about tx.retry_limit=0 (logged inside a @@ -50,7 +54,7 @@ # reconnect hostapd WPA2-PSK, stopped for DOWN_S and restarted with the # same configuration. Scored: the station reports the lost link; # the AP completes a second four-way for it within REJOIN_S of -# coming back; ping 0% loss over a PING_S window after the +# hostapd being started again; ping 0% loss over a PING_S window after the # re-join; the ledger counts 2 associations and 1 reconnect; # exactly ONE arm (the arm is per BSSID and stays in place across # a re-join to the same BSSID - on Realtek the second association @@ -91,9 +95,14 @@ # (rig/bring-up). # Build first: cmake --build build --target StaClientSelftest (build/sta_client). # +# AP_OFDM_ONLY=1 (2.4 GHz): hostapd advertises and uses OFDM rates only +# (no 1/2/5.5/11 Mb/s), so its management frames - authentication and +# association responses - go out at 6 Mb/s OFDM instead of 1 Mb/s CCK. A +# diagnostic: whether a station's association depends on CCK. +# # Env: DUT_SYSFS, AP_SYSFS, DUT_VID, DUT_PID, CH, SSID, PSK, SECS, REKEY_S, -# PTK_REKEY_S, PING_S, DOWN_S, REJOIN_S, FW_DIR, NS, TAP, READY_TIMEOUT, -# OUT, BUILD. +# PTK_REKEY_S, PING_S, DOWN_S, REJOIN_S, AP_OFDM_ONLY, FW_DIR, NS, TAP, +# READY_TIMEOUT, OUT, BUILD. # Cells: open | wpa2 | noarm | retry0 | reconnect | noreconnect | all # (default: all six). @@ -124,6 +133,7 @@ PING_S="${PING_S:-30}" # back (loss noticed, re-join backoff, authentication, association, four-way). DOWN_S="${DOWN_S:-8}" REJOIN_S="${REJOIN_S:-30}" +AP_OFDM_ONLY="${AP_OFDM_ONLY:-0}" FW_DIR="${FW_DIR:-/lib/firmware/mediatek}" NS="${NS:-staonair}" TAP="${TAP:-dvsta0}" @@ -140,7 +150,7 @@ for c in $CELLS; do done [ "$(id -u)" = 0 ] || { echo "must run as root"; exit 2; } -for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S READY_TIMEOUT; do +for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S AP_OFDM_ONLY READY_TIMEOUT; do case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac done [ "$PING_S" -ge 1 ] || { echo "PING_S must be at least 1"; exit 2; } @@ -308,6 +318,9 @@ ap_up() { # $1 open | wpa2 | wpa2norekey, $2 log tag if [ "$1" = wpa2 ]; then printf 'wpa_group_rekey=%s\nwpa_ptk_rekey=%s\n' "$REKEY_S" "$PTK_REKEY_S" fi + if [ "$AP_OFDM_ONLY" = 1 ] && [ "$CH" -le 14 ]; then + printf 'supported_rates=60 90 120 180 240 360 480 540\nbasic_rates=60 120 240\n' + fi } > "$OUT/hostapd_$2.conf" # The previous cell's hostapd exiting is not its interface being back: a # launch 30 ms after AP-DISABLED found the netdev gone ("Could not read @@ -335,6 +348,7 @@ ap_up() { # $1 open | wpa2 | wpa2norekey, $2 log tag sleep 0.1; t=$((t + 1)) done ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null + AP_START_MS=$(date +%s%3N) # reconnect measures its re-join from here ip netns exec "$NS" hostapd -t "$OUT/hostapd_$2.conf" > "$OUT/hostapd_$2.log" 2>&1 & sta_pid_record hostapd $! t=0 @@ -531,6 +545,9 @@ cell_open() { # station has stopped; the caller scores the arm-specific lines. WPA2_LINK is # "no four-way" or "four-way completed, ping ...", ending in OK on 0% loss. WPA2_LINK="" +# Set once the ARMED wpa2 cell has completed a four-way in this run: the +# positive control the Realtek noarm control needs. +ARMED_FOURWAY=no run_wpa2() { local cell="$1"; shift CELL="$cell" @@ -591,14 +608,19 @@ cell_wpa2() { *) bad "wpa2: ${WPA2_LINK:-no result} - see $OUT/sta_wpa2.log and $OUT/hostapd_wpa2.log" ;; esac [ "$WPA2_LINK" = "no four-way" ] && { check_armed wpa2; return; } - local assoc mic ptk ans + ARMED_FOURWAY=yes + # Two different MIC counters: the four-way's (mic_failures=, the + # supplicant's EAPOL-Key MIC check) must be 0; the data plane's (MIC + # failures=, CCMP on received data) may reach one per pairwise rekey. + local assoc mic fwmic ptk ans assoc=$(led wpa2 'associations'); mic=$(led wpa2 'MIC failures') + fwmic=$(led wpa2 'mic_failures') ptk=$(led wpa2 'PTK'); ans=$(led wpa2 'answered') if [ "${assoc:-0}" = 1 ] && [ "${ans:-0}" -gt 0 ] && [ "${ptk:-0}" -ge 2 ] && - [ "${mic:-999}" -le "${ptk:-0}" ]; then - ok "wpa2: ledger associations=1, rekeys answered=$ans, PTK installs=$ptk, MIC failures=$mic (<= PTK installs)" + [ "${fwmic:-1}" = 0 ] && [ "${mic:-999}" -le "${ptk:-0}" ]; then + ok "wpa2: ledger associations=1, rekeys answered=$ans, PTK installs=$ptk, four-way MIC failures=0, data-plane MIC failures=$mic (<= PTK installs)" else - bad "wpa2: ledger associations=${assoc:-?} answered=${ans:-?} PTK=${ptk:-?} MIC failures=${mic:-?} (expected 1, >0, >=2, MIC <= PTK)" + bad "wpa2: ledger associations=${assoc:-?} answered=${ans:-?} PTK=${ptk:-?} four-way MIC failures=${fwmic:-?} data-plane MIC failures=${mic:-?} (expected 1, >0, >=2, 0, <= PTK)" fi check_armed wpa2 # The station default retry limit is nonzero, so the arm must NOT warn. @@ -628,7 +650,9 @@ cell_noarm() { local beacons auth noack beacons=$(led noarm 'beacons observed'); auth=$(led noarm 'auth_tx') noack=$(grep -c 'did not acknowledge' "$OUT/hostapd_noarm.log" 2>/dev/null) - if [ "${beacons:-0}" = 0 ] || [ "${auth:-0}" = 0 ]; then + if [ "$ARMED_FOURWAY" != yes ]; then + inc "noarm: no ARMED four-way against this hostapd configuration in this run (run the wpa2 cell first, and it must get in) - a silent AP proves nothing" + elif [ "${beacons:-0}" = 0 ] || [ "${auth:-0}" = 0 ]; then inc "noarm: the unarmed station never tried (beacons observed=${beacons:-?}, auth_tx=${auth:-?}) - not a control" elif [ "$WPA2_LINK" = "no four-way" ]; then ok "noarm: unarmed, the AP never completed the four-way (auth_tx=$auth) - the arm is what makes the wpa2 link" @@ -690,11 +714,20 @@ run_reconnect() { proc_running "$STA_PID" || { station_gone "$cell"; return; } fi ap_up wpa2norekey "${cell}2" || { inc "$cell: hostapd did not come back - see $OUT/hostapd_${cell}2.log"; cell_end; return; } - local back; back=$(date +%s) + # The bound runs from hostapd being started again, not from ap_up + # returning (which waits for the AP type first). + local back=$AP_START_MS deadline=$(( AP_START_MS + REJOIN_S * 1000 )) rejoined=no + while [ "$(date +%s%3N)" -lt "$deadline" ]; do + if grep -q "EAPOL-4WAY-HS-COMPLETED $own" "$OUT/hostapd_${cell}2.log" 2>/dev/null; then + rejoined=yes; break + fi + sleep 0.2 + done if [ "$rc" = 1 ]; then - if wait_for "$OUT/hostapd_${cell}2.log" "EAPOL-4WAY-HS-COMPLETED $own" "$REJOIN_S"; then - ok "$cell: re-joined and re-keyed $(( $(date +%s) - back ))s after the AP came back (bound ${REJOIN_S}s)" + if [ "$rejoined" = yes ]; then + local ms=$(( $(date +%s%3N) - back )) + ok "$cell: re-joined and re-keyed $(( ms / 1000 )).$(( ms % 1000 / 100 ))s after hostapd was started again (bound ${REJOIN_S}s)" else if proc_running "$STA_PID"; then bad "$cell: no second four-way within ${REJOIN_S}s of the AP coming back"; cell_end @@ -707,7 +740,7 @@ run_reconnect() { *) station_gone "$cell"; sta_pid_kill hostapd; return ;; esac else - if wait_for "$OUT/hostapd_${cell}2.log" "EAPOL-4WAY-HS-COMPLETED $own" "$REJOIN_S"; then + if [ "$rejoined" = yes ]; then bad "$cell: re-joined with DEVOURER_STA_RECONNECT=0" else proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill hostapd; return; } From 374acb9748ac25046e06b651bba51eb6d9d3c094 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 11:12:21 +0200 Subject: [PATCH 07/53] tests: sta_client confirms an open association, and re-joins one the AP never held On air an 8822C station intermittently received its Association Response while the AP never saw it acknowledged: the AP never added the station, dropped its traffic and sent no deauthentication, and on an open BSS the station sat "connected" until the run ended. (On WPA2 the four-way timeout already recovers it.) An open association now counts as unconfirmed until a unicast data frame from the AP arrives for the station. Once the host has asked something - kConfirmUplink (3) unicast or ARP frames - and kConfirmMs (5 s) has passed with no such reply, supervise() calls StationSm::link_lost(): the link is lost as "unconfirmed" and the ordinary re-join policy (backoff, DEVOURER_STA_RECONNECT) takes over. Multicast chatter and an idle host are never judged. The ledger gains unconfirmed= and assoc_repeat=. Headless: an unconfirmed association is lost and re-joined; a unicast reply confirms for good; an idle host and multicast chatter are never judged; unanswered ARP requests are. tests/sta_client_onair.sh's open cell now runs a ping from the moment the TAP is up, as a user's host would, and scores hostapd's AP-STA-CONNECTED for our address within 30 s - covering one recovered association - before the scored ping; a recovery is reported. docs/station-client.md documents the rule and restates the 8822C known issue as intermittent, not CCK-only. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 31 ++++++--- tests/sta_client.cpp | 60 +++++++++++++++-- tests/sta_client_onair.sh | 26 ++++++-- tests/sta_client_selftest.inc | 122 ++++++++++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 20 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 58e03ed9..56b3bf52 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -63,6 +63,17 @@ join that fails - ends the attempts: the station logs it, stays unassociated until its time is up, and the ledger ends `Failed` with the reason. +An open association is confirmed by the AP's first unicast frame to the +station. A station cannot see the AP's side: if the AP never saw the +association response acknowledged, it does not hold the station, drops its +traffic and may never say so. So once the host has asked something - three +unicast or ARP frames - and no unicast reply has come within 5 s, the link +is lost as `unconfirmed` (`StationSm::link_lost`) and re-joined under the +policy above. Multicast chatter and an idle host are never judged; WPA2 +needs no such rule (the four-way is the confirmation). The ledger counts +these (`unconfirmed=`), and repeated association responses +(`assoc_repeat=`). + Exit status: 0 the run completed; 1 setup failed; 2 refused (`station_mode_ok` false, or the duration, `DEVOURER_CHANNEL`, `DEVOURER_STA_SCAN_DWELL_MS` (10..10000, default 250) or @@ -88,7 +99,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | AP associates our address; ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | @@ -114,15 +125,15 @@ first line then reads `fault=1`. ## Known issue: RTL8812CU (8822C) association on 2.4 GHz -Against an MT7612U AP on channel 6 the armed 8822C station authenticates -(hostapd: "authenticated", so the authentication response was -acknowledged) and receives the association response, but hostapd does not -get that response acknowledged: the AP-side status stays pending until the -station is removed ("handle_assoc_cb: STA ... not found"), and association -mostly fails; with `DEVOURER_TX_RETRY_LIMIT=0` it sometimes succeeds after -seconds. The 8822B and the MT7612U associate with the same AP. The cause is -not settled; `AP_OFDM_ONLY=1` (management frames at 6 Mb/s OFDM instead of -1 Mb/s CCK) and a 5 GHz channel are the first two experiments. +Intermittently, against an MT7612U AP on channel 6, the armed 8822C station +receives the association response but the AP never sees it acknowledged: +hostapd's status for it stays pending until the station is removed +("handle_assoc_cb: STA ... not found"). In captures of passing runs the +8822C acknowledges both the authentication and the association response +within ~0.3 ms, at 1 Mb/s CCK, and an OFDM-only AP (`AP_OFDM_ONLY=1`) fails +the same way, so it is not a CCK-only problem. The cause is not settled. +The station recovers by itself: on WPA2 through the four-way timeout, on an +open BSS through the confirmation rule above. ## What it does not do diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 890f9d03..25f9b31e 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -285,6 +285,29 @@ int8_t rssi_dbm(uint8_t raw) { /* ---- keys and per-association state ------------------------------------- */ +/* AN OPEN ASSOCIATION IS CONFIRMED BY THE AP'S FIRST UNICAST REPLY. The + * station cannot see the AP's side: an Association Response it received but + * whose acknowledgement the AP never saw leaves the AP without the station, + * and on an open BSS nothing says so - the AP drops the station's uplink and + * may never deauthenticate it. (On WPA2 the four-way is the confirmation: + * the AP starts it only for a station it holds, and HandshakeTimeout covers + * the rest.) So an open association counts as unconfirmed until a unicast + * data frame from the AP arrives for this station; if the host has sent + * kConfirmUplink frames and kConfirmMs has passed without one, the link is + * lost (StationSm::link_lost) and the ordinary re-join policy takes over. + * Only frames that ask for a reply count as uplink here - unicast, and ARP + * (its request is broadcast, its reply unicast) - so a host's multicast + * chatter (IPv6 RS/MLD, mDNS) on an idle link is never judged; an idle host + * is never judged at all. A host sending one-way unicast traffic with + * static neighbour entries would be judged lost - the price of having no + * other evidence. */ +constexpr uint32_t kConfirmMs = 5000; +constexpr uint32_t kConfirmUplink = 3; +bool g_unconfirmed = false; +uint32_t g_assoc_ms = 0; +uint32_t g_uplink_unconfirmed = 0; +std::atomic g_unconfirmed_lost{0}; + /* Called under g_mu when the station reaches Connected on a new * association. The duplicate cache is reset here and NOT at a rekey: it is * per transmitter and TID over Sequence Control (DupDetector, Dot11.h), which @@ -292,7 +315,10 @@ int8_t rssi_dbm(uint8_t raw) { * still be a duplicate. The per-key state is not reset here: a PTK or GTK * rekey happens with the machine already Connected, so note_keys() owns it, * keyed on the supplicant's install generations. */ -void on_association() { +void on_association(uint32_t now) { + g_unconfirmed = g_sm.security() == StationSm::Security::Open; + g_assoc_ms = now; + g_uplink_unconfirmed = 0; g_rx_dup.reset(); g_failed_noted = false; g_was_associated = true; @@ -381,7 +407,7 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { const StationSm::State before = g_sm.state(); g_sm.on_rx(mpdu, len, now); if (before != StationSm::State::Connected && g_sm.connected()) - on_association(); + on_association(now); if (g_sm.connected()) note_keys(); /* The data plane runs only on a live association: a protected frame that @@ -435,6 +461,7 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { * anyone on the channel inject into the host's stack. Cleartext EAPOL is * StationSm::on_rx's, and it has already had it. */ if (g_sm.security() != StationSm::Security::Open) return; + if (to_us) g_unconfirmed = false; /* the AP holds this association */ g_plain_rx.fetch_add(1); if (len > hlen) tap_up(da, sa, mpdu + hlen, len - hlen); return; @@ -549,6 +576,11 @@ bool air_msdu(const uint8_t* msdu, size_t len, const uint8_t da[6], if (!protect) { hdr.insert(hdr.end(), msdu, msdu + len); if (from_host) g_tx_plain.fetch_add(1); + /* LLC/SNAP puts the ethertype at msdu[6..7]. */ + if (from_host && g_unconfirmed && + ((da[0] & 0x01) == 0 || + (len >= 8 && msdu[6] == 0x08 && msdu[7] == 0x06))) + g_uplink_unconfirmed++; enqueue(std::move(hdr)); return true; } @@ -625,6 +657,20 @@ const char* fail_name(StationSm::Failure f); uint8_t supervise(uint32_t now) { std::lock_guard l(g_mu); + /* An unconfirmed open association the host has been talking through + * (see kConfirmMs) - lost, through the ordinary failure path below. */ + if (g_sm.state() == StationSm::State::Connected && g_unconfirmed && + g_uplink_unconfirmed >= kConfirmUplink && + (uint32_t)(now - g_assoc_ms) >= kConfirmMs) { + std::fprintf(stderr, + " station association unconfirmed: %u frames sent, no " + "unicast reply from the AP in %u ms\n", + g_uplink_unconfirmed, (unsigned)(now - g_assoc_ms)); + g_unconfirmed = false; + g_unconfirmed_lost.fetch_add(1); + g_sm.link_lost(); + } + const StationSm::State st = g_sm.state(); if (st != StationSm::State::Idle && st != StationSm::State::Failed) return g_sm.channel() ? g_sm.channel() : g_chan; @@ -774,6 +820,7 @@ const char* fail_name(StationSm::Failure f) { case StationSm::Failure::NoChannel: return "no-channel"; case StationSm::Failure::NotInfrastructure: return "not-infrastructure"; case StationSm::Failure::SsidMismatch: return "ssid-mismatch"; + case StationSm::Failure::Unconfirmed: return "unconfirmed"; } return "?"; } @@ -815,17 +862,18 @@ void report() { (int)g_end.keyed, g_bss.count()); std::fprintf(stderr, " join: beacons observed=%llu, probes sent=%llu, joins=%llu," - " associations=%llu, reconnects=%llu\n", + " associations=%llu, reconnects=%llu, unconfirmed=%llu\n", (unsigned long long)g_beacons.load(), (unsigned long long)g_probe_tx.load(), (unsigned long long)g_joins.load(), (unsigned long long)g_associations.load(), - (unsigned long long)g_reconnects.load()); + (unsigned long long)g_reconnects.load(), + (unsigned long long)g_unconfirmed_lost.load()); std::fprintf(stderr, " station rx: auth_tx=%u assoc_tx=%u eapol_tx=%u eapol_rx=%u" - " beacons=%u\n", + " beacons=%u assoc_repeat=%u\n", g_sm.auth_tx, g_sm.assoc_tx, g_sm.eapol_tx, g_sm.eapol_rx, - g_sm.beacons_rx); + g_sm.beacons_rx, g_sm.rx_assoc_repeat); std::fprintf(stderr, " refused by the address filter: not-our-bss=%u," " not-for-us=%u, ignored=%u, malformed=%u, tx-dropped=%u" diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index ec1f9558..fa80e8e5 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -26,7 +26,11 @@ # in: `noarm` is a real control, and the clear's verification is scored. # # Cells (each scored against its own witness): -# open hostapd open: the AP associates OUR address; ping 0% loss over +# open hostapd open, with a ping running from the start: the AP +# associates OUR address (hostapd's AP-STA-CONNECTED - its own +# record) within 30 s, which covers recovering a first +# association the AP did not hold (sta_client re-joins when its +# ARP gets no unicast reply, kConfirmMs); ping 0% loss over # the TAP; the ledger shows plaintext and no decryption; the arm # line; the clear ran on exit (verified, on Realtek). # wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both @@ -192,7 +196,7 @@ DUT_PID="0x${dut_have#*:}" . "$ROOT/tests/mt7612u_sta_lib.sh" sta_out_prepare || exit 2 sta_lock_take || exit 2 -sta_pid_init sta hostapd +sta_pid_init sta hostapd probe # --- the AP adapter: refused unless it is plainly a spare wireless adapter -- ap_refuse() { echo "refusing AP_SYSFS=$AP_SYSFS: $*"; sta_lock_release; exit 2; } @@ -224,6 +228,7 @@ cleanup() { # INT, then KILL after its window (sta_stop) - never a bare blocking wait. [ -n "$STA_PID" ] && sta_stop [ "$STA_HUNG" = yes ] && sta_gone=1 + sta_pid_kill probe sta_pid_kill hostapd # THE PHY COMES BACK BEFORE THE NAMESPACE GOES: `ip netns del` on a # namespace still holding a phy destroys the phy (only a re-enumeration @@ -506,7 +511,7 @@ check_armed() { # $1 cell check_cleared "$1" } -cell_end() { sta_stop; sta_pid_kill hostapd; } +cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; } # --- open --------------------------------------------------------------------- cell_open() { @@ -518,11 +523,19 @@ cell_open() { [ "$up" = 0 ] || { station_not_up open "$up"; cell_end; return; } local own; own=$(own_of open) tap_up || { inc "open: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return; } + # Traffic from the start, as a user's host would send: an open association + # the AP does not hold is only visible to the station as questions (ARP) + # that get no reply, and it re-joins on that (kConfirmMs in sta_client.cpp). + # The AP's own record - AP-STA-CONNECTED for our address - is the witness, + # and the 30 s bound covers a first association that has to be recovered. + ping -I "$TAP" -i 1 "$APIP" >/dev/null 2>&1 & + sta_pid_record probe $! if ! wait_for "$OUT/hostapd_open.log" "AP-STA-CONNECTED $own" 30; then - if proc_running "$STA_PID"; then bad "open: the AP never associated $own"; cell_end + if proc_running "$STA_PID"; then bad "open: the AP never associated $own within 30 s"; cell_end else station_gone open; sta_pid_kill hostapd; fi return fi + sta_pid_kill probe ok "open: the AP associated $own" ping_ap open; case $? in 0) ok "open: ping over the air, $(loss open)" ;; @@ -530,6 +543,11 @@ cell_open() { *) station_gone open; sta_pid_kill hostapd; return ;; esac cell_end + local unconf assoc + unconf=$(led open 'unconfirmed'); assoc=$(led open 'associations') + if [ "${unconf:-0}" -gt 0 ] 2>/dev/null; then + info "open: recovered: ${unconf} association(s) the AP did not hold were found unconfirmed and re-joined (${assoc:-?} associations, assoc_repeat=$(led open 'assoc_repeat'))" + fi local plain enc plain=$(led open 'plaintext rx'); enc=$(led open 'encrypted rx') if [ "${plain:-0}" -gt 0 ] && [ "${enc:-x}" = 0 ]; then diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 16e5517a..2fd435a8 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -107,6 +107,10 @@ void reset_all() { g_stop = 0; g_tap_read_err = 0; g_end = RunEnd{}; + g_unconfirmed = false; + g_assoc_ms = 0; + g_uplink_unconfirmed = 0; + g_unconfirmed_lost = 0; } /* Drain whatever the station queued, stripping the radiotap prefix the real @@ -1042,6 +1046,120 @@ void test_a_non_key_eapol_reaches_the_host() { "...and is not counted as a rekey"); } +/* MULTICAST CHATTER IS NOT A QUESTION: frames that expect no reply (here a + * multicast IPv6 frame) never count towards the judgement. */ +void test_multicast_chatter_is_not_judged() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure_open(kSsid, g_own); + } + Ap ap; + associate(ap, /*rsn=*/false); + drain_tx(); + for (int i = 0; i < 10; i++) { + uint8_t e[60] = {0x33, 0x33, 0, 0, 0, 2}; /* IPv6 all-routers */ + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x86; + e[13] = 0xdd; + tap_down_one(e, sizeof e); + } + drain_tx(); + supervise(kConfirmMs * 4); + check(g_sm.state() == StationSm::State::Connected, + "multicast chatter with no reply is never judged"); + + /* ...but broadcast ARP requests are questions: an ARP that is never + * answered is exactly how an AP that dropped us shows. */ + for (int i = 0; i < (int)kConfirmUplink; i++) { + uint8_t e[60]; + std::memset(e, 0xff, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x06; + std::memset(e + 14, 0, sizeof e - 14); + tap_down_one(e, sizeof e); + } + drain_tx(); + supervise(kConfirmMs * 5); + check(g_sm.state() == StationSm::State::Failed && + g_sm.fail_reason() == StationSm::Failure::Unconfirmed, + "unanswered ARP requests are judged"); +} + +/* AN OPEN ASSOCIATION THE AP DOES NOT HOLD IS FOUND AND RE-JOINED. The host + * talks (kConfirmUplink frames) and no unicast reply comes back within + * kConfirmMs: the link is lost as Unconfirmed and the ordinary re-join policy + * takes over. The two controls: a unicast reply confirms the association for + * good, and an idle host is never judged. */ +void open_link_with_uplink(Ap& ap, int frames) { + { + std::lock_guard l(g_mu); + g_sm.configure_open(kSsid, g_own); + g_rejoin_backoff_ms = 100; + } + associate(ap, /*rsn=*/false); + drain_tx(); + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + for (int i = 0; i < frames; i++) tap_down_one(e, sizeof e); + drain_tx(); +} + +void test_an_unconfirmed_open_association_is_lost() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, (int)kConfirmUplink); + check(g_sm.state() == StationSm::State::Connected, "the open link is up"); + supervise(kConfirmMs - 1); + check(g_sm.state() == StationSm::State::Connected, + "...and not judged before kConfirmMs"); + supervise(kConfirmMs); + check(g_sm.state() == StationSm::State::Failed && + g_sm.fail_reason() == StationSm::Failure::Unconfirmed, + "uplink with no unicast reply: lost as Unconfirmed"); + check(g_unconfirmed_lost.load() == 1 && g_reconnects.load() == 1, + "...counted once, as a lost link"); + const uint64_t joins = g_joins.load(); + const std::vector b = beacon(false); + rx_frame(b.data(), b.size(), -40, kConfirmMs + 200); + supervise(kConfirmMs + 200); + check(g_joins.load() == joins + 1, "...and re-joined after the backoff"); +} + +void test_a_unicast_reply_confirms_the_association() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, (int)kConfirmUplink); + const uint8_t pl[16] = {1}; + const std::vector d = ap.plain_to_sta(pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, 100); + (void)tap_read(); + for (int i = 0; i < 10; i++) { + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + tap_down_one(e, sizeof e); + } + drain_tx(); + supervise(kConfirmMs * 4); + check(g_sm.state() == StationSm::State::Connected, + "a unicast reply confirms the open association for good"); +} + +void test_an_idle_open_association_is_not_judged() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + supervise(kConfirmMs * 4); + check(g_sm.state() == StationSm::State::Connected, + "an idle host is never judged unconfirmed"); +} + /* THE FCS IS TRIMMED ONLY WHERE IT IS PRESENT, and a runt shorter than its * FCS is length 0, not an unsigned wrap that every later bounds check would * pass. */ @@ -1921,6 +2039,10 @@ int self_test() { selftest::test_the_tid_comes_from_the_qos_control_field(); selftest::test_a_group_key_we_cannot_use(); selftest::test_the_fcs_is_trimmed_only_where_present(); + selftest::test_an_unconfirmed_open_association_is_lost(); + selftest::test_a_unicast_reply_confirms_the_association(); + selftest::test_an_idle_open_association_is_not_judged(); + selftest::test_multicast_chatter_is_not_judged(); selftest::test_a_retransmission_is_a_duplicate(); selftest::test_the_dup_cache_spans_a_rekey_not_an_association(); selftest::test_a_full_tap_is_a_drop_not_a_stall(); From 15ad00263d047cd085f7102e31bc12b8b2f88940 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 11:58:56 +0200 Subject: [PATCH 08/53] tests: sta_client_onair - HOSTAPD_DEBUG, and the kernel log of a failed cell HOSTAPD_DEBUG=1 runs hostapd with -dd into the cell's hostapd log, for the AP's view of an association (station add, TX status). Off by default; meant for the open cell, since the extra lines can repeat the text the wpa2 cell counts for its rekeys. A cell that FAILs now saves the tail of dmesg to dmesg_.txt and echoes its mt76 / rtw88 / cfg80211 lines - read-only, best effort - so an AP driver error during station add shows next to the verdict. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client_onair.sh | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index fa80e8e5..9cc72057 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -99,13 +99,22 @@ # (rig/bring-up). # Build first: cmake --build build --target StaClientSelftest (build/sta_client). # +# HOSTAPD_DEBUG=1: hostapd runs with -dd, its debug output in the same +# per-cell log (hostapd_.log), for the AP's view of an association +# (sta_add, TX status). Meant for the open cell: the extra lines can repeat +# the text the wpa2 cell counts (rekey completions), so read its rekey +# verdicts with that in mind. Whatever the setting, a cell that FAILs saves +# the kernel log's tail (dmesg_.txt) and prints its mt76 / rtw88 / +# cfg80211 lines - read-only, best effort. +# # AP_OFDM_ONLY=1 (2.4 GHz): hostapd advertises and uses OFDM rates only # (no 1/2/5.5/11 Mb/s), so its management frames - authentication and # association responses - go out at 6 Mb/s OFDM instead of 1 Mb/s CCK. A # diagnostic: whether a station's association depends on CCK. # # Env: DUT_SYSFS, AP_SYSFS, DUT_VID, DUT_PID, CH, SSID, PSK, SECS, REKEY_S, -# PTK_REKEY_S, PING_S, DOWN_S, REJOIN_S, AP_OFDM_ONLY, FW_DIR, NS, TAP, +# PTK_REKEY_S, PING_S, DOWN_S, REJOIN_S, AP_OFDM_ONLY, HOSTAPD_DEBUG, +# FW_DIR, NS, TAP, # READY_TIMEOUT, OUT, BUILD. # Cells: open | wpa2 | noarm | retry0 | reconnect | noreconnect | all # (default: all six). @@ -138,6 +147,7 @@ PING_S="${PING_S:-30}" DOWN_S="${DOWN_S:-8}" REJOIN_S="${REJOIN_S:-30}" AP_OFDM_ONLY="${AP_OFDM_ONLY:-0}" +HOSTAPD_DEBUG="${HOSTAPD_DEBUG:-0}" FW_DIR="${FW_DIR:-/lib/firmware/mediatek}" NS="${NS:-staonair}" TAP="${TAP:-dvsta0}" @@ -154,7 +164,7 @@ for c in $CELLS; do done [ "$(id -u)" = 0 ] || { echo "must run as root"; exit 2; } -for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S AP_OFDM_ONLY READY_TIMEOUT; do +for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S AP_OFDM_ONLY HOSTAPD_DEBUG READY_TIMEOUT; do case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac done [ "$PING_S" -ge 1 ] || { echo "PING_S must be at least 1"; exit 2; } @@ -354,7 +364,10 @@ ap_up() { # $1 open | wpa2 | wpa2norekey, $2 log tag done ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null AP_START_MS=$(date +%s%3N) # reconnect measures its re-join from here - ip netns exec "$NS" hostapd -t "$OUT/hostapd_$2.conf" > "$OUT/hostapd_$2.log" 2>&1 & + local dbg="" + [ "$HOSTAPD_DEBUG" = 1 ] && dbg=-dd + # shellcheck disable=SC2086 # $dbg is empty or one flag + ip netns exec "$NS" hostapd $dbg -t "$OUT/hostapd_$2.conf" > "$OUT/hostapd_$2.log" 2>&1 & sta_pid_record hostapd $! t=0 until ip netns exec "$NS" iw dev "$AP_IF" info 2>/dev/null | grep -q 'type AP'; do @@ -511,7 +524,18 @@ check_armed() { # $1 cell check_cleared "$1" } -cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; } +# A cell that FAILED keeps the kernel's view (the AP driver may log a +# station-add or TX-status error): the tail of dmesg into its own file, and +# the AP-relevant lines echoed. Read-only; skipped silently if unreadable. +CELL_FAIL0=0 +dmesg_on_fail() { + [ "$fail" -gt "$CELL_FAIL0" ] || return 0 + dmesg 2>/dev/null | tail -80 > "$OUT/dmesg_${CELL:-cell}.txt" || return 0 + grep -iE 'mt76|rtw|rtl8|cfg80211|ieee80211' "$OUT/dmesg_${CELL:-cell}.txt" | + tail -10 | sed 's/^/ dmesg /' + return 0 +} +cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; dmesg_on_fail; } # --- open --------------------------------------------------------------------- cell_open() { @@ -808,7 +832,7 @@ cell_noreconnect() { run_reconnect noreconnect 0 } -for c in $CELLS; do "cell_$c"; done +for c in $CELLS; do CELL_FAIL0=$fail; "cell_$c"; done echo echo "=== $pass passed, $fail failed, $inconclusive inconclusive (logs: $OUT) ===" From 90842b7a41e39c16d038617aaf491cb1ff4d8ca9 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:00:20 +0200 Subject: [PATCH 09/53] tests: sta_client nudges the AP after every association, once more on WPA2 An MT7612U AP (kernel mt76x2u) can hold a transmitted frame's TX status until its next transmission - the same silicon behaviour docs/mt7612u-tx-retry.md records for this tree's own MT7612U driver. hostapd counts a station associated, and starts the WPA2 four-way, only once the Association Response's status is in. hostapd's debug log of a failing run: "association OK", the response sent, no TX status for six seconds, then - when the station's next frame made the AP transmit - the status with ack=1, too late ("handle_assoc_cb: STA ... not found"). On WPA2 every attempt reads "authenticated", then nothing until the station's handshake timeout. The station had acknowledged the response; it was not at fault. The moment an Association Response is accepted - Associating to Connected (open) or FourWay (WPA2) - sta_client queues one probe request (every AP answers one), so the AP transmits and a held status is released. On WPA2 a second one follows if no EAPOL has arrived kNudgeAgainMs (1 s) later, and none after that. The safety nets stay: the confirmation rule (open) and the four-way timeout (WPA2). The ledger counts the nudges. Headless: open is nudged once and never again; WPA2 is nudged on the accepted response, once more after 1 s without EAPOL, never a third time, and not a second time when message 1 arrives at once. A nudge is exactly one probe request: it does not retune, does not open the retune guard (g_tuned / g_retune_ms) and does not move the scan sweep; the probe response it provokes counts as AP liveness and leaves the BSS on its channel. docs/station-client.md: the nudge, and the AP quirk. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 46 +++++++++--- tests/sta_client.cpp | 52 ++++++++++++- tests/sta_client_onair.sh | 5 +- tests/sta_client_selftest.inc | 133 ++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+), 15 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 56b3bf52..47c1cd4e 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -63,6 +63,12 @@ join that fails - ends the attempts: the station logs it, stays unassociated until its time is up, and the ledger ends `Failed` with the reason. +The moment an association response is accepted - open or WPA2 - the +station sends one probe request (the "nudge", counted as `nudges` in the +ledger): see "AP quirk" below. On WPA2 a second one follows if no EAPOL has +arrived 1 s later; the four-way timeout re-joins if even that is not +enough. + An open association is confirmed by the AP's first unicast frame to the station. A station cannot see the AP's side: if the AP never saw the association response acknowledged, it does not hold the station, drops its @@ -123,17 +129,35 @@ first line then reads `fault=1`. time), 3 interrupted. `FW_DIR` (an MT7612U DUT) must hold the decompressed MT7612U blobs. -## Known issue: RTL8812CU (8822C) association on 2.4 GHz - -Intermittently, against an MT7612U AP on channel 6, the armed 8822C station -receives the association response but the AP never sees it acknowledged: -hostapd's status for it stays pending until the station is removed -("handle_assoc_cb: STA ... not found"). In captures of passing runs the -8822C acknowledges both the authentication and the association response -within ~0.3 ms, at 1 Mb/s CCK, and an OFDM-only AP (`AP_OFDM_ONLY=1`) fails -the same way, so it is not a CCK-only problem. The cause is not settled. -The station recovers by itself: on WPA2 through the four-way timeout, on an -open BSS through the confirmation rule above. +## AP quirk: an MT7612U AP holds the association's TX status + +An MT7612U running as the AP (kernel mt76x2u, hostapd) can hold a +transmitted frame's TX status until its NEXT transmission - the same +silicon behaviour `docs/mt7612u-tx-retry.md` records for this tree's own +MT7612U driver ("status posted only on the next TX"). hostapd acts on an +association only once the Association Response's status is in: it counts +the station associated - dropping its data until then - and on WPA2 starts +the four-way only from that status too, so both an open association and +the WPA2 key exchange stall the same way. In hostapd's debug log +(`HOSTAPD_DEBUG=1`): + +- "association OK (aid 1)", the station added, the Association Response + sent; +- no TX status for it for six seconds, while the station - which had + received the response and acknowledged it - believed it was associated; +- then the station's next frame made the AP transmit, and the status + arrived with `ack=1`: too late, the station had already given up on the + association ("handle_assoc_cb: STA ... not found"). + +The station is not at fault: it acknowledged the response, at 1 Mb/s CCK, +within ~0.3 ms in captures. The stall is intermittent and depends on +whether anything else makes the AP transmit soon after the response, which +is why a given station can pass several runs and then fail several in a +row; on WPA2 it shows as the four-way never starting, each attempt ending +in the station's handshake timeout. sta_client sends its nudge for exactly +this, for open and WPA2 alike; the confirmation rule (open) and the +four-way timeout (WPA2) re-join if the association still never becomes the +AP's. ## What it does not do diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 25f9b31e..c5453cf4 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -315,6 +315,31 @@ std::atomic g_unconfirmed_lost{0}; * still be a duplicate. The per-key state is not reset here: a PTK or GTK * rekey happens with the machine already Connected, so note_keys() owns it, * keyed on the supplicant's install generations. */ +void probe(uint8_t chan); /* below, with the scan */ +std::atomic g_nudges{0}; + +/* THE NUDGE. An AP may hold a transmitted frame's TX status until its next + * transmission - the MT7612U on mt76x2u does (docs/station-client.md) - and + * hostapd acts on an association only once the Association Response's + * status (ACK) is in: it counts the station associated, and on WPA2 starts + * the four-way, only from that status. Nothing else need make the AP + * transmit to us soon, so the association - and the four-way - can stall for + * seconds while the station's traffic is dropped. One probe request, which + * every AP answers, makes it transmit and releases the held status. Sent the + * moment an Association Response is accepted, open or WPA2; on WPA2 a + * second one follows if no EAPOL has arrived kNudgeAgainMs later (supervise), + * and the four-way timeout re-joins if even that is not enough. Caller holds + * g_mu. */ +constexpr uint32_t kNudgeAgainMs = 1000; +uint32_t g_nudge_ms = 0; +bool g_nudge_again = false; /* a second nudge is still owed (WPA2) */ +uint32_t g_nudge_eapol_rx = 0; +void nudge(uint32_t now) { + probe(g_sm.channel() ? g_sm.channel() : g_chan); + g_nudges.fetch_add(1); + g_nudge_ms = now; +} + void on_association(uint32_t now) { g_unconfirmed = g_sm.security() == StationSm::Security::Open; g_assoc_ms = now; @@ -406,6 +431,15 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { const StationSm::State before = g_sm.state(); g_sm.on_rx(mpdu, len, now); + /* An Association Response accepted: Associating -> Connected (open) or + * FourWay (WPA2). */ + if (before == StationSm::State::Associating && + g_sm.state() != StationSm::State::Associating && + g_sm.state() != StationSm::State::Failed) { + nudge(now); + g_nudge_again = g_sm.state() == StationSm::State::FourWay; + g_nudge_eapol_rx = g_sm.eapol_rx; + } if (before != StationSm::State::Connected && g_sm.connected()) on_association(now); if (g_sm.connected()) note_keys(); @@ -657,6 +691,18 @@ const char* fail_name(StationSm::Failure f); uint8_t supervise(uint32_t now) { std::lock_guard l(g_mu); + /* WPA2: still no EAPOL kNudgeAgainMs after the first nudge - nudge once + * more (see nudge()). */ + if (g_nudge_again) { + if (g_sm.state() != StationSm::State::FourWay || + g_sm.eapol_rx != g_nudge_eapol_rx) { + g_nudge_again = false; + } else if ((uint32_t)(now - g_nudge_ms) >= kNudgeAgainMs) { + g_nudge_again = false; + nudge(now); + } + } + /* An unconfirmed open association the host has been talking through * (see kConfirmMs) - lost, through the ordinary failure path below. */ if (g_sm.state() == StationSm::State::Connected && g_unconfirmed && @@ -861,10 +907,12 @@ void report() { std::fprintf(stderr, " aid=%u keyed=%d bss_known=%d\n", g_end.aid, (int)g_end.keyed, g_bss.count()); std::fprintf(stderr, - " join: beacons observed=%llu, probes sent=%llu, joins=%llu," - " associations=%llu, reconnects=%llu, unconfirmed=%llu\n", + " join: beacons observed=%llu, probes sent=%llu (nudges %llu)," + " joins=%llu, associations=%llu, reconnects=%llu," + " unconfirmed=%llu\n", (unsigned long long)g_beacons.load(), (unsigned long long)g_probe_tx.load(), + (unsigned long long)g_nudges.load(), (unsigned long long)g_joins.load(), (unsigned long long)g_associations.load(), (unsigned long long)g_reconnects.load(), diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 9cc72057..d1a02e2d 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -29,8 +29,9 @@ # open hostapd open, with a ping running from the start: the AP # associates OUR address (hostapd's AP-STA-CONNECTED - its own # record) within 30 s, which covers recovering a first -# association the AP did not hold (sta_client re-joins when its -# ARP gets no unicast reply, kConfirmMs); ping 0% loss over +# association the AP did not hold (sta_client nudges the AP with +# a probe request on associating, and re-joins when its ARP gets +# no unicast reply, kConfirmMs); ping 0% loss over # the TAP; the ledger shows plaintext and no decryption; the arm # line; the clear ran on exit (verified, on Realtek). # wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 2fd435a8..a93cef60 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -111,6 +111,10 @@ void reset_all() { g_assoc_ms = 0; g_uplink_unconfirmed = 0; g_unconfirmed_lost = 0; + g_nudges = 0; + g_nudge_ms = 0; + g_nudge_again = false; + g_nudge_eapol_rx = 0; } /* Drain whatever the station queued, stripping the radiotap prefix the real @@ -1046,6 +1050,133 @@ void test_a_non_key_eapol_reaches_the_host() { "...and is not counted as a rekey"); } +/* THE NUDGE: an accepted Association Response queues one probe request at + * once, open or WPA2, so an AP that holds the response's TX status until its + * next transmission is made to transmit (hostapd starts both the association + * and the four-way from that status). On WPA2 a second one follows if no + * EAPOL has arrived kNudgeAgainMs later, and none once it has. */ +int probes_in(const std::vector>& tx) { + int n = 0; + for (const std::vector& f : tx) + if (f.size() >= 24 && f[0] == devourer::sta::kFcProbeReq) n++; + return n; +} + +/* Beacon, authentication, association - answered, but no message 1. */ +void join_without_msg1(Ap& ap, bool rsn) { + const std::vector b = beacon(rsn); + rx_frame(b.data(), b.size(), -40, 0); + supervise(0); + pump_tx(); + for (int round = 0; round < 4; round++) { + const std::vector> tx = drain_tx(); + if (tx.empty()) break; + for (const std::vector& f : tx) { + if (f.size() >= 24 && f[0] == devourer::sta::kFcProbeReq) { + enqueue(f); /* keep it for the cell to see */ + continue; + } + const std::vector r = ap.respond(f, rsn); + if (!r.empty()) rx_frame(r.data(), r.size(), -40, 0); + } + pump_tx(); + if (g_sm.state() != StationSm::State::Authenticating && + g_sm.state() != StationSm::State::Associating) + break; + } +} + +void test_an_accepted_association_nudges_the_ap() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure_open(kSsid, g_own); + } + Ap ap; + join_without_msg1(ap, /*rsn=*/false); + check(g_sm.state() == StationSm::State::Connected, "the open link is up"); + check(probes_in(drain_tx()) == 1 && g_nudges.load() == 1, + "...and one probe request is queued the moment it is"); + supervise(kNudgeAgainMs * 3); + check(probes_in(drain_tx()) == 0, "...and no second one on an open link"); + + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap2; + join_without_msg1(ap2, /*rsn=*/true); + check(g_sm.state() == StationSm::State::FourWay, + "WPA2: associated, waiting for message 1"); + check(probes_in(drain_tx()) == 1 && g_nudges.load() == 1, + "...nudged the moment the Association Response was accepted"); + supervise(kNudgeAgainMs - 1); + check(probes_in(drain_tx()) == 0, "...not again before kNudgeAgainMs"); + supervise(kNudgeAgainMs); + check(probes_in(drain_tx()) == 1 && g_nudges.load() == 2, + "...and once more when no EAPOL has come by then"); + supervise(kNudgeAgainMs * 3); + check(probes_in(drain_tx()) == 0, "...and never a third time"); + + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap3; + associate(ap3, /*rsn=*/true); /* message 1 arrives at once */ + supervise(kNudgeAgainMs * 3); + check(g_sm.keyed() && g_nudges.load() == 1, + "WPA2 with message 1 at once: nudged once, no second nudge"); +} + +/* A NUDGE IS ONE FRAME AND NOTHING ELSE: no retune, no scan state, no + * retune guard, and the AP's probe response it provokes neither moves the + * BSS's channel nor changes the beacon-loss window - it only counts as AP + * liveness. */ +void test_a_nudge_touches_no_tuning_or_liveness_state() { + reset_all(); + { + std::lock_guard l(g_mu); + g_sm.configure(g_crypto, kSsid, kPsk, g_own); + } + Ap ap; + associate(ap, /*rsn=*/true); + check(g_sm.keyed(), "the link is keyed"); + drain_tx(); + + const uint8_t tuned = g_tuned.load(); + const uint32_t guard = g_retune_ms.load(); + const size_t scan_idx = g_scan_idx; + const uint32_t scan_switch = g_scan_switch_ms; + const uint32_t loss_ms = g_sm.beacon_loss_ms(); + { + std::lock_guard l(g_mu); + nudge(100); + } + const std::vector> tx = drain_tx(); + check(tx.size() == 1 && probes_in(tx) == 1, "a nudge is exactly one probe request"); + check(g_tuned.load() == tuned && g_retune_ms.load() == guard, + "...that touches neither the tuned channel nor the retune guard"); + check(g_scan_idx == scan_idx && g_scan_switch_ms == scan_switch, + "...nor the scan sweep"); + + /* The AP's answer: a probe response from the BSSID to this station. */ + std::vector pr = beacon(true); + pr[0] = devourer::sta::kFcProbeResp; + std::memcpy(pr.data() + 4, kStaMac, 6); + rx_frame(pr.data(), pr.size(), -40, 200); + { + std::lock_guard l(g_mu); + const devourer::sta::BssEntry* e = g_bss.find(kBssid); + check(e && e->info.channel == 6, "the probe response leaves the BSS on its channel"); + } + check(g_sm.beacon_loss_ms() == loss_ms, "...and the beacon-loss window unchanged"); + tick(200 + loss_ms - 1); + check(g_sm.connected(), "...and counts as AP liveness, not against it"); +} + /* MULTICAST CHATTER IS NOT A QUESTION: frames that expect no reply (here a * multicast IPv6 frame) never count towards the judgement. */ void test_multicast_chatter_is_not_judged() { @@ -2043,6 +2174,8 @@ int self_test() { selftest::test_a_unicast_reply_confirms_the_association(); selftest::test_an_idle_open_association_is_not_judged(); selftest::test_multicast_chatter_is_not_judged(); + selftest::test_an_accepted_association_nudges_the_ap(); + selftest::test_a_nudge_touches_no_tuning_or_liveness_state(); selftest::test_a_retransmission_is_a_duplicate(); selftest::test_the_dup_cache_spans_a_rekey_not_an_association(); selftest::test_a_full_tap_is_a_drop_not_a_stall(); From 23934f9daf1b4c6569b95b7918b21e38d0ae0df5 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:00:42 +0200 Subject: [PATCH 10/53] tests: sta_client counts the plaintext a protected link refuses Plaintext data from our BSS on a WPA2 link was refused silently. It is now counted (`plaintext refused` in the ledger's data-plane line), leaving out the four-way's own cleartext EAPOL (recognised by the full SNAP header, is_ethertype_snap + 0x888e) and the no-data subtypes (Null / QoS Null), which carry nothing to refuse. The on-air harness uses the count as its positive witness that unicast addressed to the station gets through the MT7612U's managed receive filter. The header comments say what the RX path now is on MT7612U: promiscuous until the arm, the managed filter while armed, so StationSm::on_rx's `not-for-us` count is that filter's witness. Headless: an unprotected data frame on a protected link is counted as refused; a 26-byte QoS Null is not. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client.cpp | 40 +++++++++++++++++++++++++++-------- tests/sta_client_selftest.inc | 12 ++++++++++- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index c5453cf4..15737cd4 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -38,10 +38,15 @@ * from any other address is not acknowledged (docs/mt7612u-station-identity.md). * `own` comes from GetPermanentMacAddress and is never invented. * - * THE RECEIVE PATH IS PROMISCUOUS on MT7612U (Mt7612uRadio::StartRxLoop - * installs the monitor filter), so StationSm::on_rx is the address filter, - * and its refusal counters are printed at every exit: they distinguish "the - * AP never answered" from "we never heard the AP". + * THE RECEIVE PATH IS PROMISCUOUS on MT7612U until the arm + * (Mt7612uRadio::StartRxLoop installs the monitor filter); the arm installs + * the managed filter, which drops unicast not addressed to `own` but still + * passes every BSS's beacons and group traffic, and DEVOURER_STA_ARM=0 stays + * promiscuous. So StationSm::on_rx is the address filter either way, and its + * refusal counters are printed at every exit: they distinguish "the AP never + * answered" from "we never heard the AP". Its `not-for-us` count (our BSS, + * someone else's unicast) is also the witness that the managed filter is on: + * near zero while armed, whatever such traffic is on the air. * * Exit status: 0 the run completed (the ledger says how it went); 1 setup * failed; 2 refused - the adapter's station_mode_ok is false, or the @@ -197,6 +202,10 @@ std::atomic g_beacons{0}, g_probe_tx{0}; std::atomic g_joins{0}, g_associations{0}, g_reconnects{0}; std::atomic g_enc_rx{0}, g_mic_fail{0}, g_replays{0}; std::atomic g_group_rx{0}, g_plain_rx{0}, g_rx_short{0}; +/* Plaintext data (not EAPOL) from our BSS on a protected link: refused, and + * counted - it is also the on-air harness's positive witness that unicast + * addressed to us gets through the receive filter. */ +std::atomic g_plain_refused{0}; /* One counter per direction, so each direction's books close on their own: * from host == encrypted + plaintext + dropped down * queued == aired + queue dropped + send failed */ @@ -494,7 +503,18 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { /* Plaintext on a WPA2 link is not forwarded: accepting it would let * anyone on the channel inject into the host's stack. Cleartext EAPOL is * StationSm::on_rx's, and it has already had it. */ - if (g_sm.security() != StationSm::Security::Open) return; + if (g_sm.security() != StationSm::Security::Open) { + /* Not counted: the four-way's own cleartext EAPOL (expected here), + * and the no-data subtypes (Null / QoS Null - subtype bit 2), which + * carry nothing to refuse. */ + const uint8_t* msdu = mpdu + hlen; + const bool eapol = + devourer::sta::is_ethertype_snap(msdu, len - hlen) && + msdu[6] == 0x88 && msdu[7] == 0x8e; + const bool no_data = (fc0 & 0x40) != 0; + if (!eapol && !no_data) g_plain_refused.fetch_add(1); + return; + } if (to_us) g_unconfirmed = false; /* the AP holds this association */ g_plain_rx.fetch_add(1); if (len > hlen) tap_up(da, sa, mpdu + hlen, len - hlen); @@ -945,11 +965,12 @@ void report() { sup.rsn_mismatches); std::fprintf(stderr, " data plane: encrypted rx=%llu (group=%llu), plaintext rx=" - "%llu, MIC failures=%llu, replays rejected=%llu," - " duplicates dropped=%llu, no key for it=%llu\n", + "%llu, plaintext refused=%llu, MIC failures=%llu, replays" + " rejected=%llu, duplicates dropped=%llu, no key for it=%llu\n", (unsigned long long)g_enc_rx.load(), (unsigned long long)g_group_rx.load(), (unsigned long long)g_plain_rx.load(), + (unsigned long long)g_plain_refused.load(), (unsigned long long)g_mic_fail.load(), (unsigned long long)g_replays.load(), (unsigned long long)g_dup_drop.load(), @@ -1439,8 +1460,9 @@ int main(int argc, char** argv) { /* Cleared on the way out whenever an arm was attempted - every path that * can reach SetStationIdentity ends here. The result is the only way to * learn a rollback did not land (IRadio: the port may keep answering for - * `own`), so it is printed; on a backend whose arm wrote nothing it is - * trivially true. */ + * `own`; on MT7612U, the managed receive filter may still be in force), so + * it is printed; on a backend whose arm wrote nothing it is trivially + * true. */ if (arm_attempted) { const char* r = "NOT VERIFIED"; try { diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index a93cef60..ca5ee33a 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -92,7 +92,7 @@ void reset_all() { { std::lock_guard q(g_q_mu); g_q.clear(); } g_beacons = 0; g_probe_tx = 0; g_joins = 0; g_associations = 0; g_reconnects = 0; g_enc_rx = 0; g_mic_fail = 0; g_replays = 0; - g_group_rx = 0; g_plain_rx = 0; g_rx_short = 0; + g_group_rx = 0; g_plain_rx = 0; g_plain_refused = 0; g_rx_short = 0; g_tap_tx = 0; g_tap_rx = 0; g_tap_drop = 0; g_tap_down_drop = 0; g_q_in = 0; g_tx_enc = 0; g_tx_enc_fail = 0; g_tx_plain = 0; @@ -1317,6 +1317,16 @@ void test_plaintext_is_refused_on_a_protected_link() { rx_frame(d.data(), d.size(), -40, 0); check(tap_read().empty(), "an unprotected data frame is refused"); check(g_plain_rx.load() == 0, "...and is not counted as plaintext traffic"); + check(g_plain_refused.load() == 1, "...but is counted as refused plaintext"); + + /* A QoS Null (26 bytes, no payload) is not plaintext data: not counted. */ + std::vector qn = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kPeer, /*protect=*/false, ap.seq.next()); + qn[0] = 0xc8; /* QoS Null */ + qn.insert(qn.begin() + 24, {0x00, 0x00}); /* QoS Control, TID 0 */ + check(qn.size() == 26, "the QoS Null fixture is 26 bytes"); + rx_frame(qn.data(), qn.size(), -40, 0); + check(g_plain_refused.load() == 1, "...and a QoS Null is not counted"); } /* Fragments and A-MSDUs are refused rather than misread. Handing half an MSDU From 834b05404d2d475d4b40f8b71ae04dff07d4bdc0 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:03:37 +0200 Subject: [PATCH 11/53] tests: sta_client_onair - the MT7612U managed-filter cells With an MT7612U DUT, once the wpa2 / noarm four-way is in, a monitor vif on the AP's phy injects two plaintext unicast data streams from the AP's BSSID (INJECT_S at INJECT_PPS each): one to FOREIGN, an address nobody holds, and one to the station's own address. The own stream is the positive witness that the injection reaches the DUT: the station refuses it on a WPA2 link and counts it (`plaintext refused`), and it must reach half of what was injected, else the check is INCONCLUSIVE. Then armed (wpa2) `not-for-us` must stay under 1% of the foreign stream; unarmed (noarm, the monitor filter) at least half of it must arrive. A phy that cannot add a monitor vif makes the filter check INCONCLUSIVE, not the cell. On a Realtek DUT the injection is skipped and said as INFO. The two streams share a transmitter address, so the own stream starts at sequence 2048 (sta_unicast_inject.py gains an optional seq0, default 0; existing callers unchanged). Each injector is bounded by `timeout -k`, its PID recorded, and killed - and the monitor vif deleted - by the cleanup on every exit path, before the AP phy leaves the namespace. The clear is now scored as verified on both dies: on MT7612U it restores the monitor filter and reads it back, so it is no longer trivially true. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 2 +- docs/station-client.md | 39 +++++--- tests/sta_client_onair.sh | 158 +++++++++++++++++++++++++------ tests/sta_unicast_inject.py | 18 +++- 4 files changed, 171 insertions(+), 46 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index c71f267e..24c9e41a 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -340,7 +340,7 @@ group-addressed data. What it loses is only what `StationSm::on_rx` already refused: another station's unicast (`not-for-us`) and probe responses to other stations. No disarm-while-scanning is needed. -**Witness.** `tests/mt7612u_sta_onair.sh` injects two plaintext unicast +**Witness.** `tests/sta_client_onair.sh` (an MT7612U DUT) injects two plaintext unicast streams from the AP's BSSID while the station is associated: one at an address nobody holds, one at the station's own address. The own stream is the positive witness that the injection reaches the DUT - the station counts it as diff --git a/docs/station-client.md b/docs/station-client.md index 47c1cd4e..1e31aed5 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -17,12 +17,19 @@ integrator - the scanner, the re-join policy and the data plane. rule) and outside the mutex the RX callback takes (IRadio's lock rule). - Cleared on the way out whenever an arm was attempted; the result is printed (`station identity clear: restored (verified)` / `NOT VERIFIED`). On - MT7612U the clear is trivially true, since the arm wrote nothing. - -On MT7612U the arm writes no register: it verifies that the station's address -is the adapter's own `MT_MAC_ADDR` and that the auto-responder is enabled -(`docs/mt7612u-station-identity.md`). That is why the station's address always -comes from `GetPermanentMacAddress`. + MT7612U the clear puts the monitor receive filter back and is true once it + reads back. + +On MT7612U the arm writes no identity register: it verifies that the +station's address is the adapter's own `MT_MAC_ADDR` and that the +auto-responder is enabled (`docs/mt7612u-station-identity.md`). That is why +the station's address always comes from `GetPermanentMacAddress`. The one +register it writes is the receive filter: armed, the station runs the managed +filter `0x00015f97`, which drops unicast not addressed to it but keeps every +BSS's beacons and group traffic; unarmed (`DEVOURER_STA_ARM=0`) it stays +promiscuous. `StationSm::on_rx` is the address filter either way; its ledger +`not-for-us` count (our BSS, someone else's unicast) is the witness that the +managed filter is on. ## What the station transmits @@ -106,18 +113,22 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | - | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning | - | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: the link over a 30 s ping window is reported, not scored | + | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it) | + | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | | `reconnect` | hostapd stopped and restarted: the station reports the lost link; second four-way within the bound, measured from hostapd being started again; ping 0% loss over a 30 s window; ledger 2 associations, 1 reconnect; one arm across the re-join; the clear | | `noreconnect` | as `reconnect` with `DEVOURER_STA_RECONNECT=0`: the lost link reported; no re-join; the ledger ends Failed after 1 association | - The arm differs by die. On MT7612U it writes no register, so an unarmed - link may work and `ClearStationIdentity` is trivially true: the clear is - scored as having run and its result is information. On a Realtek die the - arm writes the port registers and unarmed the MAC does not acknowledge - own-addressed unicast (`docs/realtek-station-arm.md`), so `noarm` is a - control that can fail and the clear must verify. + The arm differs by die. On MT7612U it writes only the receive filter, so + an unarmed link may work and `noarm` is the filter's control. On a Realtek + die the arm writes the port registers and unarmed the MAC does not + acknowledge own-addressed unicast (`docs/realtek-station-arm.md`), so + `noarm` is the arm's control and can fail. On both, the clear must verify. + + The injections (`INJECT_S`, `INJECT_PPS` each, `FOREIGN`) ride a monitor + vif on the AP's phy (`tests/sta_unicast_inject.py`) and run only for an + MT7612U DUT; a phy that cannot add one makes the filter check + INCONCLUSIVE, not the cell. The arm is per BSSID: a re-join to the same BSSID keeps it rather than arming again, and on Realtek the second association is the proof it still diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index d1a02e2d..3036a979 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -17,13 +17,27 @@ # every data-plane check first asserts that `ip route get` leaves through the # station's TAP. # -# THE ARM DIFFERS BY DIE. On MT7612U the arm writes no register (it checks -# MT_MAC_ADDR and the auto-responder), so DEVOURER_STA_ARM=0 changes nothing -# and ClearStationIdentity is trivially true: both are information there. On -# a Realtek die the arm WRITES the port registers (docs/realtek-station-arm.md) -# and unarmed the MAC does not acknowledge own-addressed unicast, so the AP's -# authentication response is never ACKed and hostapd never lets the station -# in: `noarm` is a real control, and the clear's verification is scored. +# THE ARM DIFFERS BY DIE. On MT7612U the arm writes no identity register (it +# checks MT_MAC_ADDR and the auto-responder) but installs the managed receive +# filter 0x00015f97 in place of the RX loop's monitor filter, so an unarmed +# station still gets in and acknowledges, and what DEVOURER_STA_ARM=0 changes +# is the filter: `noarm` is the filter's control there (the unicast injection +# below). On a Realtek die the arm WRITES the port registers +# (docs/realtek-station-arm.md) and unarmed the MAC does not acknowledge +# own-addressed unicast, so the AP's authentication response is never ACKed +# and hostapd never lets the station in: `noarm` is the arm's control. On +# both, the clear restores what the arm wrote and its verification is scored. +# +# THE MANAGED-FILTER STIMULUS (MT7612U only; on a Realtek DUT it is skipped, +# said as INFO). Once the wpa2 / noarm four-way is in, a monitor vif on the +# AP's phy injects two plaintext unicast data streams from the AP's BSSID +# for INJECT_S at INJECT_PPS each (tests/sta_unicast_inject.py): one to +# FOREIGN (an address nobody holds), one to the station's own address. The +# own stream is the positive witness that the injection reaches the DUT - the +# station refuses it on a WPA2 link and counts it (`plaintext refused`), and +# it must reach half of what was injected, else the filter check is +# INCONCLUSIVE. A phy that cannot add a monitor vif makes the check +# INCONCLUSIVE, not the cell. # # Cells (each scored against its own witness): # open hostapd open, with a ping running from the start: the AP @@ -33,7 +47,7 @@ # a probe request on associating, and re-joins when its ARP gets # no unicast reply, kConfirmMs); ping 0% loss over # the TAP; the ledger shows plaintext and no decryption; the arm -# line; the clear ran on exit (verified, on Realtek). +# line; the clear ran on exit and verified. # wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both # rekeys completed at the AP, ping 0% loss before and after them, # ONE association throughout, no four-way MIC failure, and @@ -41,6 +55,8 @@ # one-frame switchover window in which the AP still sends under # the old key - see rx_frame() in sta_client.cpp), the arm line, the clear (as for open), and # NO tx.retry_limit=0 warning (the station default is nonzero). +# MT7612U also: the managed filter - the own stream arrives and +# `not-for-us` stays under 1% of the foreign one. # noarm the control: wpa2 with DEVOURER_STA_ARM=0 - nothing else # changes. Scored everywhere: no SetStationIdentity and no clear # ran. On Realtek also scored: the station tried (beacons seen, @@ -49,8 +65,10 @@ # the arm is not what makes the wpa2 cell work. Its positive # control is the wpa2 cell of the SAME run: without an armed # four-way against the same hostapd configuration, a silent AP -# proves nothing and noarm is INCONCLUSIVE. On MT7612U the link -# is reported over a PING_S ping window, not scored. +# proves nothing and noarm is INCONCLUSIVE. On MT7612U also +# scored: under the monitor filter BOTH injected streams arrive +# (each at least half); the link is reported over a PING_S ping +# window, not scored. # retry0 wpa2 with DEVOURER_TX_RETRY_LIMIT=0. Scored: the library's # arm-time warning about tx.retry_limit=0 (logged inside a # successful SetStationIdentity, so just before sta_client's @@ -115,8 +133,8 @@ # # Env: DUT_SYSFS, AP_SYSFS, DUT_VID, DUT_PID, CH, SSID, PSK, SECS, REKEY_S, # PTK_REKEY_S, PING_S, DOWN_S, REJOIN_S, AP_OFDM_ONLY, HOSTAPD_DEBUG, -# FW_DIR, NS, TAP, -# READY_TIMEOUT, OUT, BUILD. +# INJECT_S, INJECT_PPS, FOREIGN, FW_DIR, NS, TAP, READY_TIMEOUT, OUT, +# BUILD. # Cells: open | wpa2 | noarm | retry0 | reconnect | noreconnect | all # (default: all six). @@ -149,6 +167,12 @@ DOWN_S="${DOWN_S:-8}" REJOIN_S="${REJOIN_S:-30}" AP_OFDM_ONLY="${AP_OFDM_ONLY:-0}" HOSTAPD_DEBUG="${HOSTAPD_DEBUG:-0}" +# The managed-filter stimulus (wpa2, noarm; MT7612U DUT). FOREIGN is locally +# administered and held by nobody on the rig. +INJECT_S="${INJECT_S:-10}" +INJECT_PPS="${INJECT_PPS:-100}" +FOREIGN="${FOREIGN:-02:00:00:de:ad:01}" +MON=staon_mon FW_DIR="${FW_DIR:-/lib/firmware/mediatek}" NS="${NS:-staonair}" TAP="${TAP:-dvsta0}" @@ -165,10 +189,13 @@ for c in $CELLS; do done [ "$(id -u)" = 0 ] || { echo "must run as root"; exit 2; } -for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S AP_OFDM_ONLY HOSTAPD_DEBUG READY_TIMEOUT; do +for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S AP_OFDM_ONLY HOSTAPD_DEBUG READY_TIMEOUT INJECT_S INJECT_PPS; do case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac done [ "$PING_S" -ge 1 ] || { echo "PING_S must be at least 1"; exit 2; } +if [ "$INJECT_S" -lt 1 ] || [ "$INJECT_PPS" -lt 1 ] || [ "$INJECT_PPS" -gt 2000 ]; then + echo "INJECT_S must be at least 1, INJECT_PPS 1..2000 (the injector's cap)"; exit 2 +fi [ -n "$DUT_SYSFS" ] || { echo "DUT_SYSFS is required (lsusb -t)"; exit 2; } [ -n "$AP_SYSFS" ] || { echo "AP_SYSFS is required (lsusb -t)"; exit 2; } [ "$DUT_SYSFS" != "$AP_SYSFS" ] || { echo "DUT_SYSFS and AP_SYSFS must differ"; exit 2; } @@ -207,7 +234,7 @@ DUT_PID="0x${dut_have#*:}" . "$ROOT/tests/mt7612u_sta_lib.sh" sta_out_prepare || exit 2 sta_lock_take || exit 2 -sta_pid_init sta hostapd probe +sta_pid_init sta hostapd probe inject inject_own # --- the AP adapter: refused unless it is plainly a spare wireless adapter -- ap_refuse() { echo "refusing AP_SYSFS=$AP_SYSFS: $*"; sta_lock_release; exit 2; } @@ -240,7 +267,10 @@ cleanup() { [ -n "$STA_PID" ] && sta_stop [ "$STA_HUNG" = yes ] && sta_gone=1 sta_pid_kill probe + sta_pid_kill inject + sta_pid_kill inject_own sta_pid_kill hostapd + ns_exists && ip netns exec "$NS" iw dev "$MON" del 2>/dev/null # THE PHY COMES BACK BEFORE THE NAMESPACE GOES: `ip netns del` on a # namespace still holding a phy destroys the phy (only a re-enumeration # brings it back). So the delete is conditional on the move having worked. @@ -494,23 +524,87 @@ fault_cause() { grep -m1 'FAULT\|threw' "$OUT/sta_$1.log" 2>/dev/null | sed 's/^ *//' } -# The clear ran on exit. Its result is scored on a Realtek die, where the -# clear writes registers and verifies them; on MT7612U it is trivially true -# (the arm wrote nothing) and is information. (An unverified clear is also a -# station FAULT, exit 3, scored by sta_stop.) +# The clear ran on exit and verified: on a Realtek die it restores the port +# registers, on MT7612U the pre-arm (monitor) receive filter, and either reads +# back. (An unverified clear is also a station FAULT, exit 3, scored by +# sta_stop.) check_cleared() { # $1 cell local line line=$(grep -m1 'station identity clear:' "$OUT/sta_$1.log" | sed 's/^ *//') - if [ -z "$line" ]; then - bad "$1: ClearStationIdentity did not run on exit" - elif [ "$DUT_KIND" = realtek ]; then - case "$line" in - *"restored (verified)"*) ok "$1: ClearStationIdentity ran and verified on exit" ;; - *) bad "$1: ClearStationIdentity did not verify: $line" ;; - esac + case "$line" in + *"restored (verified)"*) ok "$1: ClearStationIdentity ran and verified on exit" ;; + '') bad "$1: ClearStationIdentity did not run on exit" ;; + *) bad "$1: ClearStationIdentity did not verify: $line" ;; + esac +} + +# The managed-filter stimulus (header): the two streams off a monitor vif on +# the AP's own phy, while the station is associated. They share a transmitter +# address, so they get disjoint sequence ranges (0 and 2048): the managed +# filter's hardware DUP drop must not take one for a retransmission of the +# other. The injector counts frames it SUBMITTED, not frames that aired - +# hence the own stream as the positive witness. Each PID is recorded on the +# statement after its launch, and every injector is bounded by `timeout -k` +# whatever happens to the harness. Sets INJ_FOREIGN / INJ_OWN (empty when it +# could not run). +INJ_FOREIGN=""; INJ_OWN="" +inject_count() { sed -n 's/^injected \([0-9][0-9]*\) unicast frames.*/\1/p' "$1" 2>/dev/null | tail -1; } +inject_unicast() { # $1 cell + INJ_FOREIGN=""; INJ_OWN="" + local bssid own pf po + bssid=$(ip netns exec "$NS" cat "/sys/class/net/$AP_IF/address" 2>/dev/null) + own=$(own_of "$1") + [ -n "$bssid" ] && [ -n "$own" ] || return 1 + ip netns exec "$NS" iw dev "$MON" del 2>/dev/null + if ! { ip netns exec "$NS" iw phy "$AP_PHY" interface add "$MON" type monitor 2>/dev/null && + ip netns exec "$NS" ip link set "$MON" up 2>/dev/null; }; then + ip netns exec "$NS" iw dev "$MON" del 2>/dev/null + return 1 + fi + ip netns exec "$NS" timeout -k 5 $(( INJECT_S + 10 )) \ + python3 "$ROOT/tests/sta_unicast_inject.py" "$MON" "$FOREIGN" "$bssid" \ + "$INJECT_S" "$INJECT_PPS" > "$OUT/inject_$1.log" 2>&1 & + pf=$!; sta_pid_record inject "$pf" + ip netns exec "$NS" timeout -k 5 $(( INJECT_S + 10 )) \ + python3 "$ROOT/tests/sta_unicast_inject.py" "$MON" "$own" "$bssid" \ + "$INJECT_S" "$INJECT_PPS" 2048 > "$OUT/inject_own_$1.log" 2>&1 & + po=$!; sta_pid_record inject_own "$po" + wait "$pf" 2>/dev/null; wait "$po" 2>/dev/null + rm -f "$OUT/.pid_inject" "$OUT/.pid_inject_own" + ip netns exec "$NS" iw dev "$MON" del 2>/dev/null + INJ_FOREIGN=$(inject_count "$OUT/inject_$1.log") + INJ_OWN=$(inject_count "$OUT/inject_own_$1.log") + [ -n "$INJ_FOREIGN" ] && [ -n "$INJ_OWN" ] +} + +# Score the stimulus against the station's ledger. $1 cell, $2 managed | +# monitor (what the filter should be). Both need the OWN stream to have +# arrived (>= half) before the FOREIGN count means anything. +check_filter() { + local nfu ref + nfu=$(led "$1" 'not-for-us'); ref=$(led "$1" 'plaintext refused') + if [ "${INJ_FOREIGN:-0}" = 0 ] || [ "${INJ_OWN:-0}" = 0 ]; then + inc "$1: the unicast injectors did not run (no monitor vif on $AP_PHY?) - see $OUT/inject_$1.log, $OUT/inject_own_$1.log" + return + fi + if [ -z "$nfu" ] || [ -z "$ref" ]; then + inc "$1: no not-for-us / plaintext refused count in the ledger - see $OUT/sta_$1.log" + return + fi + if [ $(( ref * 2 )) -lt "$INJ_OWN" ]; then + inc "$1: only $ref of $INJ_OWN frames injected at the station's own address arrived - the injection is not reaching the DUT, so the filter check is not evidence" + return + fi + if [ "$2" = managed ]; then + if [ $(( nfu * 100 )) -lt "$INJ_FOREIGN" ]; then + ok "$1: managed filter on: own-addressed $ref of $INJ_OWN arrived, foreign not-for-us=$nfu of $INJ_FOREIGN" + else + bad "$1: armed station still receives others' unicast: not-for-us=$nfu of $INJ_FOREIGN (own-addressed $ref of $INJ_OWN arrived)" + fi + elif [ $(( nfu * 2 )) -ge "$INJ_FOREIGN" ]; then + ok "$1: control: unarmed, both streams arrive: own-addressed $ref of $INJ_OWN, foreign not-for-us=$nfu of $INJ_FOREIGN" else - ok "$1: ClearStationIdentity ran on exit" - info "$1: $line (trivially true on MT7612U: the arm wrote nothing)" + bad "$1: unarmed (monitor filter) yet the foreign stream did not arrive: not-for-us=$nfu of $INJ_FOREIGN while own-addressed $ref of $INJ_OWN did" fi } @@ -614,6 +708,11 @@ run_wpa2() { if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill hostapd; return 1; fi WPA2_LINK="four-way completed, ping $(loss "$cell")" [ "$p" = 0 ] && WPA2_LINK="$WPA2_LINK OK" + INJ_FOREIGN=""; INJ_OWN="" + if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then + inject_unicast "$cell" + proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill hostapd; return 1; } + fi [ "$cell" = wpa2 ] || { cell_end; return 0; } # The rekeys: waited for while the station is alive. "pairwise key @@ -666,6 +765,8 @@ cell_wpa2() { bad "wpa2: ledger associations=${assoc:-?} answered=${ans:-?} PTK=${ptk:-?} four-way MIC failures=${fwmic:-?} data-plane MIC failures=${mic:-?} (expected 1, >0, >=2, 0, <= PTK)" fi check_armed wpa2 + if [ "$DUT_KIND" = mt7612u ]; then check_filter wpa2 managed + else info "wpa2: the managed-filter check is MT7612U-only (skipped on $DUT_KIND)"; fi # The station default retry limit is nonzero, so the arm must NOT warn. if grep -q 'station identity armed with tx.retry_limit=0' "$OUT/sta_wpa2.log"; then bad "wpa2: the tx.retry_limit=0 warning fired with the station default limit" @@ -684,7 +785,8 @@ cell_noarm() { bad "noarm: an arm or clear ran with DEVOURER_STA_ARM=0 ($(grep -m1 'station identity' "$OUT/sta_noarm.log"))" fi if [ "$DUT_KIND" != realtek ]; then - info "noarm: link unarmed: ${WPA2_LINK:-no result} (the MT7612U arm writes no register; a difference from wpa2 here is worth a look)" + [ "$WPA2_LINK" = "no four-way" ] || check_filter noarm monitor + info "noarm: link unarmed: ${WPA2_LINK:-no result} (unarmed = the monitor filter; a link difference from wpa2 here is worth a look)" return fi # Realtek: unarmed, the MAC does not ACK own-addressed unicast, so hostapd diff --git a/tests/sta_unicast_inject.py b/tests/sta_unicast_inject.py index 6322055a..2629adc3 100755 --- a/tests/sta_unicast_inject.py +++ b/tests/sta_unicast_inject.py @@ -12,7 +12,11 @@ The monitor vif lives on the AP's own phy, so the injection rides the AP's radio and lands on the AP's channel without needing a third adapter. - sta_unicast_inject.py [seconds] [pps] + sta_unicast_inject.py [seconds] [pps] [seq0] + +seq0 (0..4095, default 0) is the first sequence number. Two injectors sharing +a transmitter address must use disjoint ranges, or a receiver's duplicate +detection can take one stream's frames for the other's. Note what this does NOT do: mac80211 marks injected frames no-ack by default, so these do not solicit an acknowledgement and cannot be used to measure one. @@ -71,7 +75,7 @@ def on_term(signum, frame): def main(argv): - if len(argv) < 4 or len(argv) > 6: + if len(argv) < 4 or len(argv) > 7: print(__doc__, file=sys.stderr) return 2 try: @@ -80,6 +84,14 @@ def main(argv): bssid = mac(argv[3]) secs = positive('seconds', argv[4]) if len(argv) > 4 else 120.0 pps = positive('pps', argv[5], MAX_PPS) if len(argv) > 5 else 300.0 + seq0 = 0 + if len(argv) > 6: + try: + seq0 = int(argv[6]) + except ValueError: + raise Usage('seq0 must be an integer: %r' % argv[6]) + if not 0 <= seq0 <= 4095: + raise Usage('seq0 must be 0..4095: %r' % argv[6]) except Usage as e: print('sta_unicast_inject: %s' % e, file=sys.stderr) print(__doc__, file=sys.stderr) @@ -98,7 +110,7 @@ def main(argv): gap = 1.0 / pps end = time.time() + secs sent = 0 - seq = 0 + seq = seq0 try: while time.time() < end: hdr = (struct.pack(' Date: Sat, 3 Oct 2026 15:12:56 +0200 Subject: [PATCH 12/53] mt7612u: review fixes for the station receive filter - StationIdentity.h: an arm after a DROP keeps the recorded pre-arm filter instead of recording the register. The drop's restore write may have missed and left the managed filter there; recording it made the clear "restore" a managed receiver and report it verified. Headless check in test_rx_filter_ownership. - Mt7612uRadio::Stop(): the best-effort clear is guarded. Its own WARN goes through the same logger as everything else (log_trampoline), and an escape there - with `_dev` already nulled - skipped the stop and the close and leaked the handle, which is exactly what the comment said must not happen. - mt7612uprobe staid: the two clears between cases are checked (the second one is the lost-arm clear that re-writes and verifies the filter) instead of their results being dropped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- src/mt7612u/Mt7612uRadio.cpp | 10 ++++++++-- src/mt7612u/StationIdentity.h | 6 ++++-- src/mt7612u/tools/bringup.cpp | 7 +++++-- tests/mt7612u_station_selftest.cpp | 9 +++++++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 82a9ed1e..42e2f160 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -726,8 +726,14 @@ void Mt7612uRadio::Stop() { * station still armed here would leave the managed receive filter for * whoever opens the adapter next. Put the pre-arm filter back first; * a no-op with nothing armed. Only a flag here - the logger can throw, - * and nothing may skip the stop and the close below. */ - filter_left = mt7612u_clear_station_identity(dev) != 0; + * and nothing may skip the stop and the close below: `_dev` is already + * null, so an escape would leak the handle. The clear's own WARN goes + * through the same logger (log_trampoline), hence the catch. */ + try { + filter_left = mt7612u_clear_station_identity(dev) != 0; + } catch (...) { + filter_left = true; + } mt7612u_stop(dev); } } diff --git a/src/mt7612u/StationIdentity.h b/src/mt7612u/StationIdentity.h index 258357fa..ebbeb524 100644 --- a/src/mt7612u/StationIdentity.h +++ b/src/mt7612u/StationIdentity.h @@ -150,12 +150,14 @@ mt7612u_port_compare(const uint8_t *port, int read_ok, const uint8_t *own) /* `cur_filtr` is what MT_RX_FILTR_CFG held when this arm was asked for. A * RE-arm keeps the value the first arm recorded: the register then holds the * managed filter that arm installed, and restoring THAT on clear would leave - * the receiver managed after the station is gone. */ + * the receiver managed after the station is gone. So does an arm after a + * drop: the record already holds the consumer's latest request, while the + * register may still hold the managed filter if the drop's write missed. */ static inline void mt7612u_sta_arm(struct mt7612u_sta_state *s, const uint8_t *own, const uint8_t *bssid, uint32_t cur_filtr) { - if (!s->armed) + if (!s->armed && !s->lost) s->rx_filtr_restore = cur_filtr; memcpy(s->own, own, 6); memcpy(s->bssid, bssid, 6); diff --git a/src/mt7612u/tools/bringup.cpp b/src/mt7612u/tools/bringup.cpp index ff57d910..e33744e6 100644 --- a/src/mt7612u/tools/bringup.cpp +++ b/src/mt7612u/tools/bringup.cpp @@ -5186,7 +5186,8 @@ static int gate_staid(void) printf(" SKIP could not arm an ACK responder - case 5 not run\n"); fail++; /* the most important case did not run; do not pass. */ } - mt7612u_clear_station_identity(&dev); + CHK(mt7612u_clear_station_identity(&dev) == 0 && staid_filtr_is(mon), + "clear after case 5 restores the monitor receive filter"); /* * 6. THE OTHER ORDERING, the one a real caller is likelier to hit. Case @@ -5210,7 +5211,9 @@ static int gate_staid(void) printf(" SKIP could not set up case 6\n"); fail++; } - mt7612u_clear_station_identity(&dev); + /* After a drop: the clear re-writes the pre-arm filter and verifies it. */ + CHK(mt7612u_clear_station_identity(&dev) == 0 && staid_filtr_is(mon), + "clear after the drop verifies the monitor receive filter"); #undef CHK printf("\nGATE STAID: %d passed, %d failed\n", pass, fail); diff --git a/tests/mt7612u_station_selftest.cpp b/tests/mt7612u_station_selftest.cpp index 25db895c..638b479c 100644 --- a/tests/mt7612u_station_selftest.cpp +++ b/tests/mt7612u_station_selftest.cpp @@ -278,6 +278,15 @@ void test_rx_filter_ownership() { /* An arm after a clear records afresh. */ mt7612u_sta_arm(&s, kOwn, kBssid, keep); CHECK(s.rx_filtr_restore == keep); + + /* An arm after a DROP keeps the record: the drop's restore write may have + * missed and left the managed filter in the register, and recording THAT + * would make the clear "restore" a managed receiver and verify it. */ + CHECK(mt7612u_sta_port_observed(&s, MT7612U_PORT_DIFFERENT, 0) == + MT7612U_STA_EV_DROPPED); + mt7612u_sta_arm(&s, kOwn, kBssid, kManaged); + CHECK(s.armed == 1 && s.lost == 0); + CHECK(s.rx_filtr_restore == keep); } } // namespace From b6335c86009242ae3c1994e33e6aa5ca654dc01d Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:12:56 +0200 Subject: [PATCH 13/53] tests: sta_client's confirmation window opens at the host's first question The open-association confirmation judged "kConfirmUplink frames sent and kConfirmMs since the ASSOCIATION": a host idle for longer than kConfirmMs that then sent a burst of three unicast frames (a neighbour still cached from a previous association needs no ARP first) was judged lost on the next loop pass, before any reply could arrive, and re-joined a healthy AP. The window now opens the first time supervise() sees a question, which is what docs/station-client.md already said ("within 5 s"). Headless: test_a_burst_after_idle_gets_its_window (fails against the old rule); the existing cells open the window explicitly. The comment and the doc now state the one-way-unicast limit as it is: judged until the host's stack re-verifies the neighbour, not only with static entries. on_association() goes back to taking no argument. sta_client_onair.sh: a timed-out AP-netdev wait leaves the interface up, as the same block in #466 does, so the two read the same. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 11 +++++---- tests/sta_client.cpp | 42 ++++++++++++++++++++++------------- tests/sta_client_selftest.inc | 41 +++++++++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 1e31aed5..a2fadfad 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -80,10 +80,13 @@ An open association is confirmed by the AP's first unicast frame to the station. A station cannot see the AP's side: if the AP never saw the association response acknowledged, it does not hold the station, drops its traffic and may never say so. So once the host has asked something - three -unicast or ARP frames - and no unicast reply has come within 5 s, the link -is lost as `unconfirmed` (`StationSm::link_lost`) and re-joined under the -policy above. Multicast chatter and an idle host are never judged; WPA2 -needs no such rule (the four-way is the confirmation). The ledger counts +unicast or ARP frames - and no unicast reply has come within 5 s of the +first, the link is lost as `unconfirmed` (`StationSm::link_lost`) and +re-joined under the policy above. Multicast chatter and an idle host are +never judged; WPA2 needs no such rule (the four-way is the confirmation). +One-way unicast to a neighbour the host has already resolved gets no reply +either, and is judged the same way until the host's stack re-verifies that +neighbour (a unicast ARP the AP answers). The ledger counts these (`unconfirmed=`), and repeated association responses (`assoc_repeat=`). diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 15737cd4..b01e958f 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -302,18 +302,23 @@ int8_t rssi_dbm(uint8_t raw) { * the AP starts it only for a station it holds, and HandshakeTimeout covers * the rest.) So an open association counts as unconfirmed until a unicast * data frame from the AP arrives for this station; if the host has sent - * kConfirmUplink frames and kConfirmMs has passed without one, the link is - * lost (StationSm::link_lost) and the ordinary re-join policy takes over. - * Only frames that ask for a reply count as uplink here - unicast, and ARP - * (its request is broadcast, its reply unicast) - so a host's multicast - * chatter (IPv6 RS/MLD, mDNS) on an idle link is never judged; an idle host - * is never judged at all. A host sending one-way unicast traffic with - * static neighbour entries would be judged lost - the price of having no - * other evidence. */ + * kConfirmUplink frames and kConfirmMs has passed since the first of them + * without one, the link is lost (StationSm::link_lost) and the ordinary + * re-join policy takes over. The window opens at the host's first question, + * not at the association: a host idle for longer than kConfirmMs that then + * sends a burst must still get its kConfirmMs for the reply. Only frames + * that ask for a reply count as uplink here - unicast, and ARP (its request + * is broadcast, its reply unicast) - so a host's multicast chatter (IPv6 + * RS/MLD, mDNS) on an idle link is never judged; an idle host is never + * judged at all. A host whose only traffic is one-way unicast to a neighbour + * it already resolved (static entries, or a cache kept across a re-join) is + * judged lost until its stack re-verifies that neighbour with a unicast ARP + * the AP answers - the price of having no other evidence. */ constexpr uint32_t kConfirmMs = 5000; constexpr uint32_t kConfirmUplink = 3; bool g_unconfirmed = false; -uint32_t g_assoc_ms = 0; +bool g_uplink_seen = false; /* supervise() saw the first question */ +uint32_t g_uplink_first_ms = 0; /* ...at this time: the window opens */ uint32_t g_uplink_unconfirmed = 0; std::atomic g_unconfirmed_lost{0}; @@ -349,9 +354,9 @@ void nudge(uint32_t now) { g_nudge_ms = now; } -void on_association(uint32_t now) { +void on_association() { g_unconfirmed = g_sm.security() == StationSm::Security::Open; - g_assoc_ms = now; + g_uplink_seen = false; g_uplink_unconfirmed = 0; g_rx_dup.reset(); g_failed_noted = false; @@ -450,7 +455,7 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { g_nudge_eapol_rx = g_sm.eapol_rx; } if (before != StationSm::State::Connected && g_sm.connected()) - on_association(now); + on_association(); if (g_sm.connected()) note_keys(); /* The data plane runs only on a live association: a protected frame that @@ -724,14 +729,19 @@ uint8_t supervise(uint32_t now) { } /* An unconfirmed open association the host has been talking through - * (see kConfirmMs) - lost, through the ordinary failure path below. */ + * (see kConfirmMs) - lost, through the ordinary failure path below. The + * window opens the first time this pass sees a question. */ + if (g_unconfirmed && g_uplink_unconfirmed > 0 && !g_uplink_seen) { + g_uplink_seen = true; + g_uplink_first_ms = now; + } if (g_sm.state() == StationSm::State::Connected && g_unconfirmed && - g_uplink_unconfirmed >= kConfirmUplink && - (uint32_t)(now - g_assoc_ms) >= kConfirmMs) { + g_uplink_seen && g_uplink_unconfirmed >= kConfirmUplink && + (uint32_t)(now - g_uplink_first_ms) >= kConfirmMs) { std::fprintf(stderr, " station association unconfirmed: %u frames sent, no " "unicast reply from the AP in %u ms\n", - g_uplink_unconfirmed, (unsigned)(now - g_assoc_ms)); + g_uplink_unconfirmed, (unsigned)(now - g_uplink_first_ms)); g_unconfirmed = false; g_unconfirmed_lost.fetch_add(1); g_sm.link_lost(); diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index ca5ee33a..cc79ae6d 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -108,7 +108,8 @@ void reset_all() { g_tap_read_err = 0; g_end = RunEnd{}; g_unconfirmed = false; - g_assoc_ms = 0; + g_uplink_seen = false; + g_uplink_first_ms = 0; g_uplink_unconfirmed = 0; g_unconfirmed_lost = 0; g_nudges = 0; @@ -1213,6 +1214,7 @@ void test_multicast_chatter_is_not_judged() { } drain_tx(); supervise(kConfirmMs * 5); + supervise(kConfirmMs * 6); check(g_sm.state() == StationSm::State::Failed && g_sm.fail_reason() == StationSm::Failure::Unconfirmed, "unanswered ARP requests are judged"); @@ -1245,10 +1247,11 @@ void test_an_unconfirmed_open_association_is_lost() { Ap ap; open_link_with_uplink(ap, (int)kConfirmUplink); check(g_sm.state() == StationSm::State::Connected, "the open link is up"); - supervise(kConfirmMs - 1); + supervise(10); /* the window opens */ + supervise(10 + kConfirmMs - 1); check(g_sm.state() == StationSm::State::Connected, "...and not judged before kConfirmMs"); - supervise(kConfirmMs); + supervise(10 + kConfirmMs); check(g_sm.state() == StationSm::State::Failed && g_sm.fail_reason() == StationSm::Failure::Unconfirmed, "uplink with no unicast reply: lost as Unconfirmed"); @@ -1256,11 +1259,38 @@ void test_an_unconfirmed_open_association_is_lost() { "...counted once, as a lost link"); const uint64_t joins = g_joins.load(); const std::vector b = beacon(false); - rx_frame(b.data(), b.size(), -40, kConfirmMs + 200); - supervise(kConfirmMs + 200); + rx_frame(b.data(), b.size(), -40, kConfirmMs + 210); + supervise(kConfirmMs + 210); check(g_joins.load() == joins + 1, "...and re-joined after the backoff"); } +/* THE WINDOW OPENS AT THE HOST'S FIRST QUESTION, not at the association: a + * host idle for longer than kConfirmMs that then asks kConfirmUplink things + * at once still gets kConfirmMs for the reply. */ +void test_a_burst_after_idle_gets_its_window() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + supervise(kConfirmMs * 4); /* idle, long past kConfirmMs */ + uint8_t e[60] = {0}; + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); + drain_tx(); + supervise(kConfirmMs * 4 + 1); + supervise(kConfirmMs * 4 + 2); + check(g_sm.state() == StationSm::State::Connected, + "a burst after an idle spell is not judged at once"); + const uint8_t pl[16] = {1}; + const std::vector d = ap.plain_to_sta(pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, kConfirmMs * 4 + 50); + (void)tap_read(); + supervise(kConfirmMs * 6); + check(g_sm.state() == StationSm::State::Connected, + "...and the reply inside its window confirms it"); +} + void test_a_unicast_reply_confirms_the_association() { reset_all(); Ap ap; @@ -2183,6 +2213,7 @@ int self_test() { selftest::test_an_unconfirmed_open_association_is_lost(); selftest::test_a_unicast_reply_confirms_the_association(); selftest::test_an_idle_open_association_is_not_judged(); + selftest::test_a_burst_after_idle_gets_its_window(); selftest::test_multicast_chatter_is_not_judged(); selftest::test_an_accepted_association_nudges_the_ap(); selftest::test_a_nudge_touches_no_tuning_or_liveness_state(); From a56038509445be3936f7cf08a1339df26f8007d8 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:45:57 +0200 Subject: [PATCH 14/53] mt7612u: a refused arm verifies its undo, and records one that misses When the managed filter did not read back, the arm put the previous value back with a bare unchecked write. If that missed too, the register was left managed with nothing recorded: ClearStationIdentity reported "restored (verified)" for a receiver still managed, and a retried arm (sta_client retries) read the managed value back as the pre-arm one. The undo is now verified. An undo that misses is recorded (mt7612u_sta_strand: `stranded`, with the pre-arm value), so the clear still restores it, a filter request is still recorded, and the next arm keeps the recorded value instead of reading the register. Headless: test_stranded_undo_keeps_the_pre_arm_filter. docs/mt7612u-station-identity.md: says so, and no longer claims the RX loop never writes the filter - StartRxLoop's mt7612u_set_monitor_rx() does, mediated while armed. The Stop() comment no longer overstates what the catch protects (mt7612u_stop sits outside it). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 10 +++++++--- src/mt7612u/Mt7612uRadio.cpp | 7 ++++--- src/mt7612u/StationIdentity.h | 25 +++++++++++++++++++++---- src/mt7612u/station.cpp | 21 +++++++++++++++------ tests/mt7612u_station_selftest.cpp | 23 +++++++++++++++++++++++ 5 files changed, 70 insertions(+), 16 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index 24c9e41a..eaec2b11 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -299,15 +299,19 @@ receiving and only stops acknowledging (issue #461). recorded value back and returns true only once that reads back (a failure keeps the arm recorded, so a second clear retries). A refusal returns before the filter is read, so it writes nothing; a managed write that does not read -back is undone and refused. While armed, `mt7612u_set_monitor_rx()` - which +back is undone (the undo read back too) and refused, and an undo that does +not read back either is recorded, so the clear still restores the pre-arm +value and a retried arm does not take the stranded managed filter for it. +While armed, `mt7612u_set_monitor_rx()` - which `StartRxLoop` calls after every MAC start - keeps the managed filter and only records the request, so the arm is order-independent. A beacon or ACK responder that moves the port identity drops the arm and puts the pre-arm filter back (the AP and responder paths depend on the monitor filter's `DUP` clear); a failed beacon start that restores the arm reinstalls the managed filter. All of it runs under `Mt7612uRadio::_mu`, the lock the existing -filter write and every channel change already take, and the RX loop never -writes the filter. The drop and restore writes are read back and a miss is +filter write and every channel change already take; the only other writes +are `mt_mac_start()` and `StartRxLoop`'s `mt7612u_set_monitor_rx()`, the +latter mediated as above, and the RX thread itself never writes it. The drop and restore writes are read back and a miss is logged (the clear re-verifies). `Stop()` clears a still-armed station before closing, best effort, because the chip keeps its registers across a close; every bring-up also rewrites the filter in `mt_mac_start()`. The diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 42e2f160..51b5d051 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -726,9 +726,10 @@ void Mt7612uRadio::Stop() { * station still armed here would leave the managed receive filter for * whoever opens the adapter next. Put the pre-arm filter back first; * a no-op with nothing armed. Only a flag here - the logger can throw, - * and nothing may skip the stop and the close below: `_dev` is already - * null, so an escape would leak the handle. The clear's own WARN goes - * through the same logger (log_trampoline), hence the catch. */ + * and this clear must not be what skips the stop and the close below + * (`_dev` is already null, so an escape would leak the handle). Its + * own WARN goes through the same logger (log_trampoline), hence the + * catch. */ try { filter_left = mt7612u_clear_station_identity(dev) != 0; } catch (...) { diff --git a/src/mt7612u/StationIdentity.h b/src/mt7612u/StationIdentity.h index ebbeb524..ba192bb9 100644 --- a/src/mt7612u/StationIdentity.h +++ b/src/mt7612u/StationIdentity.h @@ -131,9 +131,14 @@ struct mt7612u_sta_state { /* Dropped by a port-identity move, own/bssid kept for a restore. */ int lost; /* The receive filter the station took MT_RX_FILTR_CFG from, and puts back - * when it lets go of it (clear, or a drop). Meaningful while `armed` or - * `lost`. */ + * when it lets go of it (clear, or a drop). Meaningful while `armed`, + * `lost` or `stranded`. */ uint32_t rx_filtr_restore; + /* A refused arm whose undo did not read back: the register may hold the + * managed filter with no station armed. The clear still owes the + * restore, and the next arm must not take the register for the pre-arm + * value. */ + int stranded; }; /* `port` as read from MT_MAC_ADDR (DW0 + the low half of DW1) against `own`. @@ -157,12 +162,24 @@ static inline void mt7612u_sta_arm(struct mt7612u_sta_state *s, const uint8_t *own, const uint8_t *bssid, uint32_t cur_filtr) { - if (!s->armed && !s->lost) + if (!s->armed && !s->lost && !s->stranded) s->rx_filtr_restore = cur_filtr; memcpy(s->own, own, 6); memcpy(s->bssid, bssid, 6); s->armed = 1; s->lost = 0; + s->stranded = 0; +} + +/* A refused arm's undo did not read back (see `stranded`). An arm, a drop + * or an earlier strand already holds the right restore value. */ +static inline void mt7612u_sta_strand(struct mt7612u_sta_state *s, + uint32_t pre_arm) +{ + if (!s->armed && !s->lost && !s->stranded) + s->rx_filtr_restore = pre_arm; + if (!s->armed) + s->stranded = 1; } static inline void mt7612u_sta_clear(struct mt7612u_sta_state *s) @@ -209,7 +226,7 @@ static inline uint32_t mt7612u_sta_rx_filter_request(struct mt7612u_sta_state *s, uint32_t want, uint32_t managed) { - if (s->armed || s->lost) + if (s->armed || s->lost || s->stranded) s->rx_filtr_restore = want; return s->armed ? managed : want; } diff --git a/src/mt7612u/station.cpp b/src/mt7612u/station.cpp index c0674491..7990f1aa 100644 --- a/src/mt7612u/station.cpp +++ b/src/mt7612u/station.cpp @@ -174,11 +174,20 @@ int mt7612u_set_station_identity(struct mt7612u_dev *dev, return -1; } if (sta_write_filter(dev, MT_RX_FILTR_CFG_MANAGED) != 0) { - /* Best effort: put back what it held. A previous arm, if any, - * stands - this call changed nothing it can confirm. */ - mt_wr(dev, MT_RX_FILTR_CFG, filtr); - WARN("station identity refused: the managed receive filter " - "%08x did not read back", MT_RX_FILTR_CFG_MANAGED); + /* Put back what it held, and verify. A previous arm, if any, + * stands. An undo that does not read back is recorded, so the + * clear still restores `filtr` and a retry does not take the + * stranded value for the pre-arm one. */ + if (sta_write_filter(dev, filtr) != 0) { + mt7612u_sta_strand(&dev->sta, filtr); + WARN("station identity refused: the managed receive filter " + "%08x did not read back, nor did the undo to %08x - " + "the clear will retry it", MT_RX_FILTR_CFG_MANAGED, + filtr); + } else { + WARN("station identity refused: the managed receive filter " + "%08x did not read back", MT_RX_FILTR_CFG_MANAGED); + } return -1; } mt7612u_sta_arm(&dev->sta, own, bssid, filtr); @@ -191,7 +200,7 @@ int mt7612u_clear_station_identity(struct mt7612u_dev *dev) return 0; /* Re-written for a lost arm too: its drop restored the filter best * effort, and this is where that gets verified. */ - if ((dev->sta.armed || dev->sta.lost) && + if ((dev->sta.armed || dev->sta.lost || dev->sta.stranded) && sta_write_filter(dev, dev->sta.rx_filtr_restore) != 0) { WARN("station identity clear: the pre-arm receive filter %08x did " "not read back - the arm stays recorded so a second clear " diff --git a/tests/mt7612u_station_selftest.cpp b/tests/mt7612u_station_selftest.cpp index 638b479c..4a10f83c 100644 --- a/tests/mt7612u_station_selftest.cpp +++ b/tests/mt7612u_station_selftest.cpp @@ -289,6 +289,28 @@ void test_rx_filter_ownership() { CHECK(s.rx_filtr_restore == keep); } +/* A refused first arm whose undo did not read back: the register may hold + * the managed filter with nothing armed. The pre-arm value is kept for the + * clear, a request is still recorded, and a retried arm does not take the + * stranded register for the pre-arm value. */ +void test_stranded_undo_keeps_the_pre_arm_filter() { + mt7612u_sta_state s{}; + mt7612u_sta_strand(&s, kMonitor); + CHECK(s.stranded == 1 && s.armed == 0 && s.rx_filtr_restore == kMonitor); + /* nothing armed: a request is installed as asked, and recorded */ + const uint32_t keep = MT_RX_FILTR_CFG_PHY_ERR; + CHECK(mt7612u_sta_rx_filter_request(&s, keep, kManaged) == keep); + CHECK(s.rx_filtr_restore == keep); + /* the retry reads the stranded managed value - and does not record it */ + mt7612u_sta_arm(&s, kOwn, kBssid, kManaged); + CHECK(s.armed == 1 && s.stranded == 0 && s.rx_filtr_restore == keep); + /* a strand under a live arm changes nothing: that arm's record stands */ + mt7612u_sta_strand(&s, kManaged); + CHECK(s.armed == 1 && s.stranded == 0 && s.rx_filtr_restore == keep); + mt7612u_sta_clear(&s); + CHECK(s.stranded == 0 && s.rx_filtr_restore == 0); +} + } // namespace int main() { @@ -304,6 +326,7 @@ int main() { test_check_args_needs_no_device(); test_managed_filter_bits(); test_rx_filter_ownership(); + test_stranded_undo_keeps_the_pre_arm_filter(); if (failures) { std::fprintf(stderr, "mt7612u_station_selftest: %d failure(s)\n", failures); From 044026ca71c22d74f2c3eba7b1cd1773de68b6d6 Mon Sep 17 00:00:00 2001 From: snokvist Date: Sat, 3 Oct 2026 15:45:57 +0200 Subject: [PATCH 15/53] tests: a one-way uplink is never judged unconfirmed; harness review fixes sta_client: the open-association confirmation counted any host unicast as a question, so a one-way UDP uplink to a peer behind the AP (video, telemetry - the FPV case) got nothing unicast back and was judged lost and re-joined every few seconds, for as long as it ran. Only a frame whose answer the AP must forward back now counts (solicits_reply): an ARP request that is neither gratuitous nor a DAD probe, an ICMP / ICMPv6 echo request, a unicast IPv6 neighbour solicitation, TCP, a DNS query. Headless: test_a_one_way_udp_uplink_is_not_judged (and DNS still is), gratuitous and probe ARPs not counted; the confirmation fixtures send real ICMP / TCP / ARP requests. Both fail against the old predicate. The comment and docs/station-client.md say what an unheld association under one-way traffic costs instead. probe() returns whether it built a frame, and a nudge is counted only then. sta_client_onair.sh: - the open cell's background ping is killed on the station-gone path too; - an armed managed-filter PASS is held until the noarm control of the same run has seen the foreign stream arrive (the own stream witnesses the injection path, not the foreign injector), else INCONCLUSIVE. mt7612u_sta_lib.sh: the one-run-per-OUT lock is an flock(1) on the OUT directory. mkdir plus a separate PID write let a concurrent run read an empty PID, reclaim the live lock and share OUT. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 19 ++++--- tests/mt7612u_sta_lib.sh | 50 +++++++----------- tests/sta_client.cpp | 84 +++++++++++++++++++++++------- tests/sta_client_onair.sh | 29 +++++++++-- tests/sta_client_selftest.inc | 98 +++++++++++++++++++++++++++++------ 5 files changed, 202 insertions(+), 78 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index a2fadfad..7877e193 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -80,13 +80,16 @@ An open association is confirmed by the AP's first unicast frame to the station. A station cannot see the AP's side: if the AP never saw the association response acknowledged, it does not hold the station, drops its traffic and may never say so. So once the host has asked something - three -unicast or ARP frames - and no unicast reply has come within 5 s of the -first, the link is lost as `unconfirmed` (`StationSm::link_lost`) and -re-joined under the policy above. Multicast chatter and an idle host are -never judged; WPA2 needs no such rule (the four-way is the confirmation). -One-way unicast to a neighbour the host has already resolved gets no reply -either, and is judged the same way until the host's stack re-verifies that -neighbour (a unicast ARP the AP answers). The ledger counts +frames whose answer the AP must forward back: an ARP request, an ICMP / +ICMPv6 echo request, a unicast IPv6 neighbour solicitation, TCP, a DNS +query - and no unicast reply has come within 5 s of the first, the link is +lost as `unconfirmed` (`StationSm::link_lost`) and re-joined under the +policy above. One-way traffic (a UDP video or telemetry uplink), multicast +chatter, gratuitous and probe ARPs and an idle host are never judged; an +unheld association under one-way traffic alone is found when the host's +stack next asks something (its neighbour re-verification is a unicast ARP +request). WPA2 needs no such rule (the four-way is the confirmation). The +ledger counts these (`unconfirmed=`), and repeated association responses (`assoc_repeat=`). @@ -116,7 +119,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | - | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it) | + | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | | `reconnect` | hostapd stopped and restarted: the station reports the lost link; second four-way within the bound, measured from hostapd being started again; ping 0% loss over a 30 s window; ledger 2 associations, 1 reconnect; one arm across the re-join; the clear | diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index e5caae45..46f17257 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -11,11 +11,13 @@ # must be a directory owned by root or by the invoking user (SUDO_UID # when run through sudo), and is created 0700 when missing - so a local # user cannot point this root run's writes somewhere else. -# - One run per OUT. sta_lock_take() claims $OUT/.lock (mkdir is atomic) and -# refuses while the run that holds it is alive, so two concurrent runs -# cannot share - and kill each other through - one set of PID files. A -# lock whose holder is gone is reclaimed. (The adapters are exclusive -# anyway: mt7612uprobe and the Realtek demos take a per-adapter lock.) +# - One run per OUT. sta_lock_take() takes an flock(1) on the OUT directory +# itself and refuses while another run holds it, so two concurrent runs +# cannot share - and kill each other through - one set of PID files. The +# kernel drops the lock when the last holder exits, so there is no owner +# record to race and no stale lock to reclaim. (The adapters are +# exclusive anyway: mt7612uprobe and the Realtek demos take a +# per-adapter lock.) # - Kill only what this run started, by recorded PID. No pattern kills, and # no PID read from a file an earlier run left behind: sta_pid_init() # removes stale PID files before anything is started. @@ -58,33 +60,21 @@ sta_out_prepare() { return 0 } -# A process's start time in clock ticks since boot (/proc/PID/stat field 22), -# or nothing. Field 2 is the command name in parentheses and may hold spaces, -# so the fields are counted from after its closing parenthesis. -sta_proc_start() { - sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20 -} - STA_LOCKED=no -# The lock records "PID starttime". A holder counts as live only when that -# PID exists AND started at the recorded time - a recycled PID of an -# unrelated process is a stale lock, not a live run. +# The lock lives on fd 9, opened on the OUT directory (nothing is written, +# so nothing can be redirected through a planted file). Taking and holding it +# is one atomic flock. The children this run starts inherit fd 9, so a +# process that outlives the harness (a hung sta_client) keeps OUT locked +# until it exits - which is what it should do. sta_lock_take() { - if ! mkdir "$OUT/.lock" 2>/dev/null; then - read -r _sta_holder _sta_hstart < "$OUT/.lock/pid" 2>/dev/null - case "${_sta_holder:-}" in - ''|*[!0-9]*) ;; - *) if [ -n "${_sta_hstart:-}" ] && - [ "$(sta_proc_start "$_sta_holder")" = "$_sta_hstart" ]; then - echo "OUT=$OUT is in use by run $_sta_holder - refusing; give this" \ - "run its own OUT" - return 1 - fi ;; - esac - rm -rf "$OUT/.lock" - mkdir "$OUT/.lock" 2>/dev/null || { echo "could not lock OUT=$OUT"; return 1; } + command -v flock >/dev/null 2>&1 || { echo "flock(1) is required"; return 1; } + exec 9<"$OUT" || { echo "could not open OUT=$OUT"; return 1; } + if ! flock -n 9; then + exec 9<&- + echo "OUT=$OUT is in use by another run - refusing; give this run its" \ + "own OUT" + return 1 fi - echo "$$ $(sta_proc_start "$$")" > "$OUT/.lock/pid" STA_LOCKED=yes # A reused OUT starts with no device records: a marker an earlier run left # would make this run hand back a device it never recorded or opened. @@ -95,7 +85,7 @@ sta_lock_take() { sta_lock_release() { [ "$STA_LOCKED" = yes ] || return 0 STA_LOCKED=no - rm -rf "$OUT/.lock" + exec 9<&- } sta_is_mt7612u() { diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index b01e958f..c4ec8afb 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -306,14 +306,15 @@ int8_t rssi_dbm(uint8_t raw) { * without one, the link is lost (StationSm::link_lost) and the ordinary * re-join policy takes over. The window opens at the host's first question, * not at the association: a host idle for longer than kConfirmMs that then - * sends a burst must still get its kConfirmMs for the reply. Only frames - * that ask for a reply count as uplink here - unicast, and ARP (its request - * is broadcast, its reply unicast) - so a host's multicast chatter (IPv6 - * RS/MLD, mDNS) on an idle link is never judged; an idle host is never - * judged at all. A host whose only traffic is one-way unicast to a neighbour - * it already resolved (static entries, or a cache kept across a re-join) is - * judged lost until its stack re-verifies that neighbour with a unicast ARP - * the AP answers - the price of having no other evidence. */ + * sends a burst must still get its kConfirmMs for the reply. ONLY A FRAME + * WHOSE ANSWER THE AP MUST FORWARD BACK IS A QUESTION (solicits_reply): an + * ARP request, an ICMP / ICMPv6 echo request, a unicast IPv6 neighbour + * solicitation, TCP, a DNS query. So one-way traffic - a UDP video or + * telemetry uplink, the FPV case - is never judged, and neither is a host's + * multicast chatter (IPv6 RS/MLD, mDNS), a gratuitous or probe ARP, or an + * idle host. The cost: an unheld association under one-way traffic alone is + * found only when the host's stack next asks something (its neighbour + * re-verification is a unicast ARP request). */ constexpr uint32_t kConfirmMs = 5000; constexpr uint32_t kConfirmUplink = 3; bool g_unconfirmed = false; @@ -322,6 +323,54 @@ uint32_t g_uplink_first_ms = 0; /* ...at this time: the window opens */ uint32_t g_uplink_unconfirmed = 0; std::atomic g_unconfirmed_lost{0}; +/* Whether the host's MSDU (LLC/SNAP + payload) asks for an answer the AP + * must carry back to this station (kConfirmMs above). Conservative: anything + * not recognised is not a question, so a false "lost" needs a question that + * really went unanswered. */ +bool solicits_reply(const uint8_t* msdu, size_t len, const uint8_t da[6]) { + if (len < devourer::sta::kLlcSnapLen) return false; + const uint8_t* p = msdu + devourer::sta::kLlcSnapLen; + const size_t n = len - devourer::sta::kLlcSnapLen; + const unsigned et = ((unsigned)msdu[6] << 8) | msdu[7]; + if (et == 0x0806) { + /* An ARP request (op 1) for someone else's address; not a gratuitous + * one (sender == target) or a duplicate-address probe (sender 0), which + * no one answers. Sender IP at 14, target IP at 24. */ + static const uint8_t zero4[4] = {0, 0, 0, 0}; + return n >= 28 && p[6] == 0 && p[7] == 1 && + std::memcmp(p + 14, zero4, 4) != 0 && + std::memcmp(p + 14, p + 24, 4) != 0; + } + if (da[0] & 0x01) return false; /* group-addressed IP: no unicast owed */ + uint8_t proto = 0; + const uint8_t* l4 = nullptr; + size_t l4n = 0; + if (et == 0x0800) { + if (n < 20 || (p[0] >> 4) != 4) return false; + const size_t ihl = (size_t)(p[0] & 0x0f) * 4; + /* A non-first fragment carries no transport header. */ + if (ihl < 20 || n < ihl || ((p[6] & 0x1f) | p[7]) != 0) return false; + proto = p[9]; + l4 = p + ihl; + l4n = n - ihl; + if (proto == 1) return l4n >= 1 && l4[0] == 8; /* echo request */ + } else if (et == 0x86dd) { + if (n < 40 || (p[0] >> 4) != 6) return false; + proto = p[6]; + l4 = p + 40; + l4n = n - 40; + if (proto == 58) /* echo request, unicast NS (NUD) */ + return l4n >= 1 && (l4[0] == 128 || l4[0] == 135); + } else { + return false; + } + /* TCP: a SYN or data is acknowledged; a bare ACK only follows data this + * station received, which has already confirmed it. */ + if (proto == 6) return true; + /* UDP: a DNS query only - any other UDP may be one-way. */ + return proto == 17 && l4n >= 4 && (((unsigned)l4[2] << 8) | l4[3]) == 53; +} + /* Called under g_mu when the station reaches Connected on a new * association. The duplicate cache is reset here and NOT at a rekey: it is * per transmitter and TID over Sequence Control (DupDetector, Dot11.h), which @@ -329,7 +378,7 @@ std::atomic g_unconfirmed_lost{0}; * still be a duplicate. The per-key state is not reset here: a PTK or GTK * rekey happens with the machine already Connected, so note_keys() owns it, * keyed on the supplicant's install generations. */ -void probe(uint8_t chan); /* below, with the scan */ +bool probe(uint8_t chan); /* below, with the scan */ std::atomic g_nudges{0}; /* THE NUDGE. An AP may hold a transmitted frame's TX status until its next @@ -349,8 +398,7 @@ uint32_t g_nudge_ms = 0; bool g_nudge_again = false; /* a second nudge is still owed (WPA2) */ uint32_t g_nudge_eapol_rx = 0; void nudge(uint32_t now) { - probe(g_sm.channel() ? g_sm.channel() : g_chan); - g_nudges.fetch_add(1); + if (probe(g_sm.channel() ? g_sm.channel() : g_chan)) g_nudges.fetch_add(1); g_nudge_ms = now; } @@ -635,10 +683,7 @@ bool air_msdu(const uint8_t* msdu, size_t len, const uint8_t da[6], if (!protect) { hdr.insert(hdr.end(), msdu, msdu + len); if (from_host) g_tx_plain.fetch_add(1); - /* LLC/SNAP puts the ethertype at msdu[6..7]. */ - if (from_host && g_unconfirmed && - ((da[0] & 0x01) == 0 || - (len >= 8 && msdu[6] == 0x08 && msdu[7] == 0x06))) + if (from_host && g_unconfirmed && solicits_reply(msdu, len, da)) g_uplink_unconfirmed++; enqueue(std::move(hdr)); return true; @@ -696,15 +741,16 @@ uint8_t scan_step(uint32_t now) { } /* A directed probe request for the SSID we want, on the channel we are on: - * it finds a hidden BSS and shortens the wait on a swept channel. Caller - * holds g_mu. */ -void probe(uint8_t chan) { + * it finds a hidden BSS and shortens the wait on a swept channel. False when + * none could be built (it is then not counted). Caller holds g_mu. */ +bool probe(uint8_t chan) { std::vector m = devourer::sta::build_probe_req(g_own, g_ssid, chan, chan > 14); - if (m.empty()) return; + if (m.empty()) return false; devourer::sta::assign_seq(m, g_data_seq.next()); g_probe_tx.fetch_add(1); enqueue(std::move(m)); + return true; } const char* fail_name(StationSm::Failure f); diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 3036a979..75593515 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -56,7 +56,9 @@ # the old key - see rx_frame() in sta_client.cpp), the arm line, the clear (as for open), and # NO tx.retry_limit=0 warning (the station default is nonzero). # MT7612U also: the managed filter - the own stream arrives and -# `not-for-us` stays under 1% of the foreign one. +# `not-for-us` stays under 1% of the foreign one; that PASS is +# held until the noarm control of the same run has seen the +# foreign stream arrive, else INCONCLUSIVE. # noarm the control: wpa2 with DEVOURER_STA_ARM=0 - nothing else # changes. Scored everywhere: no SetStationIdentity and no clear # ran. On Realtek also scored: the station tried (beacons seen, @@ -579,7 +581,14 @@ inject_unicast() { # $1 cell # Score the stimulus against the station's ledger. $1 cell, $2 managed | # monitor (what the filter should be). Both need the OWN stream to have -# arrived (>= half) before the FOREIGN count means anything. +# arrived (>= half) before the FOREIGN count means anything. The own stream +# shows the injection path airs, not that the FOREIGN injector did: an armed +# PASS ("almost none arrived") is therefore held until a control in the same +# run has seen the foreign stream arrive (noarm, FOREIGN_SEEN), and scored +# after the last cell (score_held_filter) - INCONCLUSIVE without one. A FAIL +# needs no such witness: the frames arrived. +FOREIGN_SEEN=no +HELD_FILTER_PASS="" check_filter() { local nfu ref nfu=$(led "$1" 'not-for-us'); ref=$(led "$1" 'plaintext refused') @@ -597,11 +606,13 @@ check_filter() { fi if [ "$2" = managed ]; then if [ $(( nfu * 100 )) -lt "$INJ_FOREIGN" ]; then - ok "$1: managed filter on: own-addressed $ref of $INJ_OWN arrived, foreign not-for-us=$nfu of $INJ_FOREIGN" + HELD_FILTER_PASS="$1: managed filter on: own-addressed $ref of $INJ_OWN arrived, foreign not-for-us=$nfu of $INJ_FOREIGN" + info "$1: managed-filter result held until the noarm control has seen the foreign stream" else bad "$1: armed station still receives others' unicast: not-for-us=$nfu of $INJ_FOREIGN (own-addressed $ref of $INJ_OWN arrived)" fi elif [ $(( nfu * 2 )) -ge "$INJ_FOREIGN" ]; then + FOREIGN_SEEN=yes ok "$1: control: unarmed, both streams arrive: own-addressed $ref of $INJ_OWN, foreign not-for-us=$nfu of $INJ_FOREIGN" else bad "$1: unarmed (monitor filter) yet the foreign stream did not arrive: not-for-us=$nfu of $INJ_FOREIGN while own-addressed $ref of $INJ_OWN did" @@ -651,7 +662,7 @@ cell_open() { sta_pid_record probe $! if ! wait_for "$OUT/hostapd_open.log" "AP-STA-CONNECTED $own" 30; then if proc_running "$STA_PID"; then bad "open: the AP never associated $own within 30 s"; cell_end - else station_gone open; sta_pid_kill hostapd; fi + else sta_pid_kill probe; station_gone open; sta_pid_kill hostapd; fi return fi sta_pid_kill probe @@ -936,6 +947,16 @@ cell_noreconnect() { } for c in $CELLS; do CELL_FAIL0=$fail; "cell_$c"; done +score_held_filter() { + [ -n "$HELD_FILTER_PASS" ] || return 0 + echo; echo "== the held managed-filter result ==" + if [ "$FOREIGN_SEEN" = yes ]; then + ok "$HELD_FILTER_PASS (the noarm control saw the foreign stream arrive)" + else + inc "${HELD_FILTER_PASS%%:*}: managed filter not scored - no control in this run saw the foreign stream arrive (run noarm with wpa2)" + fi +} +score_held_filter echo echo "=== $pass passed, $fail failed, $inconclusive inconclusive (logs: $OUT) ===" diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index cc79ae6d..2b72a582 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -1178,6 +1178,38 @@ void test_a_nudge_touches_no_tuning_or_liveness_state() { check(g_sm.connected(), "...and counts as AP liveness, not against it"); } +/* Host frames for the confirmation cells: an ARP request (broadcast; sender + * and target IPv4 10.0.0. / 10.0.0., spa 0 = a DAD probe, spa == + * tpa = gratuitous) and a unicast IPv4 frame to kPeer carrying `proto` + * (ICMP: an echo request; UDP: to `port`). */ +void arp_request(uint8_t e[60], uint8_t spa, uint8_t tpa) { + std::memset(e, 0, 60); + std::memset(e, 0xff, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x06; + uint8_t* a = e + 14; + a[1] = 0x01; a[2] = 0x08; a[4] = 6; a[5] = 4; /* Ethernet / IPv4 */ + a[7] = 0x01; /* request */ + std::memcpy(a + 8, kStaMac, 6); + if (spa) { a[14] = 10; a[17] = spa; } + a[24] = 10; a[27] = tpa; +} + +void ipv4_to_peer(uint8_t e[60], uint8_t proto, uint16_t port) { + std::memset(e, 0, 60); + std::memcpy(e, kPeer, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x08; + e[13] = 0x00; + uint8_t* ip = e + 14; + ip[0] = 0x45; + ip[9] = proto; + uint8_t* l4 = ip + 20; + if (proto == 1) l4[0] = 8; /* echo request */ + if (proto == 17) { l4[2] = (uint8_t)(port >> 8); l4[3] = (uint8_t)port; } +} + /* MULTICAST CHATTER IS NOT A QUESTION: frames that expect no reply (here a * multicast IPv6 frame) never count towards the judgement. */ void test_multicast_chatter_is_not_judged() { @@ -1201,20 +1233,29 @@ void test_multicast_chatter_is_not_judged() { check(g_sm.state() == StationSm::State::Connected, "multicast chatter with no reply is never judged"); - /* ...but broadcast ARP requests are questions: an ARP that is never - * answered is exactly how an AP that dropped us shows. */ + /* Gratuitous and probe ARPs are announcements, not questions. */ + for (int i = 0; i < 10; i++) { + uint8_t e[60]; + arp_request(e, i % 2 ? 0 : 7, 7); + tap_down_one(e, sizeof e); + } + drain_tx(); + supervise(kConfirmMs * 4 + 1); + supervise(kConfirmMs * 5 + 2); + check(g_sm.state() == StationSm::State::Connected && + g_uplink_unconfirmed == 0, + "gratuitous and probe ARPs are never judged"); + + /* ...but ARP requests are questions: an ARP that is never answered is + * exactly how an AP that dropped us shows. */ for (int i = 0; i < (int)kConfirmUplink; i++) { uint8_t e[60]; - std::memset(e, 0xff, 6); - std::memcpy(e + 6, kStaMac, 6); - e[12] = 0x08; - e[13] = 0x06; - std::memset(e + 14, 0, sizeof e - 14); + arp_request(e, 2, 1); tap_down_one(e, sizeof e); } drain_tx(); - supervise(kConfirmMs * 5); supervise(kConfirmMs * 6); + supervise(kConfirmMs * 7); check(g_sm.state() == StationSm::State::Failed && g_sm.fail_reason() == StationSm::Failure::Unconfirmed, "unanswered ARP requests are judged"); @@ -1233,11 +1274,8 @@ void open_link_with_uplink(Ap& ap, int frames) { } associate(ap, /*rsn=*/false); drain_tx(); - uint8_t e[60] = {0}; - std::memcpy(e, kPeer, 6); - std::memcpy(e + 6, kStaMac, 6); - e[12] = 0x08; - e[13] = 0x00; + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); /* a ping */ for (int i = 0; i < frames; i++) tap_down_one(e, sizeof e); drain_tx(); } @@ -1272,10 +1310,8 @@ void test_a_burst_after_idle_gets_its_window() { Ap ap; open_link_with_uplink(ap, 0); supervise(kConfirmMs * 4); /* idle, long past kConfirmMs */ - uint8_t e[60] = {0}; - std::memcpy(e, kPeer, 6); - std::memcpy(e + 6, kStaMac, 6); - e[12] = 0x08; + uint8_t e[60]; + ipv4_to_peer(e, 6, 0); /* TCP */ for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); drain_tx(); supervise(kConfirmMs * 4 + 1); @@ -1312,6 +1348,33 @@ void test_a_unicast_reply_confirms_the_association() { "a unicast reply confirms the open association for good"); } +/* ONE-WAY TRAFFIC IS NOT A QUESTION: a UDP uplink to a peer behind the AP + * (video, telemetry) gets nothing unicast back, and must never be judged - + * while the same link's first DNS query is. */ +void test_a_one_way_udp_uplink_is_not_judged() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + uint8_t e[60]; + ipv4_to_peer(e, 17, 5600); + for (uint32_t t = 0; t <= kConfirmMs * 4; t += 100) { + tap_down_one(e, sizeof e); + supervise(t); + } + drain_tx(); + check(g_sm.state() == StationSm::State::Connected, + "a one-way UDP uplink is never judged unconfirmed"); + check(g_uplink_unconfirmed == 0, "...and none of it counts as a question"); + ipv4_to_peer(e, 17, 53); + for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); + drain_tx(); + supervise(kConfirmMs * 5); + supervise(kConfirmMs * 6); + check(g_sm.state() == StationSm::State::Failed && + g_sm.fail_reason() == StationSm::Failure::Unconfirmed, + "...but unanswered DNS queries are judged"); +} + void test_an_idle_open_association_is_not_judged() { reset_all(); Ap ap; @@ -2214,6 +2277,7 @@ int self_test() { selftest::test_a_unicast_reply_confirms_the_association(); selftest::test_an_idle_open_association_is_not_judged(); selftest::test_a_burst_after_idle_gets_its_window(); + selftest::test_a_one_way_udp_uplink_is_not_judged(); selftest::test_multicast_chatter_is_not_judged(); selftest::test_an_accepted_association_nudges_the_ap(); selftest::test_a_nudge_touches_no_tuning_or_liveness_state(); From a049c5f6924487ef896129bd6092c07244f9e60d Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 19:32:35 +0200 Subject: [PATCH 16/53] tests: mt7612u_sta_identity - an INCONCLUSIVE gate reads as one An overrun BSSID gate sets r_bss=2 and the exit code calls the run INCONCLUSIVE, but the verdict `case` had only 0, 3 and *, so the operator read "the BSSID gate did not pass". The probe-response gate had the same gap: arm C never reported is r_ack=2, printed as "arm C did not hold". Both now have a 2) branch that says INCONCLUSIVE. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_identity.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/mt7612u_sta_identity.sh b/tests/mt7612u_sta_identity.sh index 7d3ebe98..1ba3019f 100755 --- a/tests/mt7612u_sta_identity.sh +++ b/tests/mt7612u_sta_identity.sh @@ -339,15 +339,18 @@ fi echo echo "=== logs: $OUT ===" -# A gate's rc 3 is INTERRUPTED - no verdict: never a pass. +# A gate's rc 2 is INCONCLUSIVE and rc 3 INTERRUPTED: neither is a pass, and +# neither is reported as a failure. [ "${staid:-0}" = 0 ] || echo "the contract gate FAILED - see $OUT/staid.txt" case "$r_ack" in 0) ;; + 2) echo "the probe-response gate stopped before arm C - INCONCLUSIVE, see $OUT/staack.txt" ;; 3) echo "the probe-response gate was INTERRUPTED - no verdict" ;; *) echo "the probe-response gate's arm C did not hold - see $OUT/staack.txt" ;; esac case "$r_bss" in 0) ;; + 2) echo "the BSSID gate could not measure - INCONCLUSIVE, see $OUT/bssid.txt" ;; 3) echo "the BSSID gate was INTERRUPTED - no verdict" ;; *) echo "the BSSID gate did not pass - see $OUT/bssid.txt" ;; esac From 8b5fe5e8f934d4d26cc989cf1ab52c75f3612822 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 19:34:01 +0200 Subject: [PATCH 17/53] tests: station harnesses refuse a firmware dir the DUT cannot read On a host whose MediaTek firmware is zstd-compressed (only /lib/firmware/mediatek/*.bin.zst), sta_fw_link succeeded, the probe then logged "cannot open firmware/mt7662_rom_patch.bin", and the uplink harness scored "ABORTED the DUT did not confirm retry limit 15:" with an empty reason (identity: "could not read the DUT's MAC", exit 1). sta_fw_readable checks both blobs are present, readable and non-empty, and names the .zst case when that is what it finds. sta_fw_link runs it through the link it made (or found), so identity, autoack and uplink refuse the rig (exit 2) in seconds, before the DUT is taken. sta_client_onair runs it on FW_DIR for an MT7612U DUT. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 3 ++- tests/mt7612u_sta_autoack.sh | 2 +- tests/mt7612u_sta_identity.sh | 2 +- tests/mt7612u_sta_lib.sh | 25 +++++++++++++++++++++++++ tests/mt7612u_sta_uplink.sh | 2 +- tests/sta_client_onair.sh | 5 +++-- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 7877e193..01876ab5 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -144,7 +144,8 @@ first line then reads `fault=1`. named), 2 inconclusive (rig refused, AP not up, route not through the TAP, the station exited or stalled before `sta_client up:`, station out of time), 3 interrupted. `FW_DIR` (an MT7612U DUT) must hold the decompressed - MT7612U blobs. + MT7612U blobs; a directory with only the `.bin.zst` copies is refused + (exit 2) before the run starts. ## AP quirk: an MT7612U AP holds the association's TX status diff --git a/tests/mt7612u_sta_autoack.sh b/tests/mt7612u_sta_autoack.sh index 52f37ce3..60429ad8 100755 --- a/tests/mt7612u_sta_autoack.sh +++ b/tests/mt7612u_sta_autoack.sh @@ -61,7 +61,7 @@ sta_pid_init dut peer sta_peer_record || { sta_lock_release; exit 2; } # Only a link THIS run created is removed afterwards - anything already at # $ROOT/firmware, a dangling symlink included, is the operator's. -sta_fw_link +sta_fw_link || { sta_fw_unlink; sta_lock_release; exit 2; } pass=0; fail=0 ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } diff --git a/tests/mt7612u_sta_identity.sh b/tests/mt7612u_sta_identity.sh index 1ba3019f..e33365d5 100755 --- a/tests/mt7612u_sta_identity.sh +++ b/tests/mt7612u_sta_identity.sh @@ -77,7 +77,7 @@ sta_pid_init hostapd inject gate # so give it one rather than requiring the caller to cd somewhere specific. # Only a link THIS run created is removed afterwards - anything already at # $ROOT/firmware, a dangling symlink included, is the operator's. -sta_fw_link +sta_fw_link || { sta_fw_unlink; sta_lock_release; exit 2; } AP_IF="" # The accepted AP's idVendor:idProduct:serial, recorded once the guard has diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 46f17257..900b5072 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -272,12 +272,37 @@ sta_peer_handback() { sta_dev_handback peer "$PEER_SYSFS"; } # $ROOT/firmware -> FW_DIR only when nothing is there - not even a dangling # symlink, which `-e` alone would miss - and sta_fw_unlink() removes it only # if this run created it and it still points where this run pointed it. +# Either way it then checks the blobs are readable THROUGH the link (below), +# and returns 1 when they are not. STA_FW_LINK_OURS=no sta_fw_link() { if [ ! -e "$ROOT/firmware" ] && [ ! -L "$ROOT/firmware" ] && ln -sn "$FW_DIR" "$ROOT/firmware" 2>/dev/null; then STA_FW_LINK_OURS=yes fi + sta_fw_readable "$ROOT/firmware" +} + +# 0 when directory $1 holds both MT7612U blobs, readable and non-empty. A +# host whose firmware is zstd-compressed (/lib/firmware/mediatek/*.bin.zst +# only) links fine, and the DUT then fails its bring-up with "cannot open +# firmware/mt7662_rom_patch.bin", which a gate scores as an empty ABORTED or a +# missing MAC. This check makes that dead rig a refusal before anything runs. +sta_fw_readable() { + for _sta_fw in mt7662_rom_patch.bin mt7662.bin; do + if [ -f "$1/$_sta_fw" ] && [ -r "$1/$_sta_fw" ] && [ -s "$1/$_sta_fw" ]; then + continue + fi + if [ -e "$1/$_sta_fw.zst" ]; then + echo "refusing: $1/$_sta_fw is missing, only $_sta_fw.zst is there - the" \ + "DUT loads the blobs uncompressed. Decompress both (zstd -d) into a" \ + "directory and pass it as FW_DIR" + else + echo "refusing: $1/$_sta_fw is missing, unreadable or empty (FW_DIR=$FW_DIR)" + fi + return 1 + done + return 0 } sta_fw_unlink() { diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index 76cec4c3..9789577f 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -79,7 +79,7 @@ sta_pid_init resp dut sta_peer_record || { sta_lock_release; exit 2; } # Only a link THIS run created is removed afterwards - anything already at # $ROOT/firmware, a dangling symlink included, is the operator's. -sta_fw_link +sta_fw_link || { sta_fw_unlink; sta_lock_release; exit 2; } pass=0; fail=0 ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 75593515..b8301729 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -116,8 +116,8 @@ # Out-of-tree drivers such as rtl88x2cu / 88x2bu cannot, and are refused. # Read both from `lsusb -t` after the drivers have loaded (they can move). # FW_DIR (an MT7612U DUT only) must hold the DECOMPRESSED MT7612U blobs -# (mt7662*.bin); a host that ships only mt7662*.bin.zst gets INCONCLUSIVE -# (rig/bring-up). +# (mt7662*.bin); a host that ships only mt7662*.bin.zst is refused (exit 2) +# before anything is touched. # Build first: cmake --build build --target StaClientSelftest (build/sta_client). # # HOSTAPD_DEBUG=1: hostapd runs with -dd, its debug output in the same @@ -234,6 +234,7 @@ DUT_PID="0x${dut_have#*:}" # shellcheck source=tests/mt7612u_sta_lib.sh . "$ROOT/tests/mt7612u_sta_lib.sh" +if [ "$DUT_KIND" = mt7612u ]; then sta_fw_readable "$FW_DIR" || exit 2; fi sta_out_prepare || exit 2 sta_lock_take || exit 2 sta_pid_init sta hostapd probe inject inject_own From ebc5ddc93062353e1c408414ec4dab9a5ac4f779 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 19:34:43 +0200 Subject: [PATCH 18/53] tests: every station harness exits 3 on INT/TERM realtek_station_onair, sta_client_onair and mt7612u_sta_identity already exited 3 on an interrupt; mt7612u_sta_autoack, mt7612u_sta_uplink and mt7612u_ap_onair still exited 130. All six now follow the one convention: 0 pass, 1 fail, 2 inconclusive, 3 interrupted. The autoack and AP harness headers now state it; no caller in the tree reads 130. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_ap_onair.sh | 5 ++++- tests/mt7612u_sta_autoack.sh | 5 ++++- tests/mt7612u_sta_uplink.sh | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/mt7612u_ap_onair.sh b/tests/mt7612u_ap_onair.sh index 2289c157..03052010 100755 --- a/tests/mt7612u_ap_onair.sh +++ b/tests/mt7612u_ap_onair.sh @@ -28,6 +28,9 @@ # sudo tests/mt7612u_ap_onair.sh # sudo AP_SYSFS=5-1 STA_SYSFS=2-1 CH=36 tests/mt7612u_ap_onair.sh open # +# Exit status: 0 every cell passed; 1 a cell failed; 2 INCONCLUSIVE (the rig +# was refused, or a cell was NOT RUN); 3 interrupted (INT/TERM). +# # Env: AP_SYSFS, STA_SYSFS, CH, PSK, FW_DIR, SECS, AP_VBUS (hubloc:port for a # real VBUS cold cycle via uhubctl; hub ports only). Cells: open|wpa2|stop|all. @@ -136,7 +139,7 @@ cleanup() { # that; the cleanups between cells (CELLS=all) still run every time. CLEANED=no trap '[ "$CLEANED" = yes ] || cleanup' EXIT -trap 'cleanup; CLEANED=yes; exit 130' INT TERM +trap 'cleanup; CLEANED=yes; exit 3' INT TERM # --- the station ----------------------------------------------------------- STA_IF=$(ls "/sys/bus/usb/devices/$STA_SYSFS:1.0/net/" 2>/dev/null | head -1) diff --git a/tests/mt7612u_sta_autoack.sh b/tests/mt7612u_sta_autoack.sh index 60429ad8..361caa79 100755 --- a/tests/mt7612u_sta_autoack.sh +++ b/tests/mt7612u_sta_autoack.sh @@ -26,6 +26,9 @@ # sudo tests/mt7612u_sta_autoack.sh # sudo PEER_PID=0xc812 DUT_SYSFS=7-1 CH=6 tests/mt7612u_sta_autoack.sh # +# Exit status: 0 every check passed; 1 a check failed; 2 INCONCLUSIVE (the rig +# was refused, or the gate could not measure); 3 interrupted (INT/TERM). +# # Env: PEER_VID, PEER_PID, PEER_SYSFS, DUT_SYSFS, CH, SECS, RETRY_LIMIT, OUT. set -u @@ -96,7 +99,7 @@ trap cleanup EXIT # and then CARRIES ON into the next arm. CLEANED makes the EXIT pass after it # a no-op: sta_pid_kill forgets a PID on the first pass, so a second pass # would hand back an adapter the first refused to. -trap 'cleanup; exit 130' INT TERM +trap 'cleanup; exit 3' INT TERM sta_dut_take || exit 2 diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index 9789577f..b6c54e53 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -111,7 +111,7 @@ trap cleanup EXIT # and then CARRIES ON into the next arm. CLEANED makes the EXIT pass after it # a no-op: sta_pid_kill forgets a PID on the first pass, so a second pass # would hand back an adapter the first refused to. -trap 'cleanup; exit 130' INT TERM +trap 'cleanup; exit 3' INT TERM sta_dut_take || exit 2 echo "DUT MT7612U at $DUT_SYSFS transmitting to $TARGET" From 8d8e1bd4fae1311f49f7ce54f1ab897d2c4f663f Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 19:35:49 +0200 Subject: [PATCH 19/53] tests: mt7612u_ap_onair - reap reaps the children it KILLs On the KILL path reap cleared KIDS without `wait`ing anything, so every child of that cleanup - the KILLed one and the ones TERM had already ended - stayed a zombie for the rest of the run. reap now gives a KILL 2 s to land (it waits for the process to leave the kernel), then waits every child that has exited, on both paths. A child still running after that is not waited on, since that wait could block. The return value is unchanged: 1 when anything had to be KILLed. Checked with a TERM-ignoring child beside two plain ones: rc 1, no zombies left; a clean reap still returns 0. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_ap_onair.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/mt7612u_ap_onair.sh b/tests/mt7612u_ap_onair.sh index 03052010..07ed5699 100755 --- a/tests/mt7612u_ap_onair.sh +++ b/tests/mt7612u_ap_onair.sh @@ -68,24 +68,28 @@ KIDS="" # de-init runs after the signal, and re-enumerating the adapter under it is # the hand-back this must not do. Anything still alive then is KILLed and # reap returns 1, so cleanup leaves the adapter alone; 0 when all exited. +# Either way every child that has exited, a KILLed one included, is reaped, +# so none is left a zombie for the rest of the run. A KILL lands only once +# the process leaves the kernel (a USB call can hold it), so it gets 2 s, and +# one still running after that is never `wait`ed on: that would block. reap() { - local pid live t=0 + local pid live t=0 killed="" for pid in $KIDS; do kill "$pid" 2>/dev/null; done while :; do live="" for pid in $KIDS; do sta_pid_alive "$pid" && live="$live $pid"; done [ -z "$live" ] && break - if [ "$t" -ge 100 ]; then + if [ "$t" -eq 100 ]; then for pid in $live; do kill -KILL "$pid" 2>/dev/null; done echo "still running 10 s after TERM (KILLed):$live" - KIDS="" - return 1 + killed=yes fi + [ "$t" -ge 120 ] && break sleep 0.1; t=$((t + 1)) done - for pid in $KIDS; do wait "$pid" 2>/dev/null; done # reaps our own children + for pid in $KIDS; do sta_pid_alive "$pid" || wait "$pid" 2>/dev/null; done KIDS="" - return 0 + [ -z "$killed" ] } # Returns 1 when it could not reset the AP (a process outlived TERM): the From d0a3beb8119f3cab723a17455b174b0a3ec7631a Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 19:36:23 +0200 Subject: [PATCH 20/53] tests: realtek_station_onair - tail_ms never reads negative The final tx.stats t can precede the last tx.report t by a few ms (-6 ms in one review arm), and summarize printed that as a negative silence. It was harmless to `live`, which only compares tail_ms against MAX_GAP_MS, but it read as a clock fault. tail_ms is now clamped at 0, and the summarize comment says why. Checked on a fixture whose final tx.stats is 6 ms before the last report: tail_ms=0, live=1. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/realtek_station_onair.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index 2ae7b644..e1abc8d6 100755 --- a/tests/realtek_station_onair.sh +++ b/tests/realtek_station_onair.sh @@ -255,7 +255,8 @@ wait_for() { # $1 pid, $2 file, $3 regex, $4 timeout s # show whether the transmitter kept airing through the window: lead_ms is # the silence from the first submit to the first report, max_gap_ms the # longest silence between two reports, tail_ms the silence from the last -# report to the final tx.stats. live=0 when any of them exceeds MAX_GAP_MS, +# report to the final tx.stats (clamped at 0: that t can precede the last +# report's by a few ms). live=0 when any of them exceeds MAX_GAP_MS, # or a timestamp it needs is missing - an arm that aired a burst and # stalled, or that started, stalled and burst at the end, which MIN_REPORTS # alone would accept. @@ -296,7 +297,7 @@ for line in open(tx, errors='replace'): if e.get('final') and 't' in e: final_t = int(e['t']) gap = max((b - a for a, b in zip(ts, ts[1:])), default=0) -tail = (final_t - ts[-1]) if (final_t is not None and ts) else None +tail = max(final_t - ts[-1], 0) if (final_t is not None and ts) else None lead = (ts[0] - first_submit_t) if (first_submit_t is not None and ts) else None live = int(bool(ts) and tail is not None and lead is not None and lead <= max_gap and gap <= max_gap and tail <= max_gap) From ef7099446abcb583342bc4583b37948ff24a9b69 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:01:16 +0200 Subject: [PATCH 21/53] tests: sta_client - one Unconfirmed verdict per BSS, a TCP SYN only The open-association confirmation rule could thrash a healthy link. A question whose answer never comes - a host pinging or ARPing a peer that is switched off, DNS with no upstream, TCP retransmissions or a RST to a peer that has gone - failed the association as Unconfirmed, and on_association re-armed the judge on every re-join, so the link went down every ~5 s plus backoff and join, dropping host traffic each time. - The verdict is now a bounded backstop: at most one per BSS until an association on that BSS is confirmed. After a verdict the re-joined association on the same BSS is not judged (g_strike); a unicast reply, or an association on a different BSS, lifts the strike. The cost is stated where the rule is: a second unheld association on the struck BSS is not found by this rule. - A TCP segment is a question only as a SYN (SYN set, ACK clear). The old comment's "a bare ACK only follows data this station received" was false across a re-join. - A question, a probe and a nudge count only once enqueue() accepted them; one the 128-deep queue dropped never reached the AP. - g_unconfirmed is folded into g_judge. Headless cells: fewer than three questions are not judged, a broadcast from the AP does not confirm, what is and is not a question (TCP SYN vs SYN-ACK/ACK/data/RST, ICMP echo vs reply/unreachable, non-first fragment, DNS vs other UDP, unicast ICMPv6 echo/NS vs reply/multicast), one strike per BSS and its lift, the strike is per BSS, a dropped frame is not counted. The multicast-chatter cell now sends real IPv6 multicast echo requests; the reply cell sends real echo requests and judges across a full window; the idle-burst cell runs the loop while idle. Checked by 21 mutations of sta_client.cpp on a scratch copy; all are killed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 35 ++-- tests/sta_client.cpp | 94 +++++++---- tests/sta_client_selftest.inc | 295 ++++++++++++++++++++++++++++++---- 3 files changed, 354 insertions(+), 70 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 01876ab5..0ecf2578 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -79,18 +79,29 @@ enough. An open association is confirmed by the AP's first unicast frame to the station. A station cannot see the AP's side: if the AP never saw the association response acknowledged, it does not hold the station, drops its -traffic and may never say so. So once the host has asked something - three -frames whose answer the AP must forward back: an ARP request, an ICMP / -ICMPv6 echo request, a unicast IPv6 neighbour solicitation, TCP, a DNS -query - and no unicast reply has come within 5 s of the first, the link is -lost as `unconfirmed` (`StationSm::link_lost`) and re-joined under the -policy above. One-way traffic (a UDP video or telemetry uplink), multicast -chatter, gratuitous and probe ARPs and an idle host are never judged; an -unheld association under one-way traffic alone is found when the host's -stack next asks something (its neighbour re-verification is a unicast ARP -request). WPA2 needs no such rule (the four-way is the confirmation). The -ledger counts -these (`unconfirmed=`), and repeated association responses +traffic and may never say so. So once the host has asked three questions, +and no unicast reply has come within 5 s of the first, the link is lost as +`unconfirmed` (`StationSm::link_lost`) and re-joined under the policy above. +A question is a frame whose answer, if one exists, the AP must forward +back: an ARP request, an ICMP / ICMPv6 echo request, a unicast IPv6 +neighbour solicitation, a TCP SYN, a DNS query. One-way traffic (a UDP +video or telemetry uplink), multicast chatter, gratuitous and probe ARPs, +any other TCP segment and an idle host are never judged. + +A question can also go unanswered on a healthy link: the host pings or ARPs +a peer that is switched off, or its DNS has no upstream. So the rule is a +bounded backstop, with one strike per BSS. It fires at most once on a BSS +until an association on that BSS has been confirmed. The re-joined +association after a verdict is not judged, and a unicast reply, or an +association on a different BSS, lifts the strike. A host asking a dead peer +therefore costs one re-join, not one every 5 s. The cost: a second unheld +association on the struck BSS is not found by this rule, and its traffic is +lost until something else ends or confirms it (beacon loss, a +deauthentication, the AP's first reply). An unheld association under +one-way traffic alone is found only when the host's stack next asks +something (its neighbour re-verification is a unicast ARP request). WPA2 +needs no such rule (the four-way is the confirmation). The ledger counts +the verdicts (`unconfirmed=`) and repeated association responses (`assoc_repeat=`). Exit status: 0 the run completed; 1 setup failed; 2 refused diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index c4ec8afb..6339b2fc 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -265,7 +265,8 @@ uint32_t now_ms() { .count(); } -void enqueue(std::vector mpdu) { +/* True when the frame was queued; false when the full queue dropped it. */ +bool enqueue(std::vector mpdu) { /* addr1's I/G bit: a group address is never ACKed. */ const bool unicast = mpdu.size() >= 10 && (mpdu[4] & 0x01) == 0; const std::vector& rt = (unicast && !g_rt_ack.empty()) ? g_rt_ack : g_rt; @@ -277,8 +278,12 @@ void enqueue(std::vector mpdu) { g_q_in.fetch_add(1); /* Bounded: everything queued here answers a received frame or a timer, so * an unbounded queue is an allocation the air controls. */ - if (g_q.size() < 128) g_q.push_back(std::move(f)); - else g_q_drop.fetch_add(1); + if (g_q.size() < 128) { + g_q.push_back(std::move(f)); + return true; + } + g_q_drop.fetch_add(1); + return false; } /* The dBm convention this tree uses (src/LinkHealth.cpp, src/RxQuality.h): @@ -301,32 +306,48 @@ int8_t rssi_dbm(uint8_t raw) { * may never deauthenticate it. (On WPA2 the four-way is the confirmation: * the AP starts it only for a station it holds, and HandshakeTimeout covers * the rest.) So an open association counts as unconfirmed until a unicast - * data frame from the AP arrives for this station; if the host has sent - * kConfirmUplink frames and kConfirmMs has passed since the first of them + * data frame from the AP arrives for this station; if the host has asked + * kConfirmUplink questions and kConfirmMs has passed since the first of them * without one, the link is lost (StationSm::link_lost) and the ordinary * re-join policy takes over. The window opens at the host's first question, * not at the association: a host idle for longer than kConfirmMs that then - * sends a burst must still get its kConfirmMs for the reply. ONLY A FRAME - * WHOSE ANSWER THE AP MUST FORWARD BACK IS A QUESTION (solicits_reply): an - * ARP request, an ICMP / ICMPv6 echo request, a unicast IPv6 neighbour - * solicitation, TCP, a DNS query. So one-way traffic - a UDP video or - * telemetry uplink, the FPV case - is never judged, and neither is a host's - * multicast chatter (IPv6 RS/MLD, mDNS), a gratuitous or probe ARP, or an - * idle host. The cost: an unheld association under one-way traffic alone is - * found only when the host's stack next asks something (its neighbour + * sends a burst must still get its kConfirmMs for the reply. + * + * A QUESTION IS A FRAME WHOSE ANSWER, IF ONE EXISTS, THE AP MUST FORWARD BACK + * (solicits_reply): an ARP request, an ICMP / ICMPv6 echo request, a unicast + * IPv6 neighbour solicitation, a TCP SYN, a DNS query. So one-way traffic - + * a UDP video or telemetry uplink, the FPV case - is never judged, and + * neither is a host's multicast chatter (IPv6 RS/MLD, mDNS), a gratuitous or + * probe ARP, or an idle host. But a question can go unanswered on a healthy + * link: the host pings or ARPs a peer that is switched off, or its DNS has + * no upstream. + * + * ONE STRIKE PER BSS. So the verdict is a bounded backstop: it fires at most + * once per BSS until an association on that BSS has been confirmed. After + * one Unconfirmed verdict the re-joined association on the same BSS is not + * judged (g_strike), so a host asking a dead peer costs one re-join, not one + * every kConfirmMs. A unicast reply on that BSS, or an association on a + * different one, lifts it. The cost: a second unheld association on the + * struck BSS is not found by this rule, and its traffic is lost until + * something else (beacon loss, a deauthentication, the AP's first reply) + * ends or confirms it. And an unheld association under one-way traffic alone + * is found only when the host's stack next asks something (its neighbour * re-verification is a unicast ARP request). */ constexpr uint32_t kConfirmMs = 5000; constexpr uint32_t kConfirmUplink = 3; -bool g_unconfirmed = false; +/* An open association with no unicast reply yet, on a BSS whose strike is + * not spent: the verdict may fire for it. */ +bool g_judge = false; +bool g_strike = false; /* the one verdict is spent on... */ +uint8_t g_strike_bss[6] = {0}; /* ...this BSS */ bool g_uplink_seen = false; /* supervise() saw the first question */ uint32_t g_uplink_first_ms = 0; /* ...at this time: the window opens */ uint32_t g_uplink_unconfirmed = 0; std::atomic g_unconfirmed_lost{0}; -/* Whether the host's MSDU (LLC/SNAP + payload) asks for an answer the AP - * must carry back to this station (kConfirmMs above). Conservative: anything - * not recognised is not a question, so a false "lost" needs a question that - * really went unanswered. */ +/* Whether the host's MSDU (LLC/SNAP + payload) asks for an answer that, if + * one exists, the AP must carry back to this station (kConfirmMs above). + * Conservative: anything not recognised is not a question. */ bool solicits_reply(const uint8_t* msdu, size_t len, const uint8_t da[6]) { if (len < devourer::sta::kLlcSnapLen) return false; const uint8_t* p = msdu + devourer::sta::kLlcSnapLen; @@ -364,9 +385,10 @@ bool solicits_reply(const uint8_t* msdu, size_t len, const uint8_t da[6]) { } else { return false; } - /* TCP: a SYN or data is acknowledged; a bare ACK only follows data this - * station received, which has already confirmed it. */ - if (proto == 6) return true; + /* TCP: a SYN only (SYN set, ACK clear). Any other segment can go + * unanswered on a healthy link: a RST, a keepalive or a retransmission to a + * peer that has gone, or a bare ACK left over from before a re-join. */ + if (proto == 6) return l4n >= 14 && (l4[13] & 0x12) == 0x02; /* UDP: a DNS query only - any other UDP may be one-way. */ return proto == 17 && l4n >= 4 && (((unsigned)l4[2] << 8) | l4[3]) == 53; } @@ -403,7 +425,10 @@ void nudge(uint32_t now) { } void on_association() { - g_unconfirmed = g_sm.security() == StationSm::Security::Open; + /* The one strike is per BSS: a different BSS is judged afresh. */ + if (g_strike && std::memcmp(g_strike_bss, g_sm.bssid(), 6) != 0) + g_strike = false; + g_judge = g_sm.security() == StationSm::Security::Open && !g_strike; g_uplink_seen = false; g_uplink_unconfirmed = 0; g_rx_dup.reset(); @@ -568,7 +593,10 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { if (!eapol && !no_data) g_plain_refused.fetch_add(1); return; } - if (to_us) g_unconfirmed = false; /* the AP holds this association */ + if (to_us) { /* the AP holds this association */ + g_judge = false; + g_strike = false; /* ...so its BSS is judged again */ + } g_plain_rx.fetch_add(1); if (len > hlen) tap_up(da, sa, mpdu + hlen, len - hlen); return; @@ -683,9 +711,10 @@ bool air_msdu(const uint8_t* msdu, size_t len, const uint8_t da[6], if (!protect) { hdr.insert(hdr.end(), msdu, msdu + len); if (from_host) g_tx_plain.fetch_add(1); - if (from_host && g_unconfirmed && solicits_reply(msdu, len, da)) - g_uplink_unconfirmed++; - enqueue(std::move(hdr)); + /* Only a question that was queued: one the full queue dropped never + * reached the AP, so its missing answer says nothing. */ + const bool question = from_host && g_judge && solicits_reply(msdu, len, da); + if (enqueue(std::move(hdr)) && question) g_uplink_unconfirmed++; return true; } /* 0 means the length would overflow: refused like any cipher failure. */ @@ -742,14 +771,15 @@ uint8_t scan_step(uint32_t now) { /* A directed probe request for the SSID we want, on the channel we are on: * it finds a hidden BSS and shortens the wait on a swept channel. False when - * none could be built (it is then not counted). Caller holds g_mu. */ + * none could be built or the full queue dropped it; only a queued one is + * counted. Caller holds g_mu. */ bool probe(uint8_t chan) { std::vector m = devourer::sta::build_probe_req(g_own, g_ssid, chan, chan > 14); if (m.empty()) return false; devourer::sta::assign_seq(m, g_data_seq.next()); + if (!enqueue(std::move(m))) return false; /* the full queue dropped it */ g_probe_tx.fetch_add(1); - enqueue(std::move(m)); return true; } @@ -777,18 +807,20 @@ uint8_t supervise(uint32_t now) { /* An unconfirmed open association the host has been talking through * (see kConfirmMs) - lost, through the ordinary failure path below. The * window opens the first time this pass sees a question. */ - if (g_unconfirmed && g_uplink_unconfirmed > 0 && !g_uplink_seen) { + if (g_judge && g_uplink_unconfirmed > 0 && !g_uplink_seen) { g_uplink_seen = true; g_uplink_first_ms = now; } - if (g_sm.state() == StationSm::State::Connected && g_unconfirmed && + if (g_sm.state() == StationSm::State::Connected && g_judge && g_uplink_seen && g_uplink_unconfirmed >= kConfirmUplink && (uint32_t)(now - g_uplink_first_ms) >= kConfirmMs) { std::fprintf(stderr, " station association unconfirmed: %u frames sent, no " "unicast reply from the AP in %u ms\n", g_uplink_unconfirmed, (unsigned)(now - g_uplink_first_ms)); - g_unconfirmed = false; + g_judge = false; + g_strike = true; /* the one strike for this BSS */ + std::memcpy(g_strike_bss, g_sm.bssid(), 6); g_unconfirmed_lost.fetch_add(1); g_sm.link_lost(); } diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 2b72a582..f1d736c6 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -107,7 +107,9 @@ void reset_all() { g_stop = 0; g_tap_read_err = 0; g_end = RunEnd{}; - g_unconfirmed = false; + g_judge = false; + g_strike = false; + std::memset(g_strike_bss, 0, 6); g_uplink_seen = false; g_uplink_first_ms = 0; g_uplink_unconfirmed = 0; @@ -481,8 +483,13 @@ struct Ap { /* An UNPROTECTED data frame, which a WPA2 link must refuse. */ std::vector plain_to_sta(const uint8_t* payload, size_t len) { + return plain_to(kStaMac, payload, len); + } + /* ...to any destination: a group address is the AP's broadcast. */ + std::vector plain_to(const uint8_t da[6], const uint8_t* payload, + size_t len) { std::vector m = devourer::sta::data_hdr_from_ds( - kStaMac, kBssid, kPeer, /*protect=*/false, seq.next()); + da, kBssid, kPeer, /*protect=*/false, seq.next()); devourer::sta::append_llc_snap(m, 0x0800); m.insert(m.end(), payload, payload + len); return m; @@ -1180,8 +1187,10 @@ void test_a_nudge_touches_no_tuning_or_liveness_state() { /* Host frames for the confirmation cells: an ARP request (broadcast; sender * and target IPv4 10.0.0. / 10.0.0., spa 0 = a DAD probe, spa == - * tpa = gratuitous) and a unicast IPv4 frame to kPeer carrying `proto` - * (ICMP: an echo request; UDP: to `port`). */ + * tpa = gratuitous), a unicast IPv4 frame to kPeer carrying `proto` (ICMP: an + * echo request; UDP: to `port`; TCP: flags `port & 0xff`), and an IPv6 frame + * to `da` carrying ICMPv6 `type` (80 bytes, so the 40-byte header and the + * ICMPv6 type are all there). */ void arp_request(uint8_t e[60], uint8_t spa, uint8_t tpa) { std::memset(e, 0, 60); std::memset(e, 0xff, 6); @@ -1208,10 +1217,35 @@ void ipv4_to_peer(uint8_t e[60], uint8_t proto, uint16_t port) { uint8_t* l4 = ip + 20; if (proto == 1) l4[0] = 8; /* echo request */ if (proto == 17) { l4[2] = (uint8_t)(port >> 8); l4[3] = (uint8_t)port; } + if (proto == 6) l4[13] = (uint8_t)port; /* TCP flags */ +} + +const uint8_t kAllRouters6[6] = {0x33, 0x33, 0, 0, 0, 2}; +void icmpv6_to(uint8_t e[80], const uint8_t da[6], uint8_t type) { + std::memset(e, 0, 80); + std::memcpy(e, da, 6); + std::memcpy(e + 6, kStaMac, 6); + e[12] = 0x86; + e[13] = 0xdd; + uint8_t* ip = e + 14; + ip[0] = 0x60; + ip[5] = 26; /* payload length */ + ip[6] = 58; /* ICMPv6 */ + ip[7] = 64; + ip[40] = type; +} + +/* Whether one host frame counted as a question on a judged open link. */ +bool is_question(const uint8_t* e, size_t len) { + const uint32_t before = g_uplink_unconfirmed; + tap_down_one(e, len); + drain_tx(); + return g_uplink_unconfirmed == before + 1; } -/* MULTICAST CHATTER IS NOT A QUESTION: frames that expect no reply (here a - * multicast IPv6 frame) never count towards the judgement. */ +/* MULTICAST CHATTER IS NOT A QUESTION: frames that expect no reply (here + * IPv6 echo requests to a multicast group) never count towards the + * judgement. */ void test_multicast_chatter_is_not_judged() { reset_all(); { @@ -1222,15 +1256,15 @@ void test_multicast_chatter_is_not_judged() { associate(ap, /*rsn=*/false); drain_tx(); for (int i = 0; i < 10; i++) { - uint8_t e[60] = {0x33, 0x33, 0, 0, 0, 2}; /* IPv6 all-routers */ - std::memcpy(e + 6, kStaMac, 6); - e[12] = 0x86; - e[13] = 0xdd; + uint8_t e[80]; + icmpv6_to(e, kAllRouters6, 128); tap_down_one(e, sizeof e); } drain_tx(); supervise(kConfirmMs * 4); - check(g_sm.state() == StationSm::State::Connected, + supervise(kConfirmMs * 5 + 1); + check(g_sm.state() == StationSm::State::Connected && + g_uplink_unconfirmed == 0, "multicast chatter with no reply is never judged"); /* Gratuitous and probe ARPs are announcements, not questions. */ @@ -1240,29 +1274,30 @@ void test_multicast_chatter_is_not_judged() { tap_down_one(e, sizeof e); } drain_tx(); - supervise(kConfirmMs * 4 + 1); supervise(kConfirmMs * 5 + 2); + supervise(kConfirmMs * 6 + 3); check(g_sm.state() == StationSm::State::Connected && g_uplink_unconfirmed == 0, "gratuitous and probe ARPs are never judged"); - /* ...but ARP requests are questions: an ARP that is never answered is - * exactly how an AP that dropped us shows. */ + /* ...but ARP requests are questions: an ARP that is never answered is how + * an AP that dropped us shows - the first time on this BSS (the one + * strike; test_one_strike_per_bss). */ for (int i = 0; i < (int)kConfirmUplink; i++) { uint8_t e[60]; arp_request(e, 2, 1); tap_down_one(e, sizeof e); } drain_tx(); - supervise(kConfirmMs * 6); supervise(kConfirmMs * 7); + supervise(kConfirmMs * 8); check(g_sm.state() == StationSm::State::Failed && g_sm.fail_reason() == StationSm::Failure::Unconfirmed, - "unanswered ARP requests are judged"); + "unanswered ARP requests are judged, the first time on a BSS"); } /* AN OPEN ASSOCIATION THE AP DOES NOT HOLD IS FOUND AND RE-JOINED. The host - * talks (kConfirmUplink frames) and no unicast reply comes back within + * asks (kConfirmUplink questions) and no unicast reply comes back within * kConfirmMs: the link is lost as Unconfirmed and the ordinary re-join policy * takes over. The two controls: a unicast reply confirms the association for * good, and an idle host is never judged. */ @@ -1302,6 +1337,22 @@ void test_an_unconfirmed_open_association_is_lost() { check(g_joins.load() == joins + 1, "...and re-joined after the backoff"); } +/* FEWER THAN kConfirmUplink QUESTIONS ARE NOT JUDGED, however long they go + * unanswered: one lost ping is not a lost association. */ +void test_fewer_questions_than_the_threshold_are_not_judged() { + for (int n = 1; n < (int)kConfirmUplink; n++) { + reset_all(); + Ap ap; + open_link_with_uplink(ap, n); + supervise(10); + supervise(10 + kConfirmMs * 4); + check(g_sm.state() == StationSm::State::Connected && + g_uplink_unconfirmed == (uint32_t)n, + n == 1 ? "one unanswered question is not judged" + : "two unanswered questions are not judged"); + } +} + /* THE WINDOW OPENS AT THE HOST'S FIRST QUESTION, not at the association: a * host idle for longer than kConfirmMs that then asks kConfirmUplink things * at once still gets kConfirmMs for the reply. */ @@ -1309,11 +1360,13 @@ void test_a_burst_after_idle_gets_its_window() { reset_all(); Ap ap; open_link_with_uplink(ap, 0); + supervise(1000); /* the main loop runs while idle */ supervise(kConfirmMs * 4); /* idle, long past kConfirmMs */ uint8_t e[60]; - ipv4_to_peer(e, 6, 0); /* TCP */ + ipv4_to_peer(e, 6, 0x02); /* a TCP SYN */ for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); drain_tx(); + check(g_uplink_unconfirmed == kConfirmUplink, "the SYNs are questions"); supervise(kConfirmMs * 4 + 1); supervise(kConfirmMs * 4 + 2); check(g_sm.state() == StationSm::State::Connected, @@ -1327,27 +1380,209 @@ void test_a_burst_after_idle_gets_its_window() { "...and the reply inside its window confirms it"); } +/* A unicast reply confirms the association: questions asked after it, left + * unanswered across more than a whole window, are never judged. */ void test_a_unicast_reply_confirms_the_association() { reset_all(); Ap ap; - open_link_with_uplink(ap, (int)kConfirmUplink); + open_link_with_uplink(ap, 0); const uint8_t pl[16] = {1}; const std::vector d = ap.plain_to_sta(pl, sizeof pl); rx_frame(d.data(), d.size(), -40, 100); (void)tap_read(); - for (int i = 0; i < 10; i++) { - uint8_t e[60] = {0}; - std::memcpy(e, kPeer, 6); - std::memcpy(e + 6, kStaMac, 6); - e[12] = 0x08; - tap_down_one(e, sizeof e); - } + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); /* real echo requests */ + for (int i = 0; i < 10; i++) tap_down_one(e, sizeof e); drain_tx(); - supervise(kConfirmMs * 4); + supervise(1000); + supervise(1000 + kConfirmMs * 2); check(g_sm.state() == StationSm::State::Connected, "a unicast reply confirms the open association for good"); } +/* ONLY A UNICAST REPLY CONFIRMS: a group-addressed data frame from the AP + * (a broadcast ARP, mDNS) reaches every station it ever held, and says + * nothing about this association. */ +void test_a_group_frame_from_the_ap_does_not_confirm() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + const uint8_t pl[16] = {1}; + const std::vector d = ap.plain_to(kBcast, pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, 100); + check(tap_read().size() == devourer::sta::kEthHdrLen + sizeof pl, + "the AP's broadcast reaches the host"); + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); + for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); + drain_tx(); + supervise(1000); + supervise(1000 + kConfirmMs); + check(g_sm.state() == StationSm::State::Failed && + g_sm.fail_reason() == StationSm::Failure::Unconfirmed, + "...but does not confirm the association"); +} + +/* WHAT A QUESTION IS (solicits_reply), through the real transmit path. */ +void test_what_counts_as_a_question() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + uint8_t e4[60], e6[80]; + ipv4_to_peer(e4, 6, 0x02); + check(is_question(e4, sizeof e4), "a TCP SYN is a question"); + ipv4_to_peer(e4, 6, 0x12); + check(!is_question(e4, sizeof e4), "a SYN-ACK is not"); + ipv4_to_peer(e4, 6, 0x10); + check(!is_question(e4, sizeof e4), "a bare TCP ACK is not"); + ipv4_to_peer(e4, 6, 0x18); + check(!is_question(e4, sizeof e4), "TCP data (PSH-ACK) is not"); + ipv4_to_peer(e4, 6, 0x04); + check(!is_question(e4, sizeof e4), "a TCP RST is not"); + ipv4_to_peer(e4, 1, 0); + check(is_question(e4, sizeof e4), "an ICMP echo request is a question"); + ipv4_to_peer(e4, 1, 0); + e4[14 + 20] = 0; + check(!is_question(e4, sizeof e4), "an ICMP echo reply is not"); + e4[14 + 20] = 3; + check(!is_question(e4, sizeof e4), "an ICMP destination unreachable is not"); + ipv4_to_peer(e4, 1, 0); + e4[14 + 7] = 1; /* fragment offset 8 */ + check(!is_question(e4, sizeof e4), "a non-first fragment is not"); + ipv4_to_peer(e4, 17, 53); + check(is_question(e4, sizeof e4), "a DNS query is a question"); + ipv4_to_peer(e4, 17, 5600); + check(!is_question(e4, sizeof e4), "other UDP is not"); + icmpv6_to(e6, kPeer, 128); + check(is_question(e6, sizeof e6), "a unicast ICMPv6 echo request is a question"); + icmpv6_to(e6, kPeer, 135); + check(is_question(e6, sizeof e6), "a unicast neighbour solicitation is a question"); + icmpv6_to(e6, kPeer, 129); + check(!is_question(e6, sizeof e6), "an ICMPv6 echo reply is not"); + icmpv6_to(e6, kAllRouters6, 128); + check(!is_question(e6, sizeof e6), "a multicast ICMPv6 echo request is not"); +} + +/* ONE STRIKE PER BSS. A host that keeps asking a peer that is switched off + * gets no answer on a perfectly healthy link. The verdict fires once; the + * re-joined association on the same BSS is not judged again, so the link is + * not torn down every kConfirmMs. A unicast reply lifts the strike. */ +uint32_t rejoin_open(uint32_t now) { + const std::vector b = beacon(false); + rx_frame(b.data(), b.size(), -40, now); + supervise(now); + Ap ap2; + for (int round = 0; round < 8; round++) { + pump_tx(); + const std::vector> tx = drain_tx(); + if (tx.empty()) break; + for (const std::vector& f : tx) { + const std::vector r = ap2.respond(f, false); + if (!r.empty()) rx_frame(r.data(), r.size(), -40, now); + } + } + drain_tx(); + return now; +} + +void test_one_strike_per_bss() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, (int)kConfirmUplink); + supervise(10); + supervise(10 + kConfirmMs); + check(g_sm.fail_reason() == StationSm::Failure::Unconfirmed, + "the first verdict on a BSS fires"); + uint32_t now = rejoin_open(10 + kConfirmMs + 200); + check(g_sm.state() == StationSm::State::Connected && + g_associations.load() == 2, + "...and the station re-joins"); + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); /* the dead peer, again */ + for (uint32_t t = now; t <= now + kConfirmMs * 4; t += 1000) { + tap_down_one(e, sizeof e); + drain_tx(); + supervise(t); + } + check(g_sm.state() == StationSm::State::Connected && + g_unconfirmed_lost.load() == 1, + "the re-joined association on the struck BSS is not judged again"); + + /* A unicast reply lifts the strike: the next association is judged. */ + const uint8_t pl[16] = {1}; + const std::vector d = ap.plain_to_sta(pl, sizeof pl); + now += kConfirmMs * 4 + 100; + rx_frame(d.data(), d.size(), -40, now); + (void)tap_read(); + { + std::lock_guard l(g_mu); + g_sm.link_lost(); + } + supervise(now); + now = rejoin_open(now + 200); + check(g_sm.state() == StationSm::State::Connected && + g_associations.load() == 3, + "...a third association once the reply came"); + for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); + drain_tx(); + supervise(now + 1); + supervise(now + 1 + kConfirmMs); + check(g_sm.fail_reason() == StationSm::Failure::Unconfirmed && + g_unconfirmed_lost.load() == 2, + "...and judged again, the strike lifted"); +} + +/* THE STRIKE IS PER BSS: one spent on another BSS does not spare this one. + * The fixture has one BSS, so the strike is moved to another address + * directly - what an earlier verdict on a different AP leaves behind. */ +void test_the_strike_is_per_bss() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, (int)kConfirmUplink); + supervise(10); + supervise(10 + kConfirmMs); + { + std::lock_guard l(g_mu); + std::memcpy(g_strike_bss, kPeer, 6); + } + const uint32_t now = rejoin_open(10 + kConfirmMs + 200); + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); + for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); + drain_tx(); + supervise(now + 1); + supervise(now + 1 + kConfirmMs); + check(g_sm.fail_reason() == StationSm::Failure::Unconfirmed && + g_unconfirmed_lost.load() == 2, + "a strike spent on another BSS does not spare this one"); +} + +/* A QUESTION THE FULL QUEUE DROPPED IS NOT COUNTED - it never reached the + * AP, so its missing answer says nothing - and neither is a nudge. */ +void test_a_dropped_frame_is_not_counted() { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + { + std::lock_guard q(g_q_mu); + while (g_q.size() < 128) g_q.push_back(std::vector(1, 0)); + } + const uint64_t drops = g_q_drop.load(); + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); + tap_down_one(e, sizeof e); + const uint64_t nudges = g_nudges.load(), probes = g_probe_tx.load(); + { + std::lock_guard l(g_mu); + nudge(0); + } + check(g_q_drop.load() == drops + 2, "the full queue dropped both frames"); + check(g_uplink_unconfirmed == 0, "...a dropped question is not counted"); + check(g_nudges.load() == nudges && g_probe_tx.load() == probes, + "...and neither is a dropped nudge"); + drain_tx(); +} + /* ONE-WAY TRAFFIC IS NOT A QUESTION: a UDP uplink to a peer behind the AP * (video, telemetry) gets nothing unicast back, and must never be judged - * while the same link's first DNS query is. */ @@ -2279,6 +2514,12 @@ int self_test() { selftest::test_a_burst_after_idle_gets_its_window(); selftest::test_a_one_way_udp_uplink_is_not_judged(); selftest::test_multicast_chatter_is_not_judged(); + selftest::test_fewer_questions_than_the_threshold_are_not_judged(); + selftest::test_a_group_frame_from_the_ap_does_not_confirm(); + selftest::test_what_counts_as_a_question(); + selftest::test_one_strike_per_bss(); + selftest::test_the_strike_is_per_bss(); + selftest::test_a_dropped_frame_is_not_counted(); selftest::test_an_accepted_association_nudges_the_ap(); selftest::test_a_nudge_touches_no_tuning_or_liveness_state(); selftest::test_a_retransmission_is_a_duplicate(); From 3d56480a1afce25d0ee53a5c9d8f8b2a3c73a54f Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:04:00 +0200 Subject: [PATCH 22/53] tests: a second Ctrl-C cannot abandon a harness's hand-back Each station harness's cleanup set CLEANED=yes first, and the INT trap was `cleanup; exit 3`. A second INT during cleanup's sleeps and kills re-entered it, returned at once and exited, leaving the AP phy in the netns, the netns itself, NetworkManager unmanaged and the DUT not re-enumerated. cleanup now ignores INT/TERM as its first line in sta_client_onair, realtek_station_onair, mt7612u_sta_identity, mt7612u_sta_autoack and mt7612u_sta_uplink. Ignored rather than deferred, so the children it starts (the sleep between the two `authorized` writes) ignore them too. mt7612u_ap_onair also runs cleanup between cells, so there the ignore sits in the INT handler and the EXIT trap, and a between-cell cleanup runs with INT/TERM ignored and the handler restored after it. The same harness's reap dropped every pid from KIDS, a KILLed survivor included. In CELLS=all, a between-cell cleanup that returned 1 led to the EXIT trap's cleanup, whose reap saw nothing, returned 0, and re-enumerated the AP under the still-running process. A child still alive after its KILL now stays in KIDS, so every later reap returns 1 and no later cleanup touches the adapter. Checked off-device: a cleanup of the same shape hit by INT, INT, TERM runs to the end and exits 3; reap with a simulated unkillable child returns 1 and keeps it, and a later reap returns 1 again. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_ap_onair.sh | 36 ++++++++++++++++++++++++++-------- tests/mt7612u_sta_autoack.sh | 3 +++ tests/mt7612u_sta_identity.sh | 3 +++ tests/mt7612u_sta_uplink.sh | 3 +++ tests/realtek_station_onair.sh | 3 +++ tests/sta_client_onair.sh | 3 +++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/mt7612u_ap_onair.sh b/tests/mt7612u_ap_onair.sh index 07ed5699..7b1b12e5 100755 --- a/tests/mt7612u_ap_onair.sh +++ b/tests/mt7612u_ap_onair.sh @@ -71,7 +71,10 @@ KIDS="" # Either way every child that has exited, a KILLed one included, is reaped, # so none is left a zombie for the rest of the run. A KILL lands only once # the process leaves the kernel (a USB call can hold it), so it gets 2 s, and -# one still running after that is never `wait`ed on: that would block. +# one still running after that is never `wait`ed on: that would block. It +# STAYS in KIDS instead, so every later reap still finds it, still returns 1, +# and no later cleanup (the EXIT trap's included) re-enumerates the adapter +# under it. reap() { local pid live t=0 killed="" for pid in $KIDS; do kill "$pid" 2>/dev/null; done @@ -87,8 +90,11 @@ reap() { [ "$t" -ge 120 ] && break sleep 0.1; t=$((t + 1)) done - for pid in $KIDS; do sta_pid_alive "$pid" || wait "$pid" 2>/dev/null; done - KIDS="" + live="" + for pid in $KIDS; do + if sta_pid_alive "$pid"; then live="$live $pid"; else wait "$pid" 2>/dev/null; fi + done + KIDS="$live" [ -z "$killed" ] } @@ -140,10 +146,24 @@ cleanup() { # An interrupt must STOP the run: on the EXIT trap alone, INT/TERM would run # cleanup and then carry on into the next cell against a re-enumerated # adapter. CLEANED only spares the EXIT pass a second re-enumeration after -# that; the cleanups between cells (CELLS=all) still run every time. +# that; the cleanups between cells (CELLS=all) still run every time. A final +# cleanup ignores a second INT/TERM: one arriving mid-cleanup would otherwise +# abandon it with the adapter half reset. CLEANED=no -trap '[ "$CLEANED" = yes ] || cleanup' EXIT -trap 'cleanup; CLEANED=yes; exit 3' INT TERM +on_int() { trap '' INT TERM; cleanup; CLEANED=yes; exit 3; } +trap 'trap "" INT TERM; [ "$CLEANED" = yes ] || cleanup' EXIT +trap on_int INT TERM +# Nor is a between-cell cleanup: INT/TERM are ignored while it runs (press +# again once it is done). Ignored, not held by a handler, so the children it +# starts ignore them too - a Ctrl-C would otherwise cut short the `sleep` +# between the two `authorized` writes. +cell_cleanup() { + local rc + trap '' INT TERM + cleanup; rc=$? + trap on_int INT TERM + return "$rc" +} # --- the station ----------------------------------------------------------- STA_IF=$(ls "/sys/bus/usb/devices/$STA_SYSFS:1.0/net/" 2>/dev/null | head -1) @@ -350,10 +370,10 @@ case "$CELLS" in all) # A between-cell cleanup that could not reset the AP ends the run: # the cells after it are recorded as not run, never scored. cell_open - if ! cleanup; then not_run="wpa2 stop" + if ! cell_cleanup; then not_run="wpa2 stop" else cell_wpa2 - if ! cleanup; then not_run="stop"; else cell_stop; fi + if ! cell_cleanup; then not_run="stop"; else cell_stop; fi fi ;; *) echo "usage: $0 [open|wpa2|stop|all]"; exit 2 ;; esac diff --git a/tests/mt7612u_sta_autoack.sh b/tests/mt7612u_sta_autoack.sh index 361caa79..2c88b52e 100755 --- a/tests/mt7612u_sta_autoack.sh +++ b/tests/mt7612u_sta_autoack.sh @@ -74,6 +74,9 @@ DUT_PID="" CLEANED=no # shellcheck disable=SC2317 # reached through the traps below cleanup() { + # Ignored, not deferred: a second INT/TERM during the hand-back would + # otherwise end it half done (CLEANED is already set, so it cannot rerun). + trap '' INT TERM [ "$CLEANED" = yes ] && return 0 CLEANED=yes # arm() runs in a command substitution, so the PIDs it starts are recorded diff --git a/tests/mt7612u_sta_identity.sh b/tests/mt7612u_sta_identity.sh index e33365d5..d3aee9ad 100755 --- a/tests/mt7612u_sta_identity.sh +++ b/tests/mt7612u_sta_identity.sh @@ -91,6 +91,9 @@ AP_REENUM=no CLEANED=no # shellcheck disable=SC2317 # reached through the traps below cleanup() { + # Ignored, not deferred: a second INT/TERM during the hand-back would + # otherwise end it half done (CLEANED is already set, so it cannot rerun). + trap '' INT TERM [ "$CLEANED" = yes ] && return 0 CLEANED=yes sta_fw_unlink diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index b6c54e53..0af39a30 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -89,6 +89,9 @@ RESP="" CLEANED=no # shellcheck disable=SC2317 # reached through the traps below cleanup() { + # Ignored, not deferred: a second INT/TERM during the hand-back would + # otherwise end it half done (CLEANED is already set, so it cannot rerun). + trap '' INT TERM [ "$CLEANED" = yes ] && return 0 CLEANED=yes # arm() runs in a command substitution, so its PIDs are recorded in $OUT diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index e1abc8d6..cfdeb4f7 100755 --- a/tests/realtek_station_onair.sh +++ b/tests/realtek_station_onair.sh @@ -180,6 +180,9 @@ AP_REENUM=no CLEANED=no # shellcheck disable=SC2317 # reached through the traps below cleanup() { + # Ignored, not deferred: a second INT/TERM during the hand-back would + # otherwise end it half done (CLEANED is already set, so it cannot rerun). + trap '' INT TERM [ "$CLEANED" = yes ] && return 0 CLEANED=yes local dut_gone=0 peer_gone=0 diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index b8301729..f6f57aac 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -263,6 +263,9 @@ ip link show "$AP_IF" 2>/dev/null | grep -q '[<,]UP[,>]' && AP_WAS_UP=yes # back what was. NM_AP=no; NS_OURS=no; CLEANED=no; STA_HUNG=no; STA_PID=""; CELL="" cleanup() { + # Ignored, not deferred: a second INT/TERM during the hand-back would + # otherwise end it half done (CLEANED is already set, so it cannot rerun). + trap '' INT TERM [ "$CLEANED" = yes ] && return 0 CLEANED=yes local sta_gone=0 From 904083c85c6173f09f3980eb9899e1fff878d792 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:04:45 +0200 Subject: [PATCH 23/53] tests: sta_dut_take refuses an MT7612U that mt76x2u does not hold The lib header said the adapters were "exclusive anyway". They are not: the OUT lock keeps apart only runs that share an OUT, and an adapter is held only while a devourer process has it open, which nothing does between two gates. A second run with its own OUT found interface 0 unbound, took the DUT, and its hand-back toggled `authorized` under the first run. sta_dut_take now refuses when interface 0 has no driver bound (another run, or a crashed one, holds it; the message says how to re-enumerate it) or is bound to anything but mt76x2u (usbfs: a devourer process has it open). The header now says what the lock does and does not cover. A host that runs without mt76x2u loaded is now refused rather than taken. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_lib.sh | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 900b5072..1a51dad5 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -15,9 +15,13 @@ # itself and refuses while another run holds it, so two concurrent runs # cannot share - and kill each other through - one set of PID files. The # kernel drops the lock when the last holder exits, so there is no owner -# record to race and no stale lock to reclaim. (The adapters are -# exclusive anyway: mt7612uprobe and the Realtek demos take a -# per-adapter lock.) +# record to race and no stale lock to reclaim. Two runs with different +# OUTs are NOT kept apart by this lock, and an adapter is held only +# while a devourer process has it open - between two gates nothing does. +# So sta_dut_take() refuses an MT7612U that mt76x2u does not hold: an +# interface with no driver bound is another run's, or a crashed one's, +# and taking it would hand it back (an `authorized` toggle) under that +# run. # - Kill only what this run started, by recorded PID. No pattern kills, and # no PID read from a file an earlier run left behind: sta_pid_init() # removes stale PID files before anything is started. @@ -95,20 +99,33 @@ sta_is_mt7612u() { STA_DUT_TAKEN=no STA_DUT_ID="" -# Refuse a DUT_SYSFS that is not an MT7612U, then unbind it from mt76x2u and -# require that interface 0 really has no driver afterwards - a failed unbind -# leaves the kernel driver owning the chip under the probe. Only a DUT that was -# taken is handed back, and only while DUT_SYSFS still reports the +# Refuse a DUT_SYSFS that is not an MT7612U, or whose interface 0 mt76x2u +# does not hold (nothing bound: another run, or a crashed one, has it; usbfs: +# a devourer process has it open). Then unbind it from mt76x2u and require +# that interface 0 really has no driver afterwards - a failed unbind leaves +# the kernel driver owning the chip under the probe. Only a DUT that was taken +# is handed back, and only while DUT_SYSFS still reports the # idVendor:idProduct:serial recorded here. sta_dut_take() { if ! sta_is_mt7612u "$DUT_SYSFS"; then echo "refusing DUT_SYSFS=$DUT_SYSFS - not an MT7612U (0e8d:7612)" return 1 fi - if [ -e "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" ]; then - echo "$DUT_SYSFS:1.0" > /sys/bus/usb/drivers/mt76x2u/unbind 2>/dev/null - sleep 2 + _sta_drv="/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" + if [ ! -e "$_sta_drv" ]; then + echo "refusing DUT_SYSFS=$DUT_SYSFS - interface 0 has no driver bound, so" \ + "another run (or a crashed one) holds it. Once nothing uses it," \ + "re-enumerate it (authorized 0 then 1) so mt76x2u (loaded) binds again" + return 1 fi + _sta_drv=$(basename "$(readlink -f "$_sta_drv")") + if [ "$_sta_drv" != mt76x2u ]; then + echo "refusing DUT_SYSFS=$DUT_SYSFS - interface 0 is held by $_sta_drv," \ + "not mt76x2u" + return 1 + fi + echo "$DUT_SYSFS:1.0" > /sys/bus/usb/drivers/mt76x2u/unbind 2>/dev/null + sleep 2 if [ -e "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" ]; then echo "could not free DUT_SYSFS=$DUT_SYSFS: interface 0 is still bound to" \ "$(basename "$(readlink -f "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver")")" From d3c15c96a5829af5e6e978b20fc6a85dd92610f1 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:06:12 +0200 Subject: [PATCH 24/53] mt7612u: Stop() does not clear the arm; the RX log names its filter Stop() cleared a still-armed station before closing, "because the chip keeps its registers across a close" and a managed filter would reach whoever opened the adapter next. It would not: every bring-up rewrites the receive filter (the initvals, then mt_mac_start()), and so does mt76, as docs/mt7612u-station-identity.md already said one sentence later. As a second chance for a failed clear it buys nothing for the same reason, so the hunk is dropped and Stop() is master's again; the doc now says why it does not clear. StartRxLoop logged "monitor RX" even when an armed station kept the managed filter (the monitor request is recorded, not installed). It now says which filter is in force. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 7 ++++--- src/mt7612u/Mt7612uRadio.cpp | 32 ++++++++------------------------ 2 files changed, 12 insertions(+), 27 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index eaec2b11..c63cae1f 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -312,9 +312,10 @@ filter. All of it runs under `Mt7612uRadio::_mu`, the lock the existing filter write and every channel change already take; the only other writes are `mt_mac_start()` and `StartRxLoop`'s `mt7612u_set_monitor_rx()`, the latter mediated as above, and the RX thread itself never writes it. The drop and restore writes are read back and a miss is -logged (the clear re-verifies). `Stop()` clears a still-armed station before -closing, best effort, because the chip keeps its registers across a close; -every bring-up also rewrites the filter in `mt_mac_start()`. The +logged (the clear re-verifies). `Stop()` does not clear a still-armed +station: every bring-up rewrites the filter (the initvals, then +`mt_mac_start()`), and so does mt76, so a filter left armed at a close +reaches no later opener. The policy half is `mt7612u_sta_rx_filter_request()` / `mt7612u_sta_arm()` in `src/mt7612u/StationIdentity.h`, covered by ctest `mt7612u_station_identity`. diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 51b5d051..17b80ad1 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -393,6 +393,9 @@ void Mt7612uRadio::InitWrite(SelectedChannel channel) { void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { struct mt7612u_dev *mac_failed = nullptr; + /* An armed station keeps its managed filter: the monitor request below is + * recorded, not installed (mt7612u_set_monitor_rx). The log says which. */ + bool station_filter = false; { /* The WHOLE prologue, arming through the failure teardown, under the same * lock StopRxLoop uses - and released before the sleep loop below, which @@ -440,6 +443,8 @@ void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { * value - see rule 2. Before it, this write is simply overwritten. */ if (mt7612u_set_monitor_rx(_dev, _cfg.rx.keep_corrupted ? 1 : 0) != 0) _logger->warn("MT7612U monitor RX filter not applied"); + uint8_t armed_bssid[6]; + station_filter = mt7612u_station_bssid(_dev, armed_bssid) == 0; /* Arms the channel timers and zeroes the MIB counters. */ mt7612u_link_stats_start(_dev); /* A window armed before this start was measuring the previous receiver @@ -459,7 +464,8 @@ void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { if (mac_failed) throw std::runtime_error("MT7612U MAC start failed"); - _logger->info("MT7612U monitor RX on channel {}", _channel.Channel); + _logger->info("MT7612U RX on channel {} ({} receive filter)", + _channel.Channel, station_filter ? "managed station" : "monitor"); /* THE consumer. The C layer parses on its own event thread and enqueues; the * processor runs here, on the thread that called StartRxLoop, which is the @@ -715,38 +721,16 @@ void Mt7612uRadio::Stop() { * teardown lock is what keeps a concurrent StopRxLoop from using the pointer * after this steals it. */ struct mt7612u_dev *dev = nullptr; - bool filter_left = false; { std::lock_guard teardown(_teardown_mu); std::lock_guard lock(_mu); dev = _dev; _dev = nullptr; - if (dev) { - /* Best effort: the chip keeps its registers across a close, so a - * station still armed here would leave the managed receive filter for - * whoever opens the adapter next. Put the pre-arm filter back first; - * a no-op with nothing armed. Only a flag here - the logger can throw, - * and this clear must not be what skips the stop and the close below - * (`_dev` is already null, so an escape would leak the handle). Its - * own WARN goes through the same logger (log_trampoline), hence the - * catch. */ - try { - filter_left = mt7612u_clear_station_identity(dev) != 0; - } catch (...) { - filter_left = true; - } + if (dev) mt7612u_stop(dev); - } } if (dev) mt7612u_close(dev); - if (filter_left) { - try { - _logger->warn("MT7612U: closed with the station's managed receive " - "filter possibly still in force"); - } catch (...) { - } - } } void Mt7612uRadio::SetTxPower(uint8_t power) { From 4b3cf34c5a965edb55744db5ec572c412ccb75c9 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:06:52 +0200 Subject: [PATCH 25/53] tests: sta_client_onair - a verdict on an association the AP held FAILs The open cell reported every Unconfirmed verdict as INFO "recovered", including one that hit an association hostapd had already logged as AP-STA-CONNECTED - a false positive that cost the user a re-join, and the on-air oracle for the confirmation rule. hostapd logs AP-STA-CONNECTED once per association it holds, so at most (associations - held) verdicts can be genuine. More than that is a FAIL naming both counts; the genuine "the AP lost us" recovery stays INFO. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 2 +- tests/sta_client_onair.sh | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 0ecf2578..7c00ec66 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -129,7 +129,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts; more `unconfirmed` verdicts than associations the AP never held - AP-STA-CONNECTED counts the held ones - FAILs, as a verdict on a held association); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index f6f57aac..ca7cccc4 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -677,10 +677,20 @@ cell_open() { *) station_gone open; sta_pid_kill hostapd; return ;; esac cell_end - local unconf assoc + # An Unconfirmed verdict is right only for an association the AP never + # held. hostapd logs AP-STA-CONNECTED once for each association it holds, + # so at most (associations - held) verdicts can be genuine; any more hit + # an association the AP held - a false positive, which costs the user a + # re-join and is what the one-strike bound in sta_client.cpp limits. + local unconf assoc held unconf=$(led open 'unconfirmed'); assoc=$(led open 'associations') + held=$(grep -c "AP-STA-CONNECTED $own" "$OUT/hostapd_open.log") if [ "${unconf:-0}" -gt 0 ] 2>/dev/null; then - info "open: recovered: ${unconf} association(s) the AP did not hold were found unconfirmed and re-joined (${assoc:-?} associations, assoc_repeat=$(led open 'assoc_repeat'))" + if [ "$unconf" -gt $(( ${assoc:-0} - held )) ] 2>/dev/null; then + bad "open: ${unconf} unconfirmed verdict(s), but the AP held ${held} of the station's ${assoc:-?} associations - a verdict hit an association the AP held" + else + info "open: recovered: ${unconf} association(s) the AP did not hold were found unconfirmed and re-joined (${assoc:-?} associations, ${held} held by the AP, assoc_repeat=$(led open 'assoc_repeat'))" + fi fi local plain enc plain=$(led open 'plaintext rx'); enc=$(led open 'encrypted rx') From f4f001dee3fff469f1d4e90dd10b39930faa3fa3 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:07:36 +0200 Subject: [PATCH 26/53] mt7612u: staid's clear-after-drop check can fail The last staid case checked that a clear after a responder dropped the arm "verifies the monitor receive filter". The drop had already written and verified that value, so the read-back held whether or not the clear wrote anything. The register is now poisoned with the managed value first, so only a clear that re-writes the pre-arm filter passes. On the SKIP path the clear still runs, unchecked, as cleanup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- src/mt7612u/tools/bringup.cpp | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/mt7612u/tools/bringup.cpp b/src/mt7612u/tools/bringup.cpp index e33744e6..a753059c 100644 --- a/src/mt7612u/tools/bringup.cpp +++ b/src/mt7612u/tools/bringup.cpp @@ -5207,13 +5207,20 @@ static int gate_staid(void) CHK(staid_filtr_is(mon), "the drop gives the receiver back the monitor filter"); mt7612u_clear_ack_responder(&dev); + /* After a drop the clear re-writes the pre-arm filter and verifies + * it. The drop has already written that value, so a clear that + * wrote nothing would pass a bare read-back: poison the register + * first, so only a clear that writes it can pass. */ + mt_wr(&dev, MT_RX_FILTR_CFG, MT_RX_FILTR_CFG_MANAGED); + CHK(mt7612u_clear_station_identity(&dev) == 0 && + staid_filtr_is(mon), + "clear after the drop re-writes the monitor receive filter " + "(register poisoned first)"); } else { printf(" SKIP could not set up case 6\n"); fail++; + mt7612u_clear_station_identity(&dev); } - /* After a drop: the clear re-writes the pre-arm filter and verifies it. */ - CHK(mt7612u_clear_station_identity(&dev) == 0 && staid_filtr_is(mon), - "clear after the drop verifies the monitor receive filter"); #undef CHK printf("\nGATE STAID: %d passed, %d failed\n", pass, fail); From 3165563a6fc27dd307470c5f9e26780415936f6e Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:08:11 +0200 Subject: [PATCH 27/53] tests: the injector's disjoint sequence ranges are a precaution, said so sta_unicast_inject.py's seq0 and sta_client_onair's two streams at 0 and 2048 were justified as keeping duplicate detection from merging them. The injected frames never set Retry, so 802.11 duplicate detection should not merge them in the first place. The ranges stay - the benched configuration used them - but both comments now call them a precaution rather than a need. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client_onair.sh | 6 +++--- tests/sta_unicast_inject.py | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index ca7cccc4..3dae5ea1 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -546,9 +546,9 @@ check_cleared() { # $1 cell # The managed-filter stimulus (header): the two streams off a monitor vif on # the AP's own phy, while the station is associated. They share a transmitter -# address, so they get disjoint sequence ranges (0 and 2048): the managed -# filter's hardware DUP drop must not take one for a retransmission of the -# other. The injector counts frames it SUBMITTED, not frames that aired - +# address and get disjoint sequence ranges (0 and 2048) as a precaution only: +# neither sets Retry, so duplicate detection should not merge them anyway. +# The injector counts frames it SUBMITTED, not frames that aired - # hence the own stream as the positive witness. Each PID is recorded on the # statement after its launch, and every injector is bounded by `timeout -k` # whatever happens to the harness. Sets INJ_FOREIGN / INJ_OWN (empty when it diff --git a/tests/sta_unicast_inject.py b/tests/sta_unicast_inject.py index 2629adc3..4f2b6208 100755 --- a/tests/sta_unicast_inject.py +++ b/tests/sta_unicast_inject.py @@ -15,8 +15,9 @@ sta_unicast_inject.py [seconds] [pps] [seq0] seq0 (0..4095, default 0) is the first sequence number. Two injectors sharing -a transmitter address must use disjoint ranges, or a receiver's duplicate -detection can take one stream's frames for the other's. +a transmitter address can be given disjoint ranges. That is a precaution, not +a measured need: these frames never set Retry, so 802.11 duplicate detection +should not merge the two streams anyway. Note what this does NOT do: mac80211 marks injected frames no-ack by default, so these do not solicit an acknowledgement and cannot be used to measure one. From 3651a9fae9d65d72e37c0bd6bcf9831b401bf51e Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:09:46 +0200 Subject: [PATCH 28/53] tests: the firmware preflight follows the library and names what it finds - sta_client_onair checked FW_DIR alone, but the library's resolve_fw_dir falls back to /lib/firmware/mediatek and then ./firmware. The preflight now takes the first of those that holds both blobs, exactly as the library does, and requires that one to be readable; with none, the refusal names FW_DIR. Header and docs/station-client.md say so. - sta_fw_readable said "only mt7662.bin.zst is there" even when an empty or unreadable .bin was there too. A missing .bin and an unusable one are now told apart, and a missing one reports whichever compressed copies sit beside it (.zst, .xz, .gz) as "compressed firmware". - mt7612u_sta_uplink.sh gets the "Exit status:" header line the autoack and AP harnesses have, placed after its Env line. Checked on fixtures under bash and dash: .zst-only and .xz-only refused by name, an empty .bin (with or without a .zst beside it) refused as unusable, a missing directory refused, a good directory and a symlink to it pass, and so does this host's /lib/firmware/mediatek. Refs #467. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 5 +++-- tests/mt7612u_sta_lib.sh | 35 ++++++++++++++++++++++------------- tests/mt7612u_sta_uplink.sh | 3 +++ tests/sta_client_onair.sh | 22 ++++++++++++++++++---- 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 7c00ec66..12d8e73a 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -155,8 +155,9 @@ first line then reads `fault=1`. named), 2 inconclusive (rig refused, AP not up, route not through the TAP, the station exited or stalled before `sta_client up:`, station out of time), 3 interrupted. `FW_DIR` (an MT7612U DUT) must hold the decompressed - MT7612U blobs; a directory with only the `.bin.zst` copies is refused - (exit 2) before the run starts. + MT7612U blobs. Like the library, it falls back to `/lib/firmware/mediatek` + and then `./firmware`; when none holds them (compressed `.bin.zst` copies + only), the run is refused (exit 2) before it starts. ## AP quirk: an MT7612U AP holds the association's TX status diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 1a51dad5..7f30c52f 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -301,23 +301,32 @@ sta_fw_link() { } # 0 when directory $1 holds both MT7612U blobs, readable and non-empty. A -# host whose firmware is zstd-compressed (/lib/firmware/mediatek/*.bin.zst -# only) links fine, and the DUT then fails its bring-up with "cannot open -# firmware/mt7662_rom_patch.bin", which a gate scores as an empty ABORTED or a -# missing MAC. This check makes that dead rig a refusal before anything runs. +# host whose firmware is compressed (/lib/firmware/mediatek/*.bin.zst only, +# as most distributions ship it) links fine, and the DUT then fails its +# bring-up with "cannot open firmware/mt7662_rom_patch.bin", which a gate +# scores as an empty ABORTED or a missing MAC. This check makes that dead rig +# a refusal before anything runs. sta_fw_readable() { for _sta_fw in mt7662_rom_patch.bin mt7662.bin; do - if [ -f "$1/$_sta_fw" ] && [ -r "$1/$_sta_fw" ] && [ -s "$1/$_sta_fw" ]; then - continue + _sta_p="$1/$_sta_fw" + if [ ! -e "$_sta_p" ]; then + _sta_z="" + for _sta_x in zst xz gz; do + [ -e "$_sta_p.$_sta_x" ] && _sta_z="$_sta_z $_sta_fw.$_sta_x" + done + if [ -n "$_sta_z" ]; then + echo "refusing: $_sta_p is missing; only compressed firmware is there" \ + "(${_sta_z# }). The DUT loads the blobs uncompressed: decompress" \ + "both into a directory and pass it as FW_DIR" + else + echo "refusing: $_sta_p is missing (FW_DIR=$FW_DIR)" + fi + return 1 fi - if [ -e "$1/$_sta_fw.zst" ]; then - echo "refusing: $1/$_sta_fw is missing, only $_sta_fw.zst is there - the" \ - "DUT loads the blobs uncompressed. Decompress both (zstd -d) into a" \ - "directory and pass it as FW_DIR" - else - echo "refusing: $1/$_sta_fw is missing, unreadable or empty (FW_DIR=$FW_DIR)" + if [ ! -f "$_sta_p" ] || [ ! -r "$_sta_p" ] || [ ! -s "$_sta_p" ]; then + echo "refusing: $_sta_p is not a readable, non-empty file" + return 1 fi - return 1 done return 0 } diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index 0af39a30..dd68d51d 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -42,6 +42,9 @@ # sudo tests/mt7612u_sta_uplink.sh # # Env: PEER_VID, PEER_PID, PEER_SYSFS, DUT_SYSFS, CH, FRAMES, RETRY_LIMIT, OUT. +# +# Exit status: 0 every check passed; 1 a check failed; 2 INCONCLUSIVE (the rig +# was refused, or an arm could not measure); 3 interrupted (INT/TERM). set -u ROOT="$(cd "$(dirname "$0")/.." && pwd)" diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 3dae5ea1..03de7c46 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -115,9 +115,11 @@ # set_wiphy_netns): an in-kernel cfg80211 driver such as mt76 or rtw88. # Out-of-tree drivers such as rtl88x2cu / 88x2bu cannot, and are refused. # Read both from `lsusb -t` after the drivers have loaded (they can move). -# FW_DIR (an MT7612U DUT only) must hold the DECOMPRESSED MT7612U blobs -# (mt7662*.bin); a host that ships only mt7662*.bin.zst is refused (exit 2) -# before anything is touched. +# FW_DIR (an MT7612U DUT only) should hold the DECOMPRESSED MT7612U blobs +# (mt7662*.bin). Like the library, a FW_DIR without them falls back to +# /lib/firmware/mediatek, then ./firmware; when none holds them (a host that +# ships only compressed mt7662*.bin.zst) the run is refused (exit 2) before +# anything is touched. # Build first: cmake --build build --target StaClientSelftest (build/sta_client). # # HOSTAPD_DEBUG=1: hostapd runs with -dd, its debug output in the same @@ -234,7 +236,19 @@ DUT_PID="0x${dut_have#*:}" # shellcheck source=tests/mt7612u_sta_lib.sh . "$ROOT/tests/mt7612u_sta_lib.sh" -if [ "$DUT_KIND" = mt7612u ]; then sta_fw_readable "$FW_DIR" || exit 2; fi +# The firmware the library will load: the first of FW_DIR, +# /lib/firmware/mediatek and ./firmware that holds both blobs - the order and +# the test of resolve_fw_dir (src/mt7612u/Mt7612uRadio.cpp). That one must be +# readable; with none, the refusal names FW_DIR. +if [ "$DUT_KIND" = mt7612u ]; then + fw_pick="" + for d in "$FW_DIR" /lib/firmware/mediatek firmware; do + if [ -e "$d/mt7662_rom_patch.bin" ] && [ -e "$d/mt7662.bin" ]; then + fw_pick="$d"; break + fi + done + sta_fw_readable "${fw_pick:-$FW_DIR}" || exit 2 +fi sta_out_prepare || exit 2 sta_lock_take || exit 2 sta_pid_init sta hostapd probe inject inject_own From 6e7813e51d6eeb13d120ff55dc6ff3733ce3143b Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:15:16 +0200 Subject: [PATCH 29/53] tests: mt7612u_ap_onair - mark on_int as reached through the traps shellcheck 0.11 reports the new INT handler as never invoked (SC2329, SC2317 before 0.10). It is reached through `trap on_int INT TERM`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_ap_onair.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/mt7612u_ap_onair.sh b/tests/mt7612u_ap_onair.sh index 7b1b12e5..756db33f 100755 --- a/tests/mt7612u_ap_onair.sh +++ b/tests/mt7612u_ap_onair.sh @@ -150,6 +150,7 @@ cleanup() { # cleanup ignores a second INT/TERM: one arriving mid-cleanup would otherwise # abandon it with the adapter half reset. CLEANED=no +# shellcheck disable=SC2317,SC2329 # reached through the traps on_int() { trap '' INT TERM; cleanup; CLEANED=yes; exit 3; } trap 'trap "" INT TERM; [ "$CLEANED" = yes ] || cleanup' EXIT trap on_int INT TERM From 078142a153c8b00142d527b70850b8a2074e2732 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:31:20 +0200 Subject: [PATCH 30/53] tests: sta_client - a struck BSS backs off instead of being let off One strike per BSS could leave a held-but-dead association dead for the life of the process. Against an MT7612U AP that holds the association's TX status, the AP silently drops the station's data, never deauthenticates it, and keeps beaconing. A second such association on the struck BSS was never judged, and nothing else ended it. The strike is now a backoff. After n consecutive verdicts on a BSS (g_strikes), the next association on it is judged only once kConfirmMs * 2^n has passed since it was made: 10 s, 20 s, 40 s, 80 s, then the 2-minute cap (kStrikeCapMs). Questions asked inside the backoff are not counted, so the window opens at a question asked after it. A unicast reply, or an association on a different BSS, resets the count; a broadcast does not. A dead peer still costs at most one re-join per backoff period, and a dropped association is still found. Also: - A static_assert pins 3 questions / 5 s / the 2-minute cap to what docs/station-client.md states. - The association and verdict lines carry `at=`, the wall-clock time in hostapd -t's form, for the on-air harness to order them against the AP's log. Headless cells: - test_a_struck_bss_backs_off: the backoff values and cap; no judgement and no counting inside the first backoff; judged right after it; the second backoff twice as long; a reply resets it. - test_a_group_frame_does_not_reset_the_backoff. - test_the_strike_is_per_bss, reworked. - test_a_truncated_tcp_header_is_not_read: the frame sits in an exact allocation, so a missing l4n bound reads past it under ASan. 29 mutations of sta_client.cpp on a scratch copy are all killed. Two of them (the threshold and cap constants) are killed at compile time by the static_assert. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 26 +++--- tests/sta_client.cpp | 93 ++++++++++++++------ tests/sta_client_selftest.inc | 158 +++++++++++++++++++++++----------- 3 files changed, 192 insertions(+), 85 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 12d8e73a..d8afac9a 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -90,19 +90,23 @@ any other TCP segment and an idle host are never judged. A question can also go unanswered on a healthy link: the host pings or ARPs a peer that is switched off, or its DNS has no upstream. So the rule is a -bounded backstop, with one strike per BSS. It fires at most once on a BSS -until an association on that BSS has been confirmed. The re-joined -association after a verdict is not judged, and a unicast reply, or an -association on a different BSS, lifts the strike. A host asking a dead peer -therefore costs one re-join, not one every 5 s. The cost: a second unheld -association on the struck BSS is not found by this rule, and its traffic is -lost until something else ends or confirms it (beacon loss, a -deauthentication, the AP's first reply). An unheld association under -one-way traffic alone is found only when the host's stack next asks -something (its neighbour re-verification is a unicast ARP request). WPA2 +backstop that backs off per BSS. The first verdict on a BSS fires as +described. After n consecutive verdicts on a BSS, the next association on +it is judged only once 5 s x 2^n has passed since it was made: 10 s, 20 s, +40 s, 80 s, then 2 minutes, the cap. Questions asked inside the backoff are +not counted. A unicast reply from the AP, or an association on a different +BSS, resets the count; a broadcast does not. The cost: with a dead peer, +the station re-joins at most once per backoff period, the periods growing +to one re-join every 2 minutes (plus the 5 s window). And an association +the AP really dropped is found up to one backoff period late, its traffic +lost until then. An unheld association under one-way traffic alone is +found only when the host's stack next asks something (its neighbour +re-verification is a unicast ARP request). WPA2 needs no such rule (the four-way is the confirmation). The ledger counts the verdicts (`unconfirmed=`) and repeated association responses -(`assoc_repeat=`). +(`assoc_repeat=`). Each association and each verdict line carries `at=`, +the wall-clock time in the form `hostapd -t` stamps its lines with, so the +on-air harness can order them against the AP's log. Exit status: 0 the run completed; 1 setup failed; 2 refused (`station_mode_ok` false, or the duration, `DEVOURER_CHANNEL`, diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 6339b2fc..5dd3aaec 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -81,6 +81,8 @@ #include #include +#include + #include #include #include @@ -322,24 +324,52 @@ int8_t rssi_dbm(uint8_t raw) { * link: the host pings or ARPs a peer that is switched off, or its DNS has * no upstream. * - * ONE STRIKE PER BSS. So the verdict is a bounded backstop: it fires at most - * once per BSS until an association on that BSS has been confirmed. After - * one Unconfirmed verdict the re-joined association on the same BSS is not - * judged (g_strike), so a host asking a dead peer costs one re-join, not one - * every kConfirmMs. A unicast reply on that BSS, or an association on a - * different one, lifts it. The cost: a second unheld association on the - * struck BSS is not found by this rule, and its traffic is lost until - * something else (beacon loss, a deauthentication, the AP's first reply) - * ends or confirms it. And an unheld association under one-way traffic alone - * is found only when the host's stack next asks something (its neighbour - * re-verification is a unicast ARP request). */ + * BACKOFF PER BSS. So consecutive verdicts on one BSS back off: after n of + * them (g_strikes), the next association on that BSS is judged only once + * kConfirmMs * 2^n has passed since it was made (strike_backoff_ms), capped + * at kStrikeCapMs. Questions asked inside the backoff are not counted. A + * host asking a dead peer therefore costs at most one re-join per backoff + * period - 10 s, 20 s, 40 s ... then one every 2 minutes - instead of one + * every kConfirmMs, while an association the AP really dropped is still + * found, at worst a backoff period late. A unicast reply (the confirmation) + * or an association on a different BSS resets the count. An unheld + * association under one-way traffic alone is found only when the host's + * stack next asks something (its neighbour re-verification is a unicast ARP + * request). */ constexpr uint32_t kConfirmMs = 5000; constexpr uint32_t kConfirmUplink = 3; -/* An open association with no unicast reply yet, on a BSS whose strike is - * not spent: the verdict may fire for it. */ +constexpr uint32_t kStrikeCapMs = 120000; +/* docs/station-client.md and the on-air harness state these numbers. */ +static_assert(kConfirmMs == 5000 && kConfirmUplink == 3 && + kStrikeCapMs == 120000, + "docs/station-client.md documents 3 questions / 5 s / a 2 min " + "backoff cap: change them together"); +/* An open association with no unicast reply yet: the verdict may fire for + * it, once g_judge_from_ms has passed. */ bool g_judge = false; -bool g_strike = false; /* the one verdict is spent on... */ +uint32_t g_judge_from_ms = 0; +uint32_t g_strikes = 0; /* consecutive verdicts on... */ uint8_t g_strike_bss[6] = {0}; /* ...this BSS */ + +/* How long an association on a BSS with `strikes` consecutive verdicts waits + * before it is judged: 0, then kConfirmMs * 2^strikes, capped. */ +uint32_t strike_backoff_ms(uint32_t strikes) { + if (strikes == 0) return 0; + uint32_t d = kConfirmMs; + for (uint32_t i = 0; i < strikes && d < kStrikeCapMs; i++) d *= 2; + return d < kStrikeCapMs ? d : kStrikeCapMs; +} + +/* Wall-clock seconds.microseconds, the form hostapd -t stamps its lines + * with, so the on-air harness can order the station's events against the + * AP's. Into `buf`, which it returns. */ +const char* wall_stamp(char* buf, size_t n) { + struct timespec ts {}; + clock_gettime(CLOCK_REALTIME, &ts); + std::snprintf(buf, n, "%lld.%06ld", (long long)ts.tv_sec, + (long)(ts.tv_nsec / 1000)); + return buf; +} bool g_uplink_seen = false; /* supervise() saw the first question */ uint32_t g_uplink_first_ms = 0; /* ...at this time: the window opens */ uint32_t g_uplink_unconfirmed = 0; @@ -424,11 +454,12 @@ void nudge(uint32_t now) { g_nudge_ms = now; } -void on_association() { - /* The one strike is per BSS: a different BSS is judged afresh. */ - if (g_strike && std::memcmp(g_strike_bss, g_sm.bssid(), 6) != 0) - g_strike = false; - g_judge = g_sm.security() == StationSm::Security::Open && !g_strike; +void on_association(uint32_t now) { + /* The backoff is per BSS: a different BSS is judged afresh. */ + if (g_strikes && std::memcmp(g_strike_bss, g_sm.bssid(), 6) != 0) + g_strikes = 0; + g_judge = g_sm.security() == StationSm::Security::Open; + g_judge_from_ms = now + strike_backoff_ms(g_strikes); g_uplink_seen = false; g_uplink_unconfirmed = 0; g_rx_dup.reset(); @@ -437,8 +468,9 @@ void on_association() { const uint64_t n = g_associations.fetch_add(1) + 1; /* One line per association, so a re-join is visible while the run lasts * and not only in the exit ledger. */ - std::fprintf(stderr, " station connected (association %llu)\n", - (unsigned long long)n); + char at[32]; + std::fprintf(stderr, " station connected (association %llu) at=%s\n", + (unsigned long long)n, wall_stamp(at, sizeof at)); } /* A REKEY RESTARTS A PN SPACE, and the windows restart with it - both @@ -528,7 +560,7 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { g_nudge_eapol_rx = g_sm.eapol_rx; } if (before != StationSm::State::Connected && g_sm.connected()) - on_association(); + on_association(now); if (g_sm.connected()) note_keys(); /* The data plane runs only on a live association: a protected frame that @@ -595,7 +627,7 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { } if (to_us) { /* the AP holds this association */ g_judge = false; - g_strike = false; /* ...so its BSS is judged again */ + g_strikes = 0; /* ...so its BSS backs off no more */ } g_plain_rx.fetch_add(1); if (len > hlen) tap_up(da, sa, mpdu + hlen, len - hlen); @@ -807,6 +839,9 @@ uint8_t supervise(uint32_t now) { /* An unconfirmed open association the host has been talking through * (see kConfirmMs) - lost, through the ordinary failure path below. The * window opens the first time this pass sees a question. */ + /* Inside a struck BSS's backoff nothing is judged, and its questions are + * dropped: the window must open at a question asked after it. */ + if (g_judge && (int32_t)(now - g_judge_from_ms) < 0) g_uplink_unconfirmed = 0; if (g_judge && g_uplink_unconfirmed > 0 && !g_uplink_seen) { g_uplink_seen = true; g_uplink_first_ms = now; @@ -814,12 +849,18 @@ uint8_t supervise(uint32_t now) { if (g_sm.state() == StationSm::State::Connected && g_judge && g_uplink_seen && g_uplink_unconfirmed >= kConfirmUplink && (uint32_t)(now - g_uplink_first_ms) >= kConfirmMs) { + char at[32]; std::fprintf(stderr, " station association unconfirmed: %u frames sent, no " - "unicast reply from the AP in %u ms\n", - g_uplink_unconfirmed, (unsigned)(now - g_uplink_first_ms)); + "unicast reply from the AP in %u ms at=%s\n", + g_uplink_unconfirmed, (unsigned)(now - g_uplink_first_ms), + wall_stamp(at, sizeof at)); g_judge = false; - g_strike = true; /* the one strike for this BSS */ + /* One more consecutive verdict on this BSS: the next association on it + * waits longer before it is judged. */ + if (g_strikes && std::memcmp(g_strike_bss, g_sm.bssid(), 6) != 0) + g_strikes = 0; + g_strikes++; std::memcpy(g_strike_bss, g_sm.bssid(), 6); g_unconfirmed_lost.fetch_add(1); g_sm.link_lost(); diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index f1d736c6..67962d0c 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -108,7 +108,8 @@ void reset_all() { g_tap_read_err = 0; g_end = RunEnd{}; g_judge = false; - g_strike = false; + g_judge_from_ms = 0; + g_strikes = 0; std::memset(g_strike_bss, 0, 6); g_uplink_seen = false; g_uplink_first_ms = 0; @@ -1282,7 +1283,7 @@ void test_multicast_chatter_is_not_judged() { /* ...but ARP requests are questions: an ARP that is never answered is how * an AP that dropped us shows - the first time on this BSS (the one - * strike; test_one_strike_per_bss). */ + * verdict on it fires at once; test_a_struck_bss_backs_off). */ for (int i = 0; i < (int)kConfirmUplink; i++) { uint8_t e[60]; arp_request(e, 2, 1); @@ -1463,10 +1464,12 @@ void test_what_counts_as_a_question() { check(!is_question(e6, sizeof e6), "a multicast ICMPv6 echo request is not"); } -/* ONE STRIKE PER BSS. A host that keeps asking a peer that is switched off - * gets no answer on a perfectly healthy link. The verdict fires once; the - * re-joined association on the same BSS is not judged again, so the link is - * not torn down every kConfirmMs. A unicast reply lifts the strike. */ +/* BACKOFF PER BSS. A host that keeps asking a peer that is switched off gets + * no answer on a perfectly healthy link. The first verdict fires at once; + * each re-joined association on the same BSS is then judged only after a + * backoff that doubles per consecutive verdict, so the link is not torn down + * every kConfirmMs - but an association the AP really dropped is still found. + * A unicast reply resets it; a group frame does not. */ uint32_t rejoin_open(uint32_t now) { const std::vector b = beacon(false); rx_frame(b.data(), b.size(), -40, now); @@ -1485,55 +1488,102 @@ uint32_t rejoin_open(uint32_t now) { return now; } -void test_one_strike_per_bss() { - reset_all(); - Ap ap; - open_link_with_uplink(ap, (int)kConfirmUplink); - supervise(10); - supervise(10 + kConfirmMs); - check(g_sm.fail_reason() == StationSm::Failure::Unconfirmed, - "the first verdict on a BSS fires"); - uint32_t now = rejoin_open(10 + kConfirmMs + 200); - check(g_sm.state() == StationSm::State::Connected && - g_associations.load() == 2, - "...and the station re-joins"); +/* A ping to the dead peer every second over [from, to], each followed by a + * supervise pass. The time of the pass at which a verdict fired, or 0. */ +uint32_t ping_until(uint32_t from, uint32_t to) { + const uint64_t lost = g_unconfirmed_lost.load(); uint8_t e[60]; - ipv4_to_peer(e, 1, 0); /* the dead peer, again */ - for (uint32_t t = now; t <= now + kConfirmMs * 4; t += 1000) { + ipv4_to_peer(e, 1, 0); + for (uint32_t t = from; t <= to; t += 1000) { tap_down_one(e, sizeof e); drain_tx(); supervise(t); + if (g_unconfirmed_lost.load() != lost) return t; } + return 0; +} + +/* A first verdict on kBssid, then the re-join. Returns its time. */ +uint32_t struck_and_rejoined(Ap& ap) { + open_link_with_uplink(ap, (int)kConfirmUplink); + supervise(10); + supervise(10 + kConfirmMs); + check(g_unconfirmed_lost.load() == 1, "the first verdict on a BSS fires at once"); + const uint32_t t = rejoin_open(10 + kConfirmMs + 200); check(g_sm.state() == StationSm::State::Connected && - g_unconfirmed_lost.load() == 1, - "the re-joined association on the struck BSS is not judged again"); + g_associations.load() == 2, + "...and the station re-joins"); + return t; +} - /* A unicast reply lifts the strike: the next association is judged. */ +void test_a_struck_bss_backs_off() { + check(strike_backoff_ms(0) == 0 && strike_backoff_ms(1) == 2 * kConfirmMs && + strike_backoff_ms(2) == 4 * kConfirmMs && + strike_backoff_ms(5) == kStrikeCapMs && + strike_backoff_ms(40) == kStrikeCapMs, + "the backoff doubles per consecutive verdict, capped at kStrikeCapMs"); + reset_all(); + Ap ap; + uint32_t t = struck_and_rejoined(ap); + + const uint32_t b1 = t + strike_backoff_ms(1); + check(ping_until(t, b1 - 1) == 0 && g_uplink_unconfirmed == 0, + "inside the backoff the dead peer is not judged, nor its questions " + "counted"); + uint32_t v = ping_until(b1, b1 + 2 * kConfirmMs); + check(v >= b1 + kConfirmMs && v <= b1 + kConfirmMs + 1000, + "after the backoff the association is judged again"); + + t = rejoin_open(v + 200); + const uint32_t b2 = t + strike_backoff_ms(2); + check(ping_until(t, b2 - 1) == 0, "the second backoff is twice as long"); + v = ping_until(b2, b2 + 2 * kConfirmMs); + check(v >= b2 + kConfirmMs && v <= b2 + kConfirmMs + 1000, + "...and the association is judged after it"); + + /* A unicast reply resets the backoff: the next association is judged at + * once. */ + t = rejoin_open(v + 200); const uint8_t pl[16] = {1}; const std::vector d = ap.plain_to_sta(pl, sizeof pl); - now += kConfirmMs * 4 + 100; - rx_frame(d.data(), d.size(), -40, now); + rx_frame(d.data(), d.size(), -40, t + 10); (void)tap_read(); { std::lock_guard l(g_mu); g_sm.link_lost(); } - supervise(now); - now = rejoin_open(now + 200); + supervise(t + 20); + t = rejoin_open(t + 300); + v = ping_until(t, t + 2 * kConfirmMs); + check(v >= t + kConfirmMs && v <= t + kConfirmMs + 1000, + "a unicast reply resets the backoff: the next association is judged " + "at once"); +} + +/* Only a unicast reply resets the backoff. A broadcast from the AP reaches + * every station it ever held, and says nothing about this association: after + * one, a link lost some other way and re-joined is still backed off. */ +void test_a_group_frame_does_not_reset_the_backoff() { + reset_all(); + Ap ap; + uint32_t t = struck_and_rejoined(ap); + const uint8_t pl[16] = {1}; + const std::vector d = ap.plain_to(kBcast, pl, sizeof pl); + rx_frame(d.data(), d.size(), -40, t + 10); + (void)tap_read(); + { + std::lock_guard l(g_mu); + g_sm.link_lost(); + } + supervise(t + 20); + t = rejoin_open(t + 300); check(g_sm.state() == StationSm::State::Connected && - g_associations.load() == 3, - "...a third association once the reply came"); - for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); - drain_tx(); - supervise(now + 1); - supervise(now + 1 + kConfirmMs); - check(g_sm.fail_reason() == StationSm::Failure::Unconfirmed && - g_unconfirmed_lost.load() == 2, - "...and judged again, the strike lifted"); + ping_until(t, t + strike_backoff_ms(1) - 1) == 0, + "a broadcast from the AP does not reset the backoff"); } -/* THE STRIKE IS PER BSS: one spent on another BSS does not spare this one. - * The fixture has one BSS, so the strike is moved to another address +/* THE BACKOFF IS PER BSS: one earned on another BSS does not spare this + * one. The fixture has one BSS, so the strike is moved to another address * directly - what an earlier verdict on a different AP leaves behind. */ void test_the_strike_is_per_bss() { reset_all(); @@ -1546,15 +1596,25 @@ void test_the_strike_is_per_bss() { std::memcpy(g_strike_bss, kPeer, 6); } const uint32_t now = rejoin_open(10 + kConfirmMs + 200); - uint8_t e[60]; - ipv4_to_peer(e, 1, 0); - for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); - drain_tx(); - supervise(now + 1); - supervise(now + 1 + kConfirmMs); - check(g_sm.fail_reason() == StationSm::Failure::Unconfirmed && - g_unconfirmed_lost.load() == 2, - "a strike spent on another BSS does not spare this one"); + const uint32_t v = ping_until(now, now + 2 * kConfirmMs); + check(v >= now + kConfirmMs && v <= now + kConfirmMs + 1000, + "a backoff earned on another BSS does not spare this one"); +} + +/* A TCP segment too short to hold its flags byte is not a question - and the + * flags byte is never read past the end. The frame sits in an allocation of + * exactly its own length, so an ASan build catches a missing bound. */ +void test_a_truncated_tcp_header_is_not_read() { + std::vector m; + devourer::sta::append_llc_snap(m, 0x0800); + uint8_t ip[20] = {0x45}; + ip[9] = 6; + m.insert(m.end(), ip, ip + sizeof ip); + m.insert(m.end(), 13, 0x02); /* TCP, one byte short of the flags */ + std::unique_ptr exact(new uint8_t[m.size()]); + std::memcpy(exact.get(), m.data(), m.size()); + check(!solicits_reply(exact.get(), m.size(), kPeer), + "a TCP segment too short to hold its flags is not a question"); } /* A QUESTION THE FULL QUEUE DROPPED IS NOT COUNTED - it never reached the @@ -2517,7 +2577,9 @@ int self_test() { selftest::test_fewer_questions_than_the_threshold_are_not_judged(); selftest::test_a_group_frame_from_the_ap_does_not_confirm(); selftest::test_what_counts_as_a_question(); - selftest::test_one_strike_per_bss(); + selftest::test_a_struck_bss_backs_off(); + selftest::test_a_group_frame_does_not_reset_the_backoff(); + selftest::test_a_truncated_tcp_header_is_not_read(); selftest::test_the_strike_is_per_bss(); selftest::test_a_dropped_frame_is_not_counted(); selftest::test_an_accepted_association_nudges_the_ap(); From 13fbc34dad15880ac7b1c3a74f3a46bea90aa8ab Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:32:28 +0200 Subject: [PATCH 31/53] tests: sta_client_onair - order each verdict against hostapd's log The open cell's FAIL rule, unconf > (associations - AP-STA-CONNECTED count), could fail a correct recovery. The re-join's probe or authentication releases the AP's held status, so hostapd logs AP-STA-CONNECTED for the association the station already abandoned, then DISCONNECTED/CONNECTED for the new one. That gives held=2, assoc=2, unconf=1: FAIL. The count could also go negative. The count arithmetic is gone. verdicts_scored pairs each verdict with the association it ended, using the new `at=` wall-clock stamps (the form hostapd -t uses). It FAILs only a verdict whose association hostapd logged AP-STA-CONNECTED for between that association and the verdict. A CONNECTED after the verdict - the late release - does not count. The opposite race, the AP stamping its CONNECTED a few ms before the station prints its association line, can only hide a false positive, never invent one. A verdict without a stamp, or a ledger that disagrees with the log, is INCONCLUSIVE. Checked on fixtures: the review's late-release sequence scores INFO; a CONNECTED before the verdict FAILs; missing stamps and a ledger/log mismatch are INCONCLUSIVE. Also docs/station-client.md: FW_DIR "should", not "must", hold the blobs, matching the fallback. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 4 +-- tests/sta_client_onair.sh | 62 +++++++++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index d8afac9a..74abcab1 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -133,7 +133,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts; more `unconfirmed` verdicts than associations the AP never held - AP-STA-CONNECTED counts the held ones - FAILs, as a verdict on a held association); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts; a verdict on an association hostapd logged AP-STA-CONNECTED for before the verdict - ordered by the `at=` stamps against `hostapd -t` - FAILs, and a verdict without a stamp is INCONCLUSIVE); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | @@ -158,7 +158,7 @@ first line then reads `fault=1`. Exit 0 pass, 1 fail (including a station fault, exit 3, with its cause named), 2 inconclusive (rig refused, AP not up, route not through the TAP, the station exited or stalled before `sta_client up:`, station out of - time), 3 interrupted. `FW_DIR` (an MT7612U DUT) must hold the decompressed + time), 3 interrupted. `FW_DIR` (an MT7612U DUT) should hold the decompressed MT7612U blobs. Like the library, it falls back to `/lib/firmware/mediatek` and then `./firmware`; when none holds them (compressed `.bin.zst` copies only), the run is refused (exit 2) before it starts. diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 03de7c46..79df4573 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -661,6 +661,52 @@ dmesg_on_fail() { } cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; dmesg_on_fail; } +# Was each Unconfirmed verdict right? A verdict is right only for an +# association the AP never held. sta_client stamps each association and each +# verdict with `at=` in hostapd -t's wall-clock form, so every verdict is +# paired with the association it ended. If hostapd logged AP-STA-CONNECTED +# for our address between that association and its verdict, the AP held it: +# a false positive, FAIL. A CONNECTED logged after the verdict does not +# count. The re-join's own probe or authentication can release a held +# status, and hostapd then logs CONNECTED for the association the station +# has already abandoned. The race the other way - the AP stamping its +# CONNECTED a few ms before the station prints its own association line - +# can only hide a false positive, never invent one. Without stamps it cannot +# order anything: INCONCLUSIVE. $1 cell, $2 own address. +verdicts_scored() { + local unconf pairs a v held bad_n=0 n=0 + unconf=$(led "$1" 'unconfirmed') + [ "${unconf:-0}" -gt 0 ] 2>/dev/null || return 0 + pairs=$(awk ' + function at( i) { for (i = 1; i <= NF; i++) if ($i ~ /^at=/) return substr($i, 4); return "-" } + /station connected \(association/ { a = at() } + /station association unconfirmed:/ { print (a == "" ? "-" : a), at() } + ' "$OUT/sta_$1.log") + while read -r a v; do + [ -n "$a" ] || continue + n=$((n + 1)) + case "$a$v" in *-*) + inc "$1: verdict $n carries no at= stamp - cannot order it against hostapd's log" + return 0 ;; + esac + held=$(awk -v a="$a" -v b="$v" -v own="$2" ' + $0 ~ ("AP-STA-CONNECTED " own) { t = $1; sub(/:$/, "", t) + if (t + 0 >= a + 0 && t + 0 <= b + 0) c++ } + END { print c + 0 }' "$OUT/hostapd_$1.log") + if [ "$held" -gt 0 ]; then + bad_n=$((bad_n + 1)) + bad "$1: verdict $n (at $v) hit an association the AP held - hostapd logged AP-STA-CONNECTED $2 after it was made (at $a)" + fi + done </dev/null; then - if [ "$unconf" -gt $(( ${assoc:-0} - held )) ] 2>/dev/null; then - bad "open: ${unconf} unconfirmed verdict(s), but the AP held ${held} of the station's ${assoc:-?} associations - a verdict hit an association the AP held" - else - info "open: recovered: ${unconf} association(s) the AP did not hold were found unconfirmed and re-joined (${assoc:-?} associations, ${held} held by the AP, assoc_repeat=$(led open 'assoc_repeat'))" - fi - fi + verdicts_scored open "$own" local plain enc plain=$(led open 'plaintext rx'); enc=$(led open 'encrypted rx') if [ "${plain:-0}" -gt 0 ] && [ "${enc:-x}" = 0 ]; then From 00b811d5752b202896e729abb2df11b44079ac03 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:33:25 +0200 Subject: [PATCH 32/53] tests: sta_dut_take refuses only a live holder; hand-back waits for mt76x2u 904083c refused any MT7612U whose interface 0 had no driver. But src/UsbOpen.cpp detaches mt76x2u and never reattaches it, so any earlier IRadio session - a demo, a standalone sta_client - left the adapter driverless. The next harness was then refused, with a wrong "another run holds it" message. Hosts that blacklist mt76x2u, as docs/mt7612u.md recommends, were refused too. sta_dut_take now refuses only a live holder: - interface 0 bound to a driver other than mt76x2u (usbfs: a process has claimed it), or - a process with the adapter's /dev/bus/usb node open (sta_usb_holder: find over /proc/*/fd; the message names the PID and its comm). An unbound interface that nothing holds is taken as it is. The lib header now says what this cannot see: another harness between two of its gates. sta_dut_handback now waits, up to 5 s, for mt76x2u to bind again after the `authorized` toggle, when mt76x2u is loaded, so a back-to-back next run starts from a bound adapter. Checked off-device: sta_usb_holder finds a process holding a /dev/bus/usb node open, and nothing before or after it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_lib.sh | 82 ++++++++++++++++++++++++++++------------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 7f30c52f..b6334734 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -16,12 +16,15 @@ # cannot share - and kill each other through - one set of PID files. The # kernel drops the lock when the last holder exits, so there is no owner # record to race and no stale lock to reclaim. Two runs with different -# OUTs are NOT kept apart by this lock, and an adapter is held only -# while a devourer process has it open - between two gates nothing does. -# So sta_dut_take() refuses an MT7612U that mt76x2u does not hold: an -# interface with no driver bound is another run's, or a crashed one's, -# and taking it would hand it back (an `authorized` toggle) under that -# run. +# OUTs are NOT kept apart by this lock. sta_dut_take() refuses an +# MT7612U with a LIVE holder - an interface bound to a driver other than +# mt76x2u (usbfs: a process has claimed it), or a process with its +# /dev/bus/usb node open - so a run never toggles `authorized` under a +# devourer process. An unbound interface nothing holds is taken as it is: +# a devourer demo detaches mt76x2u and never reattaches it, and a host +# may blacklist mt76x2u (docs/mt7612u.md). What this cannot see is +# another harness between two of its gates, when nothing holds the +# adapter: give concurrent runs different adapters. # - Kill only what this run started, by recorded PID. No pattern kills, and # no PID read from a file an earlier run left behind: sta_pid_init() # removes stale PID files before anything is started. @@ -99,33 +102,53 @@ sta_is_mt7612u() { STA_DUT_TAKEN=no STA_DUT_ID="" -# Refuse a DUT_SYSFS that is not an MT7612U, or whose interface 0 mt76x2u -# does not hold (nothing bound: another run, or a crashed one, has it; usbfs: -# a devourer process has it open). Then unbind it from mt76x2u and require -# that interface 0 really has no driver afterwards - a failed unbind leaves -# the kernel driver owning the chip under the probe. Only a DUT that was taken -# is handed back, and only while DUT_SYSFS still reports the -# idVendor:idProduct:serial recorded here. +# The PID of a process that has the USB device at sysfs path $1 open +# (/dev/bus/usb/BBB/DDD), or nothing. Root sees every process's fds. +sta_usb_holder() { + _sta_b=$(cat "/sys/bus/usb/devices/$1/busnum" 2>/dev/null) + _sta_d=$(cat "/sys/bus/usb/devices/$1/devnum" 2>/dev/null) + [ -n "$_sta_b" ] && [ -n "$_sta_d" ] || return 0 + _sta_h=$(find /proc/[0-9]*/fd -maxdepth 1 \ + -lname "$(printf '/dev/bus/usb/%03d/%03d' "$_sta_b" "$_sta_d")" \ + 2>/dev/null | head -1) + [ -n "$_sta_h" ] || return 0 + _sta_h=${_sta_h#/proc/} + echo "${_sta_h%%/*}" +} + +# Refuse a DUT_SYSFS that is not an MT7612U, or that something live holds: +# interface 0 bound to a driver other than mt76x2u (usbfs: a process has +# claimed it), or a process with its device node open. Then unbind it from +# mt76x2u if that is bound, and require that interface 0 really has no +# driver afterwards - a failed unbind leaves the kernel driver owning the +# chip under the probe. An unbound interface nothing holds is taken as it is +# (an earlier devourer session left it so, or mt76x2u is not loaded). Only a +# DUT that was taken is handed back, and only while DUT_SYSFS still reports +# the idVendor:idProduct:serial recorded here. sta_dut_take() { if ! sta_is_mt7612u "$DUT_SYSFS"; then echo "refusing DUT_SYSFS=$DUT_SYSFS - not an MT7612U (0e8d:7612)" return 1 fi _sta_drv="/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" - if [ ! -e "$_sta_drv" ]; then - echo "refusing DUT_SYSFS=$DUT_SYSFS - interface 0 has no driver bound, so" \ - "another run (or a crashed one) holds it. Once nothing uses it," \ - "re-enumerate it (authorized 0 then 1) so mt76x2u (loaded) binds again" - return 1 + if [ -e "$_sta_drv" ]; then + _sta_drv=$(basename "$(readlink -f "$_sta_drv")") + if [ "$_sta_drv" != mt76x2u ]; then + echo "refusing DUT_SYSFS=$DUT_SYSFS - interface 0 is held by" \ + "$_sta_drv (usbfs: a process has claimed it)" + return 1 + fi fi - _sta_drv=$(basename "$(readlink -f "$_sta_drv")") - if [ "$_sta_drv" != mt76x2u ]; then - echo "refusing DUT_SYSFS=$DUT_SYSFS - interface 0 is held by $_sta_drv," \ - "not mt76x2u" + _sta_pid=$(sta_usb_holder "$DUT_SYSFS") + if [ -n "$_sta_pid" ]; then + echo "refusing DUT_SYSFS=$DUT_SYSFS - PID $_sta_pid" \ + "($(cat "/proc/$_sta_pid/comm" 2>/dev/null)) has its USB device open" return 1 fi - echo "$DUT_SYSFS:1.0" > /sys/bus/usb/drivers/mt76x2u/unbind 2>/dev/null - sleep 2 + if [ "$_sta_drv" = mt76x2u ]; then + echo "$DUT_SYSFS:1.0" > /sys/bus/usb/drivers/mt76x2u/unbind 2>/dev/null + sleep 2 + fi if [ -e "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" ]; then echo "could not free DUT_SYSFS=$DUT_SYSFS: interface 0 is still bound to" \ "$(basename "$(readlink -f "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver")")" @@ -148,6 +171,17 @@ sta_dut_handback() { echo 0 > "/sys/bus/usb/devices/$DUT_SYSFS/authorized" 2>/dev/null sleep 2 echo 1 > "/sys/bus/usb/devices/$DUT_SYSFS/authorized" 2>/dev/null + # Back to bound before the next run starts: wait, up to 5 s, for mt76x2u + # to probe it again - when mt76x2u is loaded at all. + [ -d /sys/bus/usb/drivers/mt76x2u ] || return 0 + _sta_t=0 + until [ -e "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" ]; do + if [ "$_sta_t" -ge 50 ]; then + echo "DUT_SYSFS=$DUT_SYSFS: mt76x2u did not bind again within 5 s" + return 0 + fi + sleep 0.1; _sta_t=$((_sta_t + 1)) + done } # PID files live in $OUT as .pid_. Every name a script uses is listed From ab205e3220784e6d46075f83e44073d3895b10ff Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:34:12 +0200 Subject: [PATCH 33/53] mt7612u: staid reads its poison back and retries a missed clear; RX log says "at RX start" - gate_staid's case 6 wrote the managed value into MT_RX_FILTR_CFG before the clear-after-drop check, but never read it back. A poison that did not stick made the check prove nothing and still pass. It is now read back. If it did not stick, the check is a SKIP (counted as a failure, like the gate's other SKIPs) and the clear still runs as cleanup. - With Stop()'s clear gone (master's again), a failed clear in case 6 would end the gate with the managed filter in force. A clear that missed is now retried once, and a second miss is reported with the register value read. - StartRxLoop's filter word is the filter at RX start. The usual station arms after it (IRadio.h), so its log says so, and SetStationIdentity now logs the switch to the managed filter. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- src/mt7612u/Mt7612uRadio.cpp | 7 +++++-- src/mt7612u/tools/bringup.cpp | 24 +++++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/mt7612u/Mt7612uRadio.cpp b/src/mt7612u/Mt7612uRadio.cpp index 17b80ad1..c18cfc2f 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -394,7 +394,9 @@ void Mt7612uRadio::InitWrite(SelectedChannel channel) { void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { struct mt7612u_dev *mac_failed = nullptr; /* An armed station keeps its managed filter: the monitor request below is - * recorded, not installed (mt7612u_set_monitor_rx). The log says which. */ + * recorded, not installed (mt7612u_set_monitor_rx). The log says which + * filter is in force AT RX START; the usual station arms after this + * (IRadio.h), and SetStationIdentity logs the switch. */ bool station_filter = false; { /* The WHOLE prologue, arming through the failure teardown, under the same @@ -464,7 +466,7 @@ void Mt7612uRadio::StartRxLoop(Action_ParsedRadioPacket packetProcessor) { if (mac_failed) throw std::runtime_error("MT7612U MAC start failed"); - _logger->info("MT7612U RX on channel {} ({} receive filter)", + _logger->info("MT7612U RX on channel {} ({} receive filter at RX start)", _channel.Channel, station_filter ? "managed station" : "monitor"); /* THE consumer. The C layer parses on its own event thread and enqueues; the @@ -1051,6 +1053,7 @@ bool Mt7612uRadio::SetStationIdentity(const devourer::MacAddr &own, return false; if (mt7612u_set_station_identity(_dev, own.data(), bssid.data()) != 0) return false; + _logger->info("MT7612U station identity armed: managed receive filter"); /* The arm covers RECEIVE and auto-ACK only. What a station transmits is * the caller's: its unicast (management, EAPOL, data) must request an ACK - * build_stream_radiotap(mode, false), since the default stream radiotap is diff --git a/src/mt7612u/tools/bringup.cpp b/src/mt7612u/tools/bringup.cpp index a753059c..368c2169 100644 --- a/src/mt7612u/tools/bringup.cpp +++ b/src/mt7612u/tools/bringup.cpp @@ -5210,12 +5210,26 @@ static int gate_staid(void) /* After a drop the clear re-writes the pre-arm filter and verifies * it. The drop has already written that value, so a clear that * wrote nothing would pass a bare read-back: poison the register - * first, so only a clear that writes it can pass. */ + * first, so only a clear that writes it can pass - and only once the + * poison reads back, or the check would prove nothing. */ mt_wr(&dev, MT_RX_FILTR_CFG, MT_RX_FILTR_CFG_MANAGED); - CHK(mt7612u_clear_station_identity(&dev) == 0 && - staid_filtr_is(mon), - "clear after the drop re-writes the monitor receive filter " - "(register poisoned first)"); + if (staid_filtr_is(MT_RX_FILTR_CFG_MANAGED)) { + CHK(mt7612u_clear_station_identity(&dev) == 0 && + staid_filtr_is(mon), + "clear after the drop re-writes the monitor receive " + "filter (register poisoned first)"); + } else { + printf(" SKIP the poison write did not read back - the " + "clear-after-drop check would prove nothing\n"); + fail++; + mt7612u_clear_station_identity(&dev); + } + /* Stop() does not clear (master's), so a clear that missed is + * retried here, once, and a second miss is said. */ + if (!staid_filtr_is(mon) && + mt7612u_clear_station_identity(&dev) != 0) + printf(" NOTE a second clear missed too - the receive " + "filter is left as read above\n"); } else { printf(" SKIP could not set up case 6\n"); fail++; From d760103f5e140135ff91000af9af9d66976d3f4d Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:34:21 +0200 Subject: [PATCH 34/53] tests: sta_client_onair - say why the preflight's ./firmware is sta_client's The library resolves ./firmware against sta_client's working directory, and the preflight against the harness's. They are the same directory: the script never changes it, and sta_up starts sta_client from it. The comment now says so. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client_onair.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 79df4573..960bf96a 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -239,7 +239,9 @@ DUT_PID="0x${dut_have#*:}" # The firmware the library will load: the first of FW_DIR, # /lib/firmware/mediatek and ./firmware that holds both blobs - the order and # the test of resolve_fw_dir (src/mt7612u/Mt7612uRadio.cpp). That one must be -# readable; with none, the refusal names FW_DIR. +# readable; with none, the refusal names FW_DIR. ./firmware is relative to +# the working directory, the same for both: this script never changes it, and +# sta_up starts sta_client from it. if [ "$DUT_KIND" = mt7612u ]; then fw_pick="" for d in "$FW_DIR" /lib/firmware/mediatek firmware; do From aa97923c8893e9dc418e1882f0baad85e869e545 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 20:54:33 +0200 Subject: [PATCH 35/53] tests: sta_client_onair - the verdict check proves its stamp ordering first verdicts_scored assumed hostapd stamps a held association's AP-STA-CONNECTED after the station's own at=. Nothing establishes that. hostapd logs CONNECTED microseconds after accepting the association, the station prints its stamp milliseconds either side, and which comes first depends on the rig. When hostapd is first, a held association's CONNECTED falls before at=, and a false positive scored INFO "recovered" - a silent pass. The cell now uses its own positive control. Every association that got no verdict, and whose CONNECTED the log shows, must have that CONNECTED at or after its at=. If one is earlier, the ordering does not hold on this rig and the verdicts are INCONCLUSIVE, not cleared; the same goes when there is no such association to check. A CONNECTED inside a verdict's window still FAILs. Also: - Stamps must match sec.usec with exactly six digits, and an association's must not follow its verdict's; otherwise INCONCLUSIVE. Stamps are compared as two integers, since a 6-decimal epoch is at the edge of double precision. - A missing ledger while the log shows verdicts is INCONCLUSIVE. - The CONNECTED match is on exact fields, so a MAC that merely contains ours does not count. - The hostapd log is told apart by FILENAME: FNR == NR fails when that log is empty. Checked on the review's 13 fixtures plus 5 more. held_early_race, garbage_a, garbage_v, v_before_a, no control and no ledger are INCONCLUSIVE; held-inside, multi (verdict 2), epoch_1us and the -dd double stamp FAIL; a recovery whose control is in order is INFO. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client_onair.sh | 133 +++++++++++++++++++++++++++----------- 1 file changed, 95 insertions(+), 38 deletions(-) diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 960bf96a..2f45635f 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -665,48 +665,105 @@ cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; dmesg_on_fail; # Was each Unconfirmed verdict right? A verdict is right only for an # association the AP never held. sta_client stamps each association and each -# verdict with `at=` in hostapd -t's wall-clock form, so every verdict is -# paired with the association it ended. If hostapd logged AP-STA-CONNECTED -# for our address between that association and its verdict, the AP held it: -# a false positive, FAIL. A CONNECTED logged after the verdict does not -# count. The re-join's own probe or authentication can release a held -# status, and hostapd then logs CONNECTED for the association the station -# has already abandoned. The race the other way - the AP stamping its -# CONNECTED a few ms before the station prints its own association line - -# can only hide a false positive, never invent one. Without stamps it cannot -# order anything: INCONCLUSIVE. $1 cell, $2 own address. +# verdict with `at=` (wall clock, hostapd -t's sec.usec form), so a verdict +# is paired with the association it ended, and FAILs if hostapd logged +# AP-STA-CONNECTED for our address between the two. One logged after the +# verdict does not count: the re-join's own probe or authentication can +# release a held status, and hostapd then logs CONNECTED for the association +# the station has already abandoned. +# +# THE ORDERING IS CHECKED, NOT ASSUMED. hostapd logs CONNECTED microseconds +# after it accepts an association, and the station prints its own stamp a +# few ms either side of that, depending on the rig. If hostapd can stamp +# first, a held association's CONNECTED falls before `at=` and the check +# above would pass a false positive. So every association that got NO +# verdict and whose CONNECTED the log shows is the positive control: its +# CONNECTED must not precede its `at=`. If one does, or there is no such +# association to check, the verdicts cannot be cleared: INCONCLUSIVE rather +# than INFO. A CONNECTED inside a verdict's window still FAILs either way. +# Missing, malformed or out-of-order stamps, or a ledger that is missing or +# disagrees with the log, are INCONCLUSIVE too. $1 cell, $2 own address. verdicts_scored() { - local unconf pairs a v held bad_n=0 n=0 + local unconf res n ctrl tag k a v unconf=$(led "$1" 'unconfirmed') - [ "${unconf:-0}" -gt 0 ] 2>/dev/null || return 0 - pairs=$(awk ' - function at( i) { for (i = 1; i <= NF; i++) if ($i ~ /^at=/) return substr($i, 4); return "-" } - /station connected \(association/ { a = at() } - /station association unconfirmed:/ { print (a == "" ? "-" : a), at() } - ' "$OUT/sta_$1.log") - while read -r a v; do - [ -n "$a" ] || continue - n=$((n + 1)) - case "$a$v" in *-*) - inc "$1: verdict $n carries no at= stamp - cannot order it against hostapd's log" - return 0 ;; - esac - held=$(awk -v a="$a" -v b="$v" -v own="$2" ' - $0 ~ ("AP-STA-CONNECTED " own) { t = $1; sub(/:$/, "", t) - if (t + 0 >= a + 0 && t + 0 <= b + 0) c++ } - END { print c + 0 }' "$OUT/hostapd_$1.log") - if [ "$held" -gt 0 ]; then - bad_n=$((bad_n + 1)) - bad "$1: verdict $n (at $v) hit an association the AP held - hostapd logged AP-STA-CONNECTED $2 after it was made (at $a)" - fi - done < Date: Wed, 7 Oct 2026 20:54:33 +0200 Subject: [PATCH 36/53] tests: sta_client pins its at= stamps; docs name the backoff's limits - New headless cell test_the_stamps_are_wall_clock_microseconds. It captures stderr across an association and a verdict, and requires both lines to carry at=.<6 digits> within 5 s of time(NULL). CLOCK_MONOTONIC, millisecond precision, and dropping at= from either line are now killed. - The verdict path no longer re-checks the BSS before counting a strike; on_association already resets the count for a new BSS. - docs/station-client.md: - quotes the association and verdict lines with at=; - describes the open cell's ordering check; - adds two limits: the backoff remembers one BSS, so a station alternating between two BSSes of one ESS resets it each time (a verdict per cycle of the 5 s window, the re-join backoff and the handshake); and sta_dut_handback rebinds mt76x2u even when the run took the DUT unbound. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 17 +++++++++-- tests/sta_client.cpp | 4 +-- tests/sta_client_selftest.inc | 56 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 74abcab1..479746be 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -60,8 +60,9 @@ has printed, and separates "heard nothing", "heard another BSS" and "our AP refused us". Its first line is the state the run ENDED in, before the teardown's leave: `Connected`, or `Failed reason=` for a run that gave up. While it runs, the station also logs each association -(`station connected (association N)`) and each failure (`station link lost: -` or `station join failed: `). +(`station connected (association N) at=`), each unconfirmed +verdict (`station association unconfirmed: ... at=`) and each +failure (`station link lost: ` or `station join failed: `). Re-join policy: after a lost link or a failed join the station waits `DEVOURER_STA_BACKOFF_MS` and joins again, for as long as the run lasts. @@ -133,7 +134,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts; a verdict on an association hostapd logged AP-STA-CONNECTED for before the verdict - ordered by the `at=` stamps against `hostapd -t` - FAILs, and a verdict without a stamp is INCONCLUSIVE); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts; a verdict on an association hostapd logged AP-STA-CONNECTED for before the verdict - ordered by the `at=` stamps against `hostapd -t` - FAILs. A verdict is cleared only once the ordering itself is checked: every association without a verdict whose CONNECTED is logged must show it at or after its own `at=`. If one is earlier, or there is none to check, or a stamp or the ledger is missing or malformed, the verdicts are INCONCLUSIVE); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | @@ -205,3 +206,13 @@ AP's. - A pairwise rekey can cost one received frame (802.11-2016 12.7.6.5); the note is at the `ccmp_decrypt` call in `rx_frame()`. - The host stack owns ARP, IP and DHCP on the TAP. +- The confirmation backoff remembers one BSS (`g_strike_bss`). A station + that alternates between two BSSes of one ESS resets it at each switch, so + a host asking a dead peer can then cost a verdict and a re-join every + cycle: the 5 s window (at least), the re-join backoff + (`DEVOURER_STA_BACKOFF_MS`, 1 s by default) and the + handshake. `select_open` normally keeps to one BSS, so this needs the + BSSes to swap rank between joins. +- The MT7612U harnesses' hand-back (`sta_dut_handback`) re-enumerates the + DUT so mt76x2u binds again, even when the run took it with no driver bound + (as an earlier devourer session leaves it). diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 5dd3aaec..33fa76f4 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -858,9 +858,7 @@ uint8_t supervise(uint32_t now) { g_judge = false; /* One more consecutive verdict on this BSS: the next association on it * waits longer before it is judged. */ - if (g_strikes && std::memcmp(g_strike_bss, g_sm.bssid(), 6) != 0) - g_strikes = 0; - g_strikes++; + g_strikes++; /* on_association already reset it for a new BSS */ std::memcpy(g_strike_bss, g_sm.bssid(), 6); g_unconfirmed_lost.fetch_add(1); g_sm.link_lost(); diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 67962d0c..00da6e34 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -1617,6 +1617,61 @@ void test_a_truncated_tcp_header_is_not_read() { "a TCP segment too short to hold its flags is not a question"); } +/* THE at= STAMPS are what the on-air harness orders against hostapd -t: the + * wall clock (CLOCK_REALTIME), seconds and exactly six fractional digits, on + * the association line AND the verdict line. Read back off stderr. */ +bool stamp_ok(const std::string& line, const char* what) { + const size_t p = line.find(" at="); + if (p == std::string::npos) { + std::fprintf(stdout, " (%s: no at= in '%s')\n", what, line.c_str()); + return false; + } + const std::string t = line.substr(p + 4); + const size_t dot = t.find('.'); + size_t end = dot == std::string::npos ? 0 : dot + 1; + while (end < t.size() && t[end] >= '0' && t[end] <= '9') end++; + const bool digits = dot != std::string::npos && dot > 0 && + t.find_first_not_of("0123456789") == dot && + end - dot - 1 == 6 && end == t.size(); + const long long sec = digits ? std::atoll(t.c_str()) : 0; + const long long now = (long long)time(nullptr); + const bool wall = sec >= now - 5 && sec <= now + 5; + if (!digits || !wall) + std::fprintf(stdout, " (%s: at=%s - %s)\n", what, t.c_str(), + !digits ? "not sec.usec with 6 digits" : "not the wall clock"); + return digits && wall; +} + +void test_the_stamps_are_wall_clock_microseconds() { + reset_all(); + FILE* cap = std::tmpfile(); + if (!cap) { check(false, "tmpfile for the stderr capture"); return; } + std::fflush(stderr); + const int saved = ::dup(2); + ::dup2(fileno(cap), 2); + Ap ap; + open_link_with_uplink(ap, (int)kConfirmUplink); + supervise(10); + supervise(10 + kConfirmMs); + std::fflush(stderr); + ::dup2(saved, 2); + ::close(saved); + std::rewind(cap); + std::string assoc, verdict; + char buf[512]; + while (std::fgets(buf, sizeof buf, cap)) { + std::string l(buf); + while (!l.empty() && (l.back() == '\n' || l.back() == '\r')) l.pop_back(); + if (l.find("station connected (association") != std::string::npos) assoc = l; + if (l.find("station association unconfirmed:") != std::string::npos) verdict = l; + } + std::fclose(cap); + check(!assoc.empty() && stamp_ok(assoc, "association"), + "the association line carries at="); + check(!verdict.empty() && stamp_ok(verdict, "verdict"), + "the verdict line carries at="); +} + /* A QUESTION THE FULL QUEUE DROPPED IS NOT COUNTED - it never reached the * AP, so its missing answer says nothing - and neither is a nudge. */ void test_a_dropped_frame_is_not_counted() { @@ -2580,6 +2635,7 @@ int self_test() { selftest::test_a_struck_bss_backs_off(); selftest::test_a_group_frame_does_not_reset_the_backoff(); selftest::test_a_truncated_tcp_header_is_not_read(); + selftest::test_the_stamps_are_wall_clock_microseconds(); selftest::test_the_strike_is_per_bss(); selftest::test_a_dropped_frame_is_not_counted(); selftest::test_an_accepted_association_nudges_the_ap(); From 6879f70deee93dd60eb9c9cf8b88a21e8a3c28b1 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 21:10:05 +0200 Subject: [PATCH 37/53] tests: sta_client_onair - every CONNECTED is accounted for, and a FAIL comes first verdicts_scored's positive control took the LATEST CONNECTED in [previous verdict, next association]. So the next association's CONNECTED satisfied an earlier row, and that row's own early CONNECTED was never compared. Flash's input - association 1 at 1005, association 2 at 1010, a verdict at 1015, CONNECTEDs at 1004.9 and 1009.9 - scored INFO. Each association now owns a window: its at= to its verdict, or to the next association. Every own CONNECTED in hostapd's log is accounted for: - inside a verdict's window: that verdict FAILs; - the first one inside a window without a verdict: that association's own, the positive control - counted once per association, so one CONNECTED cannot satisfy several rows; - outside every window, or a second one in a window without a verdict: ignored if hostapd logged DISCONNECTED for us before the next association (an episode the re-join ended - the late release); otherwise it was stamped before an association's at=, the ordering premise fails on this rig, and the verdicts are INCONCLUSIVE. Flash's input is INCONCLUSIVE. It is not a FAIL: once the rig stamps first, a CONNECTED before an at= cannot be pinned to one association. Also: - BAD is tested before STAMP, so a malformed stamp on one row no longer hides a FAIL on another. - Records shorter than two fields are skipped ($(NF-1) on a blank record is fatal in gawk). - A missing parse result while the station log shows verdicts is INCONCLUSIVE. - docs/station-client.md describes the windowed pairing. 24 fixtures, run under gawk: - FAIL: held inside, multi (verdict 2 only), epoch 1 us, -dd double stamp, a FAIL beside a garbage stamp, blank hostapd lines. - INFO: late release in order, two controls, and unheld/held/unheld. - INCONCLUSIVE: Flash's shift, the held-early race, an early control, unheld/held-early/unheld, no control, missing/garbage stamps, a verdict before its association, a ledger mismatch, a missing ledger. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 2 +- tests/sta_client_onair.sh | 108 +++++++++++++++++++++++--------------- 2 files changed, 66 insertions(+), 44 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 479746be..805f504c 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -134,7 +134,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts; a verdict on an association hostapd logged AP-STA-CONNECTED for before the verdict - ordered by the `at=` stamps against `hostapd -t` - FAILs. A verdict is cleared only once the ordering itself is checked: every association without a verdict whose CONNECTED is logged must show it at or after its own `at=`. If one is earlier, or there is none to check, or a stamp or the ledger is missing or malformed, the verdicts are INCONCLUSIVE); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts. Each association owns the window from its `at=` to its verdict, or to the next association; a verdict FAILs when hostapd (`hostapd -t`) logged AP-STA-CONNECTED for our address inside its window, whatever else the log shows. A verdict is cleared only once the stamp ordering is checked: every CONNECTED must be accounted for - the first one in a window without a verdict is that association's own (the positive control, counted once per association), and one outside every window must be followed by a DISCONNECTED before the next association (an episode the re-join ended). Any other CONNECTED - stamped before the next association's `at=` - or no control at all leaves the verdicts INCONCLUSIVE, and so does a missing, malformed or out-of-order stamp, a missing or mismatched ledger, or logs that could not be parsed); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 2f45635f..5eb5f9b7 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -665,24 +665,32 @@ cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; dmesg_on_fail; # Was each Unconfirmed verdict right? A verdict is right only for an # association the AP never held. sta_client stamps each association and each -# verdict with `at=` (wall clock, hostapd -t's sec.usec form), so a verdict -# is paired with the association it ended, and FAILs if hostapd logged -# AP-STA-CONNECTED for our address between the two. One logged after the -# verdict does not count: the re-join's own probe or authentication can -# release a held status, and hostapd then logs CONNECTED for the association -# the station has already abandoned. +# verdict with `at=` (wall clock, hostapd -t's sec.usec form). Each +# association owns the window from its `at=` to its verdict (or to the next +# association), and a verdict FAILs if hostapd logged AP-STA-CONNECTED for +# our address inside its association's window. # # THE ORDERING IS CHECKED, NOT ASSUMED. hostapd logs CONNECTED microseconds # after it accepts an association, and the station prints its own stamp a -# few ms either side of that, depending on the rig. If hostapd can stamp -# first, a held association's CONNECTED falls before `at=` and the check -# above would pass a false positive. So every association that got NO -# verdict and whose CONNECTED the log shows is the positive control: its -# CONNECTED must not precede its `at=`. If one does, or there is no such -# association to check, the verdicts cannot be cleared: INCONCLUSIVE rather -# than INFO. A CONNECTED inside a verdict's window still FAILs either way. -# Missing, malformed or out-of-order stamps, or a ledger that is missing or -# disagrees with the log, are INCONCLUSIVE too. $1 cell, $2 own address. +# few ms either side, depending on the rig. If hostapd can stamp first, a +# held association's CONNECTED falls before its `at=`, outside the window, +# and a false positive would pass. So every CONNECTED is accounted for: +# - inside a verdict's window: that verdict FAILs; +# - the first one inside a window without a verdict: that association's +# own, stamped after its `at=` - the positive control, counted once per +# association; +# - outside every window, or a second one in a window without a verdict: +# either an episode that ended (hostapd logs DISCONNECTED before the next +# association: the re-join's own probe can release a held status after +# the verdict, and hostapd then connects and drops the abandoned +# association) - ignored - or a CONNECTED stamped BEFORE the next +# association's `at=`, which proves this rig can stamp first. +# The last case, or no control at all, leaves the verdicts INCONCLUSIVE +# rather than cleared - it cannot tell which association such a CONNECTED +# belongs to. A FAIL is reported first, whatever else the log shows; then +# missing, malformed or out-of-order stamps, a ledger that is missing or +# disagrees with the log, or a log that could not be parsed, are +# INCONCLUSIVE. $1 cell, $2 own address. verdicts_scored() { local unconf res n ctrl tag k a v unconf=$(led "$1" 'unconfirmed') @@ -701,49 +709,59 @@ verdicts_scored() { split(x, xs, "."); split(y, ys, ".") return xs[1] + 0 < ys[1] + 0 || (xs[1] + 0 == ys[1] + 0 && xs[2] + 0 <= ys[2] + 0) } + function lt(x, y) { return !le(y, x) } FILENAME == ARGV[1] { # the hostapd log; FNR == NR fails when it is empty - if ($(NF - 1) == "AP-STA-CONNECTED" && $NF == own) { - t = $1; sub(/:$/, "", t); if (ok(t)) c[++nc] = t + if (NF >= 2 && $NF == own && + ($(NF - 1) == "AP-STA-CONNECTED" || $(NF - 1) == "AP-STA-DISCONNECTED")) { + t = $1; sub(/:$/, "", t) + if (ok(t)) { ne++; ET[ne] = t; EK[ne] = ($(NF - 1) == "AP-STA-CONNECTED") ? "C" : "D" } } next } - { na++; A[na] = $1; V[na] = $2 } + NF >= 2 { na++; A[na] = $1; V[na] = $2 } END { - prev = "0.000000" for (i = 1; i <= na; i++) { - a = A[i]; v = V[i] - if (v != "-") { - nv++ - if (!ok(a) || !ok(v) || !le(a, v)) { print "STAMP", nv; if (ok(v)) prev = v; continue } - for (j = 1; j <= nc; j++) - if (le(a, c[j]) && le(c[j], v)) { print "BAD", nv, a, v; break } - prev = v - } else { - if (!ok(a)) { print "STAMP", 0; continue } - hi = (i < na && ok(A[i + 1])) ? A[i + 1] : "" - last = "" - for (j = 1; j <= nc; j++) - if (c[j] != prev && le(prev, c[j]) && (hi == "" || le(c[j], hi))) last = c[j] - if (last != "") { nctl++; if (!le(a, last)) print "EARLY", a, last } + if (V[i] != "-") nv++ + if (!ok(A[i]) || (V[i] != "-" && (!ok(V[i]) || lt(V[i], A[i])))) { stamp = 1; continue } + if (i > 1 && ok(A[i - 1]) && lt(A[i], (V[i - 1] != "-" && ok(V[i - 1])) ? V[i - 1] : A[i - 1])) stamp = 1 + if (V[i] != "-") + for (j = 1; j <= ne; j++) + if (EK[j] == "C" && le(A[i], ET[j]) && le(ET[j], V[i])) { print "BAD", nv, A[i], V[i]; break } + } + if (stamp) print "STAMP" + else for (j = 1; j <= ne; j++) { + if (EK[j] != "C") continue + t = ET[j]; w = 0; nexta = "" + for (i = 1; i <= na; i++) { + e = (V[i] != "-") ? V[i] : (i < na ? A[i + 1] : "") + if (le(A[i], t) && (e == "" || lt(t, e) || (V[i] != "-" && le(t, e)))) { w = i; break } } + for (i = 1; i <= na; i++) if (lt(t, A[i])) { nexta = A[i]; break } + if (w && V[w] != "-") continue # a BAD above + if (w && !owned[w]) { owned[w] = 1; nctl++; continue } + closed = j < ne && EK[j + 1] == "D" && (nexta == "" || lt(ET[j + 1], nexta)) + if (!closed) print "EARLY", (nexta == "" ? "-" : nexta), t } print "N", nv + 0 print "CTRL", nctl + 0 }' "$OUT/hostapd_$1.log" -) n=$(printf '%s\n' "$res" | sed -n 's/^N //p') ctrl=$(printf '%s\n' "$res" | sed -n 's/^CTRL //p') - if [ -z "$unconf" ]; then - [ "${n:-0}" = 0 ] || - inc "$1: the log shows $n unconfirmed verdict(s) but the ledger is missing - cannot check them" + if [ -z "$n" ]; then + if grep -q 'station association unconfirmed:' "$OUT/sta_$1.log" 2>/dev/null || + [ "${unconf:-0}" != 0 ]; then + inc "$1: could not parse the station and hostapd logs - cannot check the unconfirmed verdicts" + fi return 0 fi - [ "$unconf" = 0 ] && [ "${n:-0}" = 0 ] && return 0 - if [ "$unconf" != "${n:-0}" ]; then - inc "$1: the ledger counts $unconf unconfirmed verdict(s) but the log shows ${n:-0} - cannot pair them" + if [ -z "$unconf" ]; then + [ "$n" = 0 ] || + inc "$1: the log shows $n unconfirmed verdict(s) but the ledger is missing - cannot check them" return 0 fi - if printf '%s\n' "$res" | grep -q '^STAMP'; then - inc "$1: an association or verdict line has a missing, malformed or out-of-order at= stamp - cannot order the verdicts against hostapd's log" + [ "$unconf" = 0 ] && [ "$n" = 0 ] && return 0 + if [ "$unconf" != "$n" ]; then + inc "$1: the ledger counts $unconf unconfirmed verdict(s) but the log shows $n - cannot pair them" return 0 fi if printf '%s\n' "$res" | grep -q '^BAD'; then @@ -755,12 +773,16 @@ $res EOF return 0 fi + if printf '%s\n' "$res" | grep -q '^STAMP'; then + inc "$1: an association or verdict line has a missing, malformed or out-of-order at= stamp - cannot order the verdicts against hostapd's log" + return 0 + fi if printf '%s\n' "$res" | grep -q '^EARLY'; then - inc "$1: $unconf unconfirmed verdict(s) not cleared - on this rig hostapd stamped a held association's AP-STA-CONNECTED before the station's own at= ($(printf '%s\n' "$res" | sed -n 's/^EARLY \([^ ]*\) \([^ ]*\)$/CONNECTED \2 < at= \1/p' | head -1)), so a CONNECTED that precedes at= cannot be told from an earlier association's" + inc "$1: $unconf unconfirmed verdict(s) not cleared - hostapd logged an AP-STA-CONNECTED that belongs to no association window and was not dropped before the next association ($(printf '%s\n' "$res" | sed -n 's/^EARLY \([^ ]*\) \([^ ]*\)$/CONNECTED \2, next at= \1/p' | head -1)): this rig can stamp a held association's CONNECTED before the station's own at=, so the verdicts cannot be cleared" return 0 fi if [ "${ctrl:-0}" = 0 ]; then - inc "$1: $unconf unconfirmed verdict(s) not cleared - no association without a verdict shows an AP-STA-CONNECTED to check the stamp ordering against" + inc "$1: $unconf unconfirmed verdict(s) not cleared - no association without a verdict shows its own AP-STA-CONNECTED to check the stamp ordering against" return 0 fi info "$1: recovered: $unconf association(s) the AP did not hold were found unconfirmed and re-joined (none logged AP-STA-CONNECTED before its verdict; ordering checked against $ctrl held association(s); $(led "$1" 'associations') associations, assoc_repeat=$(led "$1" 'assoc_repeat'))" From e436a0c7bcc08dd3943bd432c7f16f4bb8943da5 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 21:10:05 +0200 Subject: [PATCH 38/53] tests: sta_client's stamp cell checks its redirect and allows a minute The at= cell redirected stderr without checking dup()/dup2(), and required the stamp within 5 s of time(), which a loaded or sanitised CI box can miss. The redirect and the restore are now checked. The window is +/-60 s: the point is wall clock rather than monotonic, which differ by about the epoch. The 6-digit check is unchanged, and the four stamp mutations are still killed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/sta_client_selftest.inc | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 00da6e34..f6e77d8b 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -1618,8 +1618,9 @@ void test_a_truncated_tcp_header_is_not_read() { } /* THE at= STAMPS are what the on-air harness orders against hostapd -t: the - * wall clock (CLOCK_REALTIME), seconds and exactly six fractional digits, on - * the association line AND the verdict line. Read back off stderr. */ + * wall clock (CLOCK_REALTIME, within a minute of time()), seconds and exactly + * six fractional digits, on the association line AND the verdict line. Read + * back off stderr. */ bool stamp_ok(const std::string& line, const char* what) { const size_t p = line.find(" at="); if (p == std::string::npos) { @@ -1635,7 +1636,10 @@ bool stamp_ok(const std::string& line, const char* what) { end - dot - 1 == 6 && end == t.size(); const long long sec = digits ? std::atoll(t.c_str()) : 0; const long long now = (long long)time(nullptr); - const bool wall = sec >= now - 5 && sec <= now + 5; + /* Generous: the point is the wall clock rather than the monotonic one, + * which is off by about the epoch (~1.7e9 s), not a tight bound that a + * loaded or sanitised build could miss. */ + const bool wall = sec >= now - 60 && sec <= now + 60; if (!digits || !wall) std::fprintf(stdout, " (%s: at=%s - %s)\n", what, t.c_str(), !digits ? "not sec.usec with 6 digits" : "not the wall clock"); @@ -1648,14 +1652,20 @@ void test_the_stamps_are_wall_clock_microseconds() { if (!cap) { check(false, "tmpfile for the stderr capture"); return; } std::fflush(stderr); const int saved = ::dup(2); - ::dup2(fileno(cap), 2); + if (saved < 0 || ::dup2(fileno(cap), 2) < 0) { + if (saved >= 0) ::close(saved); + std::fclose(cap); + check(false, "redirect stderr for the capture"); + return; + } Ap ap; open_link_with_uplink(ap, (int)kConfirmUplink); supervise(10); supervise(10 + kConfirmMs); std::fflush(stderr); - ::dup2(saved, 2); + const bool restored = ::dup2(saved, 2) >= 0; ::close(saved); + check(restored, "stderr restored after the capture"); std::rewind(cap); std::string assoc, verdict; char buf[512]; From 45266808336d1cd05b4555479bb2655796deb56d Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 21:19:57 +0200 Subject: [PATCH 39/53] tests: sta_client_onair - a verdict that began while hostapd held us is not cleared A window without a verdict and without a CONNECTED of its own could claim the next association's early-stamped CONNECTED as its control. That happens when association k-1 was never held and ended in a deauthentication re-join, which leaves no verdict. Association k's false-positive verdict then had an empty window and scored INFO. New conservative rule: for each verdicted association, if the last hostapd event for our address before its at= is a CONNECTED with no DISCONNECTED after it, hostapd still held us when the association began. That CONNECTED may be its own, stamped early, so the verdicts are INCONCLUSIVE. The clean recovered path - late release, then DISCONNECTED, then the new association's CONNECTED after its at= - stays INFO. Also: - Our address is matched case-insensitively as the token after the event name, not as $NF (newer hostapd appends auth_alg=), and only on the cell's AP interface. - An empty at= is a stamp error. - The comment records two fail-safe quirks: mawk reading an empty hostapd log as station rows, and duplicated lines. 30 fixtures; gawk, mawk and busybox awk agree on every one. The three hole shapes are INCONCLUSIVE. Also: an upper-case MAC with a trailing auth_alg= FAILs; our MAC on another interface is ignored; an empty at= is INCONCLUSIVE; every earlier fixture is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 2 +- tests/sta_client_onair.sh | 43 +++++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 805f504c..b9d3a35e 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -134,7 +134,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts. Each association owns the window from its `at=` to its verdict, or to the next association; a verdict FAILs when hostapd (`hostapd -t`) logged AP-STA-CONNECTED for our address inside its window, whatever else the log shows. A verdict is cleared only once the stamp ordering is checked: every CONNECTED must be accounted for - the first one in a window without a verdict is that association's own (the positive control, counted once per association), and one outside every window must be followed by a DISCONNECTED before the next association (an episode the re-join ended). Any other CONNECTED - stamped before the next association's `at=` - or no control at all leaves the verdicts INCONCLUSIVE, and so does a missing, malformed or out-of-order stamp, a missing or mismatched ledger, or logs that could not be parsed); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts. Each association owns the window from its `at=` to its verdict, or to the next association; a verdict FAILs when hostapd (`hostapd -t`) logged AP-STA-CONNECTED for our address inside its window, whatever else the log shows. A verdict is cleared only once the stamp ordering is checked: every CONNECTED must be accounted for - the first one in a window without a verdict is that association's own (the positive control, counted once per association), and one outside every window must be followed by a DISCONNECTED before the next association (an episode the re-join ended). Any other CONNECTED - stamped before the next association's `at=` - or no control at all leaves the verdicts INCONCLUSIVE. So does a verdicted association that began while hostapd still held us - the last event for our address before its `at=` a CONNECTED with no DISCONNECTED after it - because that CONNECTED may be its own, stamped early. So do a missing, empty, malformed or out-of-order stamp, a missing or mismatched ledger, and logs that could not be parsed. Only our address on the cell's AP interface counts, matched case-insensitively as the token after the event name); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 5eb5f9b7..cb3d5c47 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -687,21 +687,32 @@ cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; dmesg_on_fail; # association's `at=`, which proves this rig can stamp first. # The last case, or no control at all, leaves the verdicts INCONCLUSIVE # rather than cleared - it cannot tell which association such a CONNECTED -# belongs to. A FAIL is reported first, whatever else the log shows; then +# belongs to. So does a verdicted association that began while hostapd still +# held us: the last event for our address before its `at=` is a CONNECTED +# with no DISCONNECTED after it. That CONNECTED may be this association's, +# stamped early, and the window-owner rule cannot see it when an earlier +# window had no CONNECTED of its own to claim (an association hostapd never +# held, re-joined after a deauthentication, which leaves no verdict). +# Only our address on this cell's AP interface counts, matched as the token +# after the event name (newer hostapd appends fields), case-insensitively. +# Known and safe: mawk with an empty hostapd log reads it as the station +# rows (no CONNECTED, so no control: INCONCLUSIVE), and a duplicated line +# can only add a CONNECTED (INCONCLUSIVE or FAIL, never INFO). +# A FAIL is reported first, whatever else the log shows; then # missing, malformed or out-of-order stamps, a ledger that is missing or # disagrees with the log, or a log that could not be parsed, are -# INCONCLUSIVE. $1 cell, $2 own address. +# INCONCLUSIVE. $1 cell, $2 own address, $3 the AP interface. verdicts_scored() { local unconf res n ctrl tag k a v unconf=$(led "$1" 'unconfirmed') # One row per association: its at=, and its verdict's at= ("-" for none, # "?" for a line without a stamp). res=$(awk ' - function at( i) { for (i = 1; i <= NF; i++) if ($i ~ /^at=/) return substr($i, 4); return "?" } + function at( i) { for (i = 1; i <= NF; i++) if ($i ~ /^at=./) return substr($i, 4); return "?" } /station connected \(association/ { if (a != "") print a, "-"; a = at() } /station association unconfirmed:/ { print (a == "" ? "?" : a), at(); a = "" } END { if (a != "") print a, "-" } - ' "$OUT/sta_$1.log" | awk -v own="$2" ' + ' "$OUT/sta_$1.log" | awk -v own="$2" -v ifc="$3:" ' function ok(s) { return s ~ /^[0-9]+\.[0-9][0-9][0-9][0-9][0-9][0-9]$/ } # x <= y on sec.usec, compared as two integers: an epoch with six # decimals is at the edge of what a double holds. @@ -711,11 +722,13 @@ verdicts_scored() { } function lt(x, y) { return !le(y, x) } FILENAME == ARGV[1] { # the hostapd log; FNR == NR fails when it is empty - if (NF >= 2 && $NF == own && - ($(NF - 1) == "AP-STA-CONNECTED" || $(NF - 1) == "AP-STA-DISCONNECTED")) { - t = $1; sub(/:$/, "", t) - if (ok(t)) { ne++; ET[ne] = t; EK[ne] = ($(NF - 1) == "AP-STA-CONNECTED") ? "C" : "D" } - } + for (i = 2; i < NF; i++) + if (($i == "AP-STA-CONNECTED" || $i == "AP-STA-DISCONNECTED") && + tolower($(i + 1)) == tolower(own) && $(i - 1) == ifc) { + t = $1; sub(/:$/, "", t) + if (ok(t)) { ne++; ET[ne] = t; EK[ne] = ($i == "AP-STA-CONNECTED") ? "C" : "D" } + break + } next } NF >= 2 { na++; A[na] = $1; V[na] = $2 } @@ -724,9 +737,13 @@ verdicts_scored() { if (V[i] != "-") nv++ if (!ok(A[i]) || (V[i] != "-" && (!ok(V[i]) || lt(V[i], A[i])))) { stamp = 1; continue } if (i > 1 && ok(A[i - 1]) && lt(A[i], (V[i - 1] != "-" && ok(V[i - 1])) ? V[i - 1] : A[i - 1])) stamp = 1 - if (V[i] != "-") + if (V[i] != "-") { for (j = 1; j <= ne; j++) if (EK[j] == "C" && le(A[i], ET[j]) && le(ET[j], V[i])) { print "BAD", nv, A[i], V[i]; break } + last = "" + for (j = 1; j <= ne; j++) if (lt(ET[j], A[i])) last = EK[j] " " ET[j] + if (last ~ /^C /) print "HELD", A[i], substr(last, 3) + } } if (stamp) print "STAMP" else for (j = 1; j <= ne; j++) { @@ -777,6 +794,10 @@ EOF inc "$1: an association or verdict line has a missing, malformed or out-of-order at= stamp - cannot order the verdicts against hostapd's log" return 0 fi + if printf '%s\n' "$res" | grep -q '^HELD'; then + inc "$1: $unconf unconfirmed verdict(s) not cleared - a verdicted association began while hostapd still held us ($(printf '%s\n' "$res" | sed -n 's/^HELD \([^ ]*\) \([^ ]*\)$/CONNECTED \2 with no DISCONNECTED before at= \1/p' | head -1)): that CONNECTED may be its own, stamped early" + return 0 + fi if printf '%s\n' "$res" | grep -q '^EARLY'; then inc "$1: $unconf unconfirmed verdict(s) not cleared - hostapd logged an AP-STA-CONNECTED that belongs to no association window and was not dropped before the next association ($(printf '%s\n' "$res" | sed -n 's/^EARLY \([^ ]*\) \([^ ]*\)$/CONNECTED \2, next at= \1/p' | head -1)): this rig can stamp a held association's CONNECTED before the station's own at=, so the verdicts cannot be cleared" return 0 @@ -818,7 +839,7 @@ cell_open() { *) station_gone open; sta_pid_kill hostapd; return ;; esac cell_end - verdicts_scored open "$own" + verdicts_scored open "$own" "$AP_IF" local plain enc plain=$(led open 'plaintext rx'); enc=$(led open 'encrypted rx') if [ "${plain:-0}" -gt 0 ] && [ "${enc:-x}" = 0 ]; then From 2f6cffe1f69c9e2cd4d101084d1b67880990bfde Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 21:25:26 +0200 Subject: [PATCH 40/53] tests: sta_client_onair - an AP clock that stepped back is INCONCLUSIVE The held-at-start rule takes the last hostapd event in log order before an association's at=. If hostapd's wall clock steps back, a DISCONNECTED later in the file can be stamped earlier than the CONNECTED before it. The rule then reads "not held", and a false positive scores INFO (fixture clock_back). If hostapd's stamps for our address are not monotonic in file order, the verdicts are now INCONCLUSIVE, with a message saying the AP clock stepped. 31 fixtures; gawk, mawk and busybox awk agree on every one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 2 +- tests/sta_client_onair.sh | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index b9d3a35e..61fbe928 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -134,7 +134,7 @@ first line then reads `fault=1`. | Cell | Scored | |---|---| - | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts. Each association owns the window from its `at=` to its verdict, or to the next association; a verdict FAILs when hostapd (`hostapd -t`) logged AP-STA-CONNECTED for our address inside its window, whatever else the log shows. A verdict is cleared only once the stamp ordering is checked: every CONNECTED must be accounted for - the first one in a window without a verdict is that association's own (the positive control, counted once per association), and one outside every window must be followed by a DISCONNECTED before the next association (an episode the re-join ended). Any other CONNECTED - stamped before the next association's `at=` - or no control at all leaves the verdicts INCONCLUSIVE. So does a verdicted association that began while hostapd still held us - the last event for our address before its `at=` a CONNECTED with no DISCONNECTED after it - because that CONNECTED may be its own, stamped early. So do a missing, empty, malformed or out-of-order stamp, a missing or mismatched ledger, and logs that could not be parsed. Only our address on the cell's AP interface counts, matched case-insensitively as the token after the event name); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | + | `open` | with a ping running from the start, the AP associates our address within 30 s (recovering an unconfirmed first association counts. Each association owns the window from its `at=` to its verdict, or to the next association; a verdict FAILs when hostapd (`hostapd -t`) logged AP-STA-CONNECTED for our address inside its window, whatever else the log shows. A verdict is cleared only once the stamp ordering is checked: every CONNECTED must be accounted for - the first one in a window without a verdict is that association's own (the positive control, counted once per association), and one outside every window must be followed by a DISCONNECTED before the next association (an episode the re-join ended). Any other CONNECTED - stamped before the next association's `at=` - or no control at all leaves the verdicts INCONCLUSIVE. So does a verdicted association that began while hostapd still held us - the last event for our address before its `at=` a CONNECTED with no DISCONNECTED after it - because that CONNECTED may be its own, stamped early. So do a missing, empty, malformed or out-of-order stamp, hostapd stamps for our address that go backwards (the AP clock stepped), a missing or mismatched ledger, and logs that could not be parsed. Only our address on the cell's AP interface counts, matched case-insensitively as the token after the event name); ping 0% loss over the TAP; ledger plaintext only; armed; the clear | | `wpa2` | four-way, group and pairwise rekeys at the AP; ping before and after; one association; no four-way MIC failure, data-plane MIC failures <= PTK installs; armed; the clear; no `tx.retry_limit=0` warning. MT7612U: the managed filter - plaintext unicast injected from the AP's BSSID at the station (`plaintext refused` at least half of it, else INCONCLUSIVE) and at a foreign address (`not-for-us` under 1% of it - a PASS counts only once the `noarm` control of the same run has seen that stream arrive, else INCONCLUSIVE) | | `noarm` | control, `DEVOURER_STA_ARM=0`: no arm and no clear ran. Realtek: the station tried and the AP did NOT complete the four-way - a completed one FAILs; INCONCLUSIVE unless the armed `wpa2` cell of the same run got in (the positive control). MT7612U: under the monitor filter both injected streams arrive (each at least half); the link over a 30 s ping window is reported, not scored | | `retry0` | `DEVOURER_TX_RETRY_LIMIT=0`: the arm-time warning; the clear (the link over a 30 s ping window is reported, not scored) | diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index cb3d5c47..94ec0ece 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -726,7 +726,10 @@ verdicts_scored() { if (($i == "AP-STA-CONNECTED" || $i == "AP-STA-DISCONNECTED") && tolower($(i + 1)) == tolower(own) && $(i - 1) == ifc) { t = $1; sub(/:$/, "", t) - if (ok(t)) { ne++; ET[ne] = t; EK[ne] = ($i == "AP-STA-CONNECTED") ? "C" : "D" } + if (ok(t)) { + if (ne && lt(t, ET[ne])) clock = 1 # the AP clock stepped back + ne++; ET[ne] = t; EK[ne] = ($i == "AP-STA-CONNECTED") ? "C" : "D" + } break } next @@ -745,7 +748,8 @@ verdicts_scored() { if (last ~ /^C /) print "HELD", A[i], substr(last, 3) } } - if (stamp) print "STAMP" + if (clock) print "CLOCK" + if (stamp || clock) print "STAMP" else for (j = 1; j <= ne; j++) { if (EK[j] != "C") continue t = ET[j]; w = 0; nexta = "" @@ -790,6 +794,10 @@ $res EOF return 0 fi + if printf '%s\n' "$res" | grep -q '^CLOCK'; then + inc "$1: hostapd's stamps for our address go backwards in its log - the AP clock stepped, so the verdicts cannot be ordered against it" + return 0 + fi if printf '%s\n' "$res" | grep -q '^STAMP'; then inc "$1: an association or verdict line has a missing, malformed or out-of-order at= stamp - cannot order the verdicts against hostapd's log" return 0 From 407ea6d9d247104910048c940c2637a4ae639fe1 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 22:21:37 +0200 Subject: [PATCH 41/53] tests: sta_client - a (QoS) Null from the AP does not confirm the association Qodo on #471: rx_frame() cleared g_judge and reset the backoff on any unprotected frame addressed to us. A QoS Null (fc0 0xc8) does reach that path: is_qos_data() is (fc0 & 0x8c) == 0x88 (src/sta/Dot11.h:774), which admits the no-data subtype, and the gate at tests/sta_client.cpp :571 passes it. An AP sends a QoS Null for power-save or keepalive probing whether or not it forwards our traffic, so an AP that drops our data could still "confirm" the association. A plain Null (0x48) never got there: it is neither kFcData nor QoS data. The lift now needs a data-bearing frame: no-data subtype bit (0x40) clear, and a body. New cell test_a_null_frame_does_not_confirm covers a Null, a QoS Null, and a QoS Null with trailing bytes - each must leave the association to be judged Unconfirmed. Three mutations are killed: the to_us test dropped, null frames confirming, and the subtype check dropped. 35 of 35 in the set. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 6 ++++-- tests/sta_client.cpp | 8 +++++++- tests/sta_client_selftest.inc | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 61fbe928..dbb51f31 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -77,8 +77,10 @@ ledger): see "AP quirk" below. On WPA2 a second one follows if no EAPOL has arrived 1 s later; the four-way timeout re-joins if even that is not enough. -An open association is confirmed by the AP's first unicast frame to the -station. A station cannot see the AP's side: if the AP never saw the +An open association is confirmed by the AP's first unicast data frame to +the station - one that carries an MSDU; a (QoS) Null, which an AP sends for +power-save or keepalive probing either way, does not count. A station cannot +see the AP's side: if the AP never saw the association response acknowledged, it does not hold the station, drops its traffic and may never say so. So once the host has asked three questions, and no unicast reply has come within 5 s of the first, the link is lost as diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 33fa76f4..e9da096e 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -625,7 +625,13 @@ void rx_frame(const uint8_t* mpdu, size_t len, int8_t rssi, uint32_t now) { if (!eapol && !no_data) g_plain_refused.fetch_add(1); return; } - if (to_us) { /* the AP holds this association */ + /* The AP holds this association once it forwards us DATA: a frame with + * a data subtype and a body. A QoS Null (no-data subtype bit 0x40, + * admitted by is_qos_data above) carries no MSDU, and an AP sends one + * for power-save or keepalive probing whether or not it forwards our + * traffic. (A plain Null, 0x48, never gets this far: it is neither + * kFcData nor QoS data.) */ + if (to_us && !(fc0 & 0x40) && len > hlen) { g_judge = false; g_strikes = 0; /* ...so its BSS backs off no more */ } diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index f6e77d8b..8eb532ca 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -1401,6 +1401,38 @@ void test_a_unicast_reply_confirms_the_association() { "a unicast reply confirms the open association for good"); } +/* ONLY DATA CONFIRMS. A QoS Null from the AP (subtype bit 0x40, no body) + * reaches the plaintext path - is_qos_data admits it - but carries no MSDU: + * an AP sends one for power-save or keepalive probing whether or not it + * forwards our traffic, so it must leave the association unconfirmed. A + * plain Null is pinned too; it is dropped before that path. */ +void test_a_null_frame_does_not_confirm() { + /* 0: Null; 1: QoS Null; 2: QoS Null with trailing bytes - the subtype, + * not only the length, says there is no MSDU. */ + for (int qos = 0; qos < 3; qos++) { + reset_all(); + Ap ap; + open_link_with_uplink(ap, 0); + std::vector m = devourer::sta::data_hdr_from_ds( + kStaMac, kBssid, kBssid, /*protect=*/false, ap.seq.next()); + m[0] = qos ? 0xc8 : 0x48; /* QoS Null : Null */ + if (qos) m.insert(m.begin() + 24, 2, 0); /* QoS Control */ + if (qos == 2) m.insert(m.end(), 8, 0xaa); + rx_frame(m.data(), m.size(), -40, 100); + uint8_t e[60]; + ipv4_to_peer(e, 1, 0); + for (int i = 0; i < (int)kConfirmUplink; i++) tap_down_one(e, sizeof e); + drain_tx(); + supervise(1000); + supervise(1000 + kConfirmMs); + check(g_sm.state() == StationSm::State::Failed && + g_sm.fail_reason() == StationSm::Failure::Unconfirmed, + qos == 0 ? "a Null from the AP does not confirm the association" + : qos == 1 ? "a QoS Null from the AP does not confirm the association" + : "a QoS Null with trailing bytes does not confirm either"); + } +} + /* ONLY A UNICAST REPLY CONFIRMS: a group-addressed data frame from the AP * (a broadcast ARP, mDNS) reaches every station it ever held, and says * nothing about this association. */ @@ -2641,6 +2673,7 @@ int self_test() { selftest::test_multicast_chatter_is_not_judged(); selftest::test_fewer_questions_than_the_threshold_are_not_judged(); selftest::test_a_group_frame_from_the_ap_does_not_confirm(); + selftest::test_a_null_frame_does_not_confirm(); selftest::test_what_counts_as_a_question(); selftest::test_a_struck_bss_backs_off(); selftest::test_a_group_frame_does_not_reset_the_backoff(); From c54226efd4339349960d34c72e093f4bfeec69b8 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 22:23:46 +0200 Subject: [PATCH 42/53] tests: a hostapd that outlives its kill stays recorded, and nothing starts or moves under it Qodo on #471: run_reconnect ignored a failed `sta_pid_kill hostapd`, and sta_pid_kill deleted the PID record before returning 1 for a process still alive after 10 s. So a stuck hostapd survived untracked, a replacement was started on the same interface, and cleanup later moved the AP phy and netns out from under the original. - sta_pid_kill keeps the record when the process survives. Every caller was checked; a kept record only means a later call (cleanup) tries again, which every caller tolerates. New sta_pid_kill_hard escalates to KILL, and new sta_pid_live asks whether a recorded process still runs. - sta_client_onair: - every hostapd stop is sta_pid_kill_hard; - ap_up refuses (the cell INCONCLUSIVE, the reason in the cell's hostapd log) while a recorded hostapd lives, so no replacement ever starts beside it; - run_reconnect scores INCONCLUSIVE and does not restart the AP when the old hostapd outlived TERM and KILL; - cleanup leaves the AP phy in the netns and the netns in place when hostapd outlived both, and prints the recovery commands - the same survivor rule as mt7612u_ap_onair's reap. - mt7612u_sta_identity and realtek_station_onair escalate the same way and do not re-enumerate the AP under a surviving hostapd. - The autoack/uplink trap comments no longer say sta_pid_kill forgets a PID. Checked off-device: - A TERM-ignoring stand-in: sta_pid_kill returns 1, the record is kept, ap_up refuses; sta_pid_kill_hard then ends it and drops the record. - A simulated KILL-resistant one: sta_pid_kill_hard returns 1, the record is kept, ap_up refuses. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_autoack.sh | 4 +-- tests/mt7612u_sta_identity.sh | 5 +++- tests/mt7612u_sta_lib.sh | 21 +++++++++++--- tests/mt7612u_sta_uplink.sh | 4 +-- tests/realtek_station_onair.sh | 7 +++-- tests/sta_client_onair.sh | 50 +++++++++++++++++++++++----------- 6 files changed, 64 insertions(+), 27 deletions(-) diff --git a/tests/mt7612u_sta_autoack.sh b/tests/mt7612u_sta_autoack.sh index 2c88b52e..bf34d3c7 100755 --- a/tests/mt7612u_sta_autoack.sh +++ b/tests/mt7612u_sta_autoack.sh @@ -100,8 +100,8 @@ cleanup() { trap cleanup EXIT # AND IT MUST STOP: with INT/TERM on the EXIT trap the shell runs cleanup # and then CARRIES ON into the next arm. CLEANED makes the EXIT pass after it -# a no-op: sta_pid_kill forgets a PID on the first pass, so a second pass -# would hand back an adapter the first refused to. +# a no-op, so the hand-back is decided once - by the pass that ran the +# kills. trap 'cleanup; exit 3' INT TERM sta_dut_take || exit 2 diff --git a/tests/mt7612u_sta_identity.sh b/tests/mt7612u_sta_identity.sh index d3aee9ad..62e778d9 100755 --- a/tests/mt7612u_sta_identity.sh +++ b/tests/mt7612u_sta_identity.sh @@ -105,7 +105,10 @@ cleanup() { else echo "DUT gate still running - not re-enumerating DUT_SYSFS=$DUT_SYSFS"; fi # hostapd -B daemonizes; its PID is the one it wrote to -P for this run. # Unconditional: nothing is recorded unless hostapd started. - sta_pid_kill hostapd + if ! sta_pid_kill_hard hostapd; then + echo "hostapd outlived TERM and KILL - not re-enumerating AP_SYSFS=$AP_SYSFS" + sta_lock_release; return 0 + fi [ "$AP_REENUM" = yes ] || { sta_lock_release; return 0; } sleep 1 iw dev staid_mon del 2>/dev/null diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index b6334734..066a6598 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -205,13 +205,14 @@ sta_pid_alive() { # is our child, and forget it. POLLED, never a bare `wait` first: a child # that ignores the signal - or a background job started with SIGINT ignored, # as a non-interactive shell starts them - would block that `wait` for good. -# Returns 1, and says so, if it is still alive then (unreaped, so the caller -# can escalate by PID); 0 otherwise, and silently when nothing is recorded. +# Returns 1, and says so, if it is still alive then - unreaped, and STILL +# RECORDED, so a later call (or sta_pid_live) still finds it and nothing is +# started or re-enumerated under it; 0 otherwise, and silently when nothing +# is recorded. sta_pid_kill() { [ -f "$OUT/.pid_$1" ] || return 0 _sta_pid=$(cat "$OUT/.pid_$1" 2>/dev/null) - rm -f "$OUT/.pid_$1" - case "$_sta_pid" in ''|*[!0-9]*) return 0 ;; esac + case "$_sta_pid" in ''|*[!0-9]*) rm -f "$OUT/.pid_$1"; return 0 ;; esac kill "-${2:-TERM}" "$_sta_pid" 2>/dev/null _sta_t=0 while sta_pid_alive "$_sta_pid"; do @@ -221,10 +222,22 @@ sta_pid_kill() { fi sleep 0.1; _sta_t=$((_sta_t + 1)) done + rm -f "$OUT/.pid_$1" wait "$_sta_pid" 2>/dev/null # exited: reaps our child, no-op otherwise return 0 } +# sta_pid_kill, escalated to KILL when the first signal did not end it. +# 1 when the process outlived both; its record is kept. +sta_pid_kill_hard() { + sta_pid_kill "$1" "${2:-TERM}" || sta_pid_kill "$1" KILL +} + +# 0 when a process recorded under $1 is still running. +sta_pid_live() { + [ -f "$OUT/.pid_$1" ] && sta_pid_alive "$(cat "$OUT/.pid_$1" 2>/dev/null)" +} + # A USB device's identity as idVendor:idProduct:serial (serial empty when the # device has none), or nothing when the path names no device. Recorded when a # device is accepted and compared before anything destructive is done to the diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index dd68d51d..fc2fb4fe 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -115,8 +115,8 @@ cleanup() { trap cleanup EXIT # AND IT MUST STOP: with INT/TERM on the EXIT trap the shell runs cleanup # and then CARRIES ON into the next arm. CLEANED makes the EXIT pass after it -# a no-op: sta_pid_kill forgets a PID on the first pass, so a second pass -# would hand back an adapter the first refused to. +# a no-op, so the hand-back is decided once - by the pass that ran the +# kills. trap 'cleanup; exit 3' INT TERM sta_dut_take || exit 2 diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index cfdeb4f7..2340deb1 100755 --- a/tests/realtek_station_onair.sh +++ b/tests/realtek_station_onair.sh @@ -188,14 +188,17 @@ cleanup() { local dut_gone=0 peer_gone=0 sta_pid_kill peer INT || peer_gone=1 sta_pid_kill dut INT || dut_gone=1 - sta_pid_kill hostapd + local ap_gone=0 + sta_pid_kill_hard hostapd || ap_gone=1 # Only once a process has really exited: re-enumerating an adapter still # inside its de-init is what the hand-back must not do. if [ "$dut_gone" = 0 ]; then sta_dev_handback dut "$DUT_SYSFS" else echo "DUT still running - not re-enumerating $DUT_SYSFS"; fi if [ "$peer_gone" = 0 ]; then sta_dev_handback peer "${PEER_SYSFS:-}" else echo "peer still running - not re-enumerating $PEER_SYSFS"; fi - if [ "$AP_REENUM" = yes ]; then + if [ "$AP_REENUM" = yes ] && [ "$ap_gone" = 1 ]; then + echo "hostapd outlived TERM and KILL - not re-enumerating AP_SYSFS=$AP_SYSFS" + elif [ "$AP_REENUM" = yes ]; then # hostapd's `bssid=` leaves the interface carrying that address after it # exits; re-enumerate rather than bounce the link # (tests/mt7612u_sta_identity.sh has the history). diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 94ec0ece..02d3fb54 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -291,12 +291,19 @@ cleanup() { sta_pid_kill probe sta_pid_kill inject sta_pid_kill inject_own - sta_pid_kill hostapd + local ap_free=yes + sta_pid_kill_hard hostapd || ap_free=no ns_exists && ip netns exec "$NS" iw dev "$MON" del 2>/dev/null # THE PHY COMES BACK BEFORE THE NAMESPACE GOES: `ip netns del` on a # namespace still holding a phy destroys the phy (only a re-enumeration # brings it back). So the delete is conditional on the move having worked. - if [ "$NS_OURS" = yes ] && ns_exists; then + # And neither happens under a hostapd that outlived TERM and KILL: moving + # its interface away from it is what the hand-back must not do. + if [ "$ap_free" = no ]; then + echo "WARNING: hostapd (pid $(cat "$OUT/.pid_hostapd" 2>/dev/null)) outlived TERM and" \ + "KILL - leaving $AP_PHY in netns $NS. Once it is gone:" + echo " sudo ip netns exec $NS iw phy $AP_PHY set netns 1; sudo ip netns del $NS" + elif [ "$NS_OURS" = yes ] && ns_exists; then ip netns exec "$NS" iw phy "$AP_PHY" set netns 1 2>/dev/null sleep 1 if ip netns exec "$NS" ls /sys/class/ieee80211/ 2>/dev/null | grep -q .; then @@ -376,6 +383,14 @@ wait_for() { # $1 file, $2 regex, $3 seconds # stdout: AP-STA-CONNECTED, EAPOL-4WAY-HS-COMPLETED and the rekey lines are # read from that file. AP up is judged by the interface type, not the log. ap_up() { # $1 open | wpa2 | wpa2norekey, $2 log tag + # Never a second hostapd on the interface while the recorded one lives (a + # kill that failed keeps its record): the replacement would fight it, and + # the original would be left untracked. + if sta_pid_live hostapd; then + echo "rig: the previous hostapd (pid $(cat "$OUT/.pid_hostapd")) is still" \ + "running - not starting another" | tee "$OUT/hostapd_$2.log" + return 1 + fi { printf 'interface=%s\ndriver=nl80211\nssid=%s\n' "$AP_IF" "$SSID" if [ "$CH" -le 14 ]; then printf 'hw_mode=g\n'; else printf 'hw_mode=a\n'; fi @@ -661,7 +676,7 @@ dmesg_on_fail() { tail -10 | sed 's/^/ dmesg /' return 0 } -cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill hostapd; dmesg_on_fail; } +cell_end() { sta_pid_kill probe; sta_stop; sta_pid_kill_hard hostapd; dmesg_on_fail; } # Was each Unconfirmed verdict right? A verdict is right only for an # association the AP never held. sta_client stamps each association and each @@ -836,7 +851,7 @@ cell_open() { sta_pid_record probe $! if ! wait_for "$OUT/hostapd_open.log" "AP-STA-CONNECTED $own" 30; then if proc_running "$STA_PID"; then bad "open: the AP never associated $own within 30 s"; cell_end - else sta_pid_kill probe; station_gone open; sta_pid_kill hostapd; fi + else sta_pid_kill probe; station_gone open; sta_pid_kill_hard hostapd; fi return fi sta_pid_kill probe @@ -844,7 +859,7 @@ cell_open() { ping_ap open; case $? in 0) ok "open: ping over the air, $(loss open)" ;; 1) bad "open: ping $(loss open)" ;; - *) station_gone open; sta_pid_kill hostapd; return ;; + *) station_gone open; sta_pid_kill_hard hostapd; return ;; esac cell_end verdicts_scored open "$own" "$AP_IF" @@ -879,20 +894,20 @@ run_wpa2() { tap_up || { inc "$cell: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return 1; } if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then WPA2_LINK="no four-way" - if ! proc_running "$STA_PID"; then station_gone "$cell"; sta_pid_kill hostapd; return 1; fi + if ! proc_running "$STA_PID"; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; fi cell_end return 0 fi local p=0 if [ "$cell" = wpa2 ]; then ping_ap "$cell" || p=$? else ping_window "$cell" || p=$?; fi - if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill hostapd; return 1; fi + if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; fi WPA2_LINK="four-way completed, ping $(loss "$cell")" [ "$p" = 0 ] && WPA2_LINK="$WPA2_LINK OK" INJ_FOREIGN=""; INJ_OWN="" if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then inject_unicast "$cell" - proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill hostapd; return 1; } + proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } fi [ "$cell" = wpa2 ] || { cell_end; return 0; } @@ -907,7 +922,7 @@ run_wpa2() { sleep 1; t=$((t + 1)) done if ! proc_running "$STA_PID" && { [ "${gk:-0}" -lt 1 ] || [ "${pk:-0}" -lt 2 ]; }; then - station_gone "$cell"; sta_pid_kill hostapd; return 1 + station_gone "$cell"; sta_pid_kill_hard hostapd; return 1 fi if [ "${gk:-0}" -ge 1 ]; then ok "$cell: the AP completed a group rekey" else bad "$cell: no group rekey completed in ${lim}s"; fi @@ -917,7 +932,7 @@ run_wpa2() { ping_ap "${cell}_after"; case $? in 0) ok "$cell: ping after the rekeys, $(loss "${cell}_after")" ;; 1) bad "$cell: ping after the rekeys $(loss "${cell}_after")" ;; - *) station_gone "$cell"; sta_pid_kill hostapd; return 1 ;; + *) station_gone "$cell"; sta_pid_kill_hard hostapd; return 1 ;; esac cell_end return 0 @@ -1022,19 +1037,22 @@ run_reconnect() { if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then if proc_running "$STA_PID"; then inc "$cell: the first association never completed - nothing to reconnect"; cell_end - else station_gone "$cell"; sta_pid_kill hostapd; fi + else station_gone "$cell"; sta_pid_kill_hard hostapd; fi return fi ping_ap "$cell"; case $? in 0) ;; 1) inc "$cell: ping $(loss "$cell") before the loss - nothing to compare against"; cell_end; return ;; - *) station_gone "$cell"; sta_pid_kill hostapd; return ;; + *) station_gone "$cell"; sta_pid_kill_hard hostapd; return ;; esac # THE AP GOES AWAY (hostapd deauthenticates its stations on the way out, # and its beacons stop), then comes back on the same BSSID. echo " stopping hostapd for ${DOWN_S}s" - sta_pid_kill hostapd + if ! sta_pid_kill_hard hostapd; then + inc "$cell: hostapd outlived TERM and KILL - no AP restart, no replacement started" + cell_end; return + fi sleep "$DOWN_S" if ! grep -q '^ station link lost:' "$OUT/sta_$cell.log"; then proc_running "$STA_PID" || { station_gone "$cell"; return; } @@ -1057,19 +1075,19 @@ run_reconnect() { else if proc_running "$STA_PID"; then bad "$cell: no second four-way within ${REJOIN_S}s of the AP coming back"; cell_end - else station_gone "$cell"; sta_pid_kill hostapd; fi + else station_gone "$cell"; sta_pid_kill_hard hostapd; fi return fi ping_window "${cell}_after"; case $? in 0) ok "$cell: ping after the re-join, $(loss "${cell}_after")" ;; 1) bad "$cell: ping after the re-join, $(loss "${cell}_after")" ;; - *) station_gone "$cell"; sta_pid_kill hostapd; return ;; + *) station_gone "$cell"; sta_pid_kill_hard hostapd; return ;; esac else if [ "$rejoined" = yes ]; then bad "$cell: re-joined with DEVOURER_STA_RECONNECT=0" else - proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill hostapd; return; } + proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return; } ok "$cell: no re-join within ${REJOIN_S}s with DEVOURER_STA_RECONNECT=0" fi fi From af19b9c8be8b2e818b276bbaa7c4591b8e10bcd2 Mon Sep 17 00:00:00 2001 From: snokvist Date: Wed, 7 Oct 2026 22:37:37 +0200 Subject: [PATCH 43/53] tests: pin the empty-body half of the confirmation lift; an empty PID is not live - test_a_null_frame_does_not_confirm gains a Data (0x08) and a QoS Data (0x88) frame addressed to us with an empty body. Neither may confirm. Without them, dropping the `len > hlen` half of the lift survived; the mutation is now killed. - sta_pid_alive returns "not live" for an empty or non-numeric PID. Before, an empty record read /proc//stat, which is /proc/stat, and counted as running. Checked under dash and bash. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_lib.sh | 2 ++ tests/sta_client_selftest.inc | 23 +++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 066a6598..612e5bc5 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -196,6 +196,8 @@ sta_pid_record() { echo "$2" > "$OUT/.pid_$1"; } # Is PID running? `kill -0` alone also succeeds on an exited but unreaped # child (a zombie, state Z in /proc/PID/stat after the command name). sta_pid_alive() { + # An empty or non-numeric PID is not live: /proc//stat is /proc/stat. + case "$1" in ''|*[!0-9]*) return 1 ;; esac _sta_st=$(sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) [ -n "$_sta_st" ] && [ "$_sta_st" != Z ] && [ "$_sta_st" != X ] } diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 8eb532ca..d31b9ce8 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -1408,16 +1408,25 @@ void test_a_unicast_reply_confirms_the_association() { * plain Null is pinned too; it is dropped before that path. */ void test_a_null_frame_does_not_confirm() { /* 0: Null; 1: QoS Null; 2: QoS Null with trailing bytes - the subtype, - * not only the length, says there is no MSDU. */ - for (int qos = 0; qos < 3; qos++) { + * not only the length, says there is no MSDU; 3: Data and 4: QoS Data + * with an EMPTY body - the length, not only the subtype, says there is + * none. */ + static const char* const what[] = { + "a Null from the AP does not confirm the association", + "a QoS Null from the AP does not confirm the association", + "a QoS Null with trailing bytes does not confirm either", + "a Data frame with an empty body does not confirm either", + "a QoS Data frame with an empty body does not confirm either"}; + static const uint8_t fc[] = {0x48, 0xc8, 0xc8, 0x08, 0x88}; + for (int k = 0; k < 5; k++) { reset_all(); Ap ap; open_link_with_uplink(ap, 0); std::vector m = devourer::sta::data_hdr_from_ds( kStaMac, kBssid, kBssid, /*protect=*/false, ap.seq.next()); - m[0] = qos ? 0xc8 : 0x48; /* QoS Null : Null */ - if (qos) m.insert(m.begin() + 24, 2, 0); /* QoS Control */ - if (qos == 2) m.insert(m.end(), 8, 0xaa); + m[0] = fc[k]; + if (fc[k] & 0x80) m.insert(m.begin() + 24, 2, 0); /* QoS Control */ + if (k == 2) m.insert(m.end(), 8, 0xaa); rx_frame(m.data(), m.size(), -40, 100); uint8_t e[60]; ipv4_to_peer(e, 1, 0); @@ -1427,9 +1436,7 @@ void test_a_null_frame_does_not_confirm() { supervise(1000 + kConfirmMs); check(g_sm.state() == StationSm::State::Failed && g_sm.fail_reason() == StationSm::Failure::Unconfirmed, - qos == 0 ? "a Null from the AP does not confirm the association" - : qos == 1 ? "a QoS Null from the AP does not confirm the association" - : "a QoS Null with trailing bytes does not confirm either"); + what[k]); } } From 26db041a791eeefcaee176a8aa3b80b1d53352a4 Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 15:49:18 +0200 Subject: [PATCH 44/53] tests: refuse a held adapter on every DUT and peer take The Realtek take in sta_client_onair.sh (and every sta_dev_record user: realtek_station_onair.sh's DUT and peer, the MT7612U harnesses' peer) had no live-holder gate. sta_dev_unbind_wifi skips an interface with no wireless netdev, which is exactly what a libusb-claimed interface looks like, so a DUT another devourer process held passed, and cleanup's sta_dev_handback then toggled `authorized` under that live process. sta_usb_unheld refuses an adapter with an interface bound to usbfs or a process holding its /dev/bus/usb node. sta_dut_take and sta_dev_record both call it. sta_client_onair.sh marks the Realtek DUT opened only once its unbind has succeeded. sta_usb_holder reads `ls -l /proc/*/fd` instead of `find -lname`: busybox find has no -lname, and the swallowed error read as "nothing holds it". GNU find -samefile is no substitute, because it holds the node open itself. The AP paths need no gate: every AP guard requires a wireless netdev on the adapter, which a usbfs-claimed interface does not carry. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/mt7612u_sta_lib.sh | 64 +++++++++++++++++++++++++++------------ tests/sta_client_onair.sh | 6 ++-- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 612e5bc5..12b5d6ac 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -16,11 +16,13 @@ # cannot share - and kill each other through - one set of PID files. The # kernel drops the lock when the last holder exits, so there is no owner # record to race and no stale lock to reclaim. Two runs with different -# OUTs are NOT kept apart by this lock. sta_dut_take() refuses an -# MT7612U with a LIVE holder - an interface bound to a driver other than -# mt76x2u (usbfs: a process has claimed it), or a process with its -# /dev/bus/usb node open - so a run never toggles `authorized` under a -# devourer process. An unbound interface nothing holds is taken as it is: +# OUTs are NOT kept apart by this lock. Every DUT and peer take +# (sta_dut_take() for the MT7612U, sta_dev_record() for a Realtek +# adapter) refuses an adapter with a LIVE holder - an interface bound to +# usbfs (a process has claimed it), or a process with its /dev/bus/usb +# node open - so a run never toggles `authorized` under a devourer +# process. sta_dut_take() also refuses interface 0 bound to any driver +# other than mt76x2u. An unbound interface nothing holds is taken as it is: # a devourer demo detaches mt76x2u and never reattaches it, and a host # may blacklist mt76x2u (docs/mt7612u.md). What this cannot see is # another harness between two of its gates, when nothing holds the @@ -104,16 +106,40 @@ STA_DUT_TAKEN=no STA_DUT_ID="" # The PID of a process that has the USB device at sysfs path $1 open # (/dev/bus/usb/BBB/DDD), or nothing. Root sees every process's fds. +# Read from `ls -l`, which GNU and busybox print alike: busybox find has no +# -lname (its error would read as "nothing holds it"), and GNU find -samefile +# holds the node open itself. sta_usb_holder() { _sta_b=$(cat "/sys/bus/usb/devices/$1/busnum" 2>/dev/null) _sta_d=$(cat "/sys/bus/usb/devices/$1/devnum" 2>/dev/null) [ -n "$_sta_b" ] && [ -n "$_sta_d" ] || return 0 - _sta_h=$(find /proc/[0-9]*/fd -maxdepth 1 \ - -lname "$(printf '/dev/bus/usb/%03d/%03d' "$_sta_b" "$_sta_d")" \ - 2>/dev/null | head -1) - [ -n "$_sta_h" ] || return 0 - _sta_h=${_sta_h#/proc/} - echo "${_sta_h%%/*}" + # shellcheck disable=SC2012 # the names listed are /proc fd numbers + ls -l /proc/[0-9]*/fd 2>/dev/null | + awk -v n="$(printf '/dev/bus/usb/%03d/%03d' "$_sta_b" "$_sta_d")" ' + /^\/proc\/[0-9]+\/fd:$/ { split($0, p, "/"); pid = p[3]; next } + $NF == n && $(NF - 1) == "->" && pid != "" { print pid; exit }' +} + +# Refuse the USB device at sysfs path $2 (called $1 in the message) while a +# live process holds it: an interface bound to usbfs (claimed over libusb), +# or a process with its /dev/bus/usb node open. A libusb-claimed interface +# carries no netdev, so sta_dev_unbind_wifi() alone would pass it. +sta_usb_unheld() { + for _sta_if in "/sys/bus/usb/devices/$2:"*; do + [ -e "$_sta_if/driver" ] || continue + if [ "$(basename "$(readlink -f "$_sta_if/driver")")" = usbfs ]; then + echo "refusing $1 at $2 - $(basename "$_sta_if") is held by usbfs" \ + "(a process has claimed it)" + return 1 + fi + done + _sta_pid=$(sta_usb_holder "$2") + if [ -n "$_sta_pid" ]; then + echo "refusing $1 at $2 - PID $_sta_pid" \ + "($(cat "/proc/$_sta_pid/comm" 2>/dev/null)) has its USB device open" + return 1 + fi + return 0 } # Refuse a DUT_SYSFS that is not an MT7612U, or that something live holds: @@ -139,12 +165,7 @@ sta_dut_take() { return 1 fi fi - _sta_pid=$(sta_usb_holder "$DUT_SYSFS") - if [ -n "$_sta_pid" ]; then - echo "refusing DUT_SYSFS=$DUT_SYSFS - PID $_sta_pid" \ - "($(cat "/proc/$_sta_pid/comm" 2>/dev/null)) has its USB device open" - return 1 - fi + sta_usb_unheld DUT "$DUT_SYSFS" || return 1 if [ "$_sta_drv" = mt76x2u ]; then echo "$DUT_SYSFS:1.0" > /sys/bus/usb/drivers/mt76x2u/unbind 2>/dev/null sleep 2 @@ -256,9 +277,11 @@ sta_usb_id() { # open detaches its kernel driver and nothing re-attaches it. Each is kept # under a NAME, in files in OUT (a process started inside a command # substitution sets them too, and its variables never reach the parent): -# sta_dev_record NAME SYSFS VID PID - before the run: refuse a hub and any -# device that is not VID:PID, and note its idVendor:idProduct:serial; -# sta_dev_opened NAME - just before a process opens it; +# sta_dev_record NAME SYSFS VID PID - before the run: refuse a hub, any +# device that is not VID:PID, and one a live process holds +# (sta_usb_unheld), and note its idVendor:idProduct:serial; +# sta_dev_opened NAME - just before a process opens it, and after any +# sta_dev_unbind_wifi() has succeeded; # sta_dev_handback NAME SYSFS - re-enumerate it with an `authorized` 0/1 # toggle so its kernel driver binds again - only when this run opened it, # and only while SYSFS still reports the recorded identity, so a device @@ -273,6 +296,7 @@ sta_dev_record() { if [ "$_sta_have" != "$_sta_want" ]; then echo "refusing $1 at $2 - it reports $_sta_have, not $_sta_want"; return 1 fi + sta_usb_unheld "$1" "$2" || return 1 sta_usb_id "$2" > "$OUT/.id_$1" rm -f "$OUT/.opened_$1" return 0 diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 02d3fb54..742f0021 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -331,11 +331,11 @@ trap 'cleanup; exit 3' INT TERM if [ "$DUT_KIND" = mt7612u ]; then sta_dut_take || exit 2 else - # Marked opened BEFORE the unbind, so the hand-back re-binds its driver - # whatever happens after this point. + # Marked opened only once the unbind has succeeded, so cleanup toggles + # `authorized` only on an adapter this run freed (and nothing held). sta_dev_record dut "$DUT_SYSFS" "$DUT_VID" "$DUT_PID" || exit 2 - sta_dev_opened dut sta_dev_unbind_wifi "$DUT_SYSFS" || exit 2 + sta_dev_opened dut fi if command -v nmcli >/dev/null 2>&1; then From d4a93f70148e7a51ed14775c0da18447403b30b0 Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 15:49:18 +0200 Subject: [PATCH 45/53] tests: use the lib's sta_pid_alive, not a local proc_running copy sta_client_onair.sh and realtek_station_onair.sh each carried a proc_running that duplicated sta_pid_alive without its empty-PID guard: `proc_running ""` reads /proc//stat, which is /proc/stat, and returns true. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- tests/realtek_station_onair.sh | 14 ++---------- tests/sta_client_onair.sh | 40 +++++++++++++++------------------- 2 files changed, 19 insertions(+), 35 deletions(-) diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index 2340deb1..810890cc 100755 --- a/tests/realtek_station_onair.sh +++ b/tests/realtek_station_onair.sh @@ -228,21 +228,11 @@ sel_env() { # $1 sysfs -> DEVOURER_USB_BUS / _PORT assignments printf 'DEVOURER_USB_BUS=%s DEVOURER_USB_PORT=%s' "${1%%-*}" "${1#*-}" } -# Is PID running? `kill -0` is not enough: a background child that has exited -# but is not yet reaped is a zombie, and kill -0 still succeeds on it. The -# state field of /proc/PID/stat (after the parenthesised command name) is Z -# for a zombie. -proc_running() { # $1 pid - local st - st=$(sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) - [ -n "$st" ] && [ "$st" != Z ] && [ "$st" != X ] -} - # Wait for a regex in a file while the process lives. 0 found, 1 not. wait_for() { # $1 pid, $2 file, $3 regex, $4 timeout s local t=0 until grep -qE "$3" "$2" 2>/dev/null; do - proc_running "$1" || return 1 + sta_pid_alive "$1" || return 1 [ "$t" -ge "$4" ] && return 1 sleep 1; t=$((t + 1)) done @@ -415,7 +405,7 @@ down_arm() { fi # LIVENESS AFTER THE WINDOW: a DUT that died mid-window reads ~0% ACKed, # which is a control's PASSING value. - if [ -n "$dut" ] && ! proc_running "$dut"; then + if [ -n "$dut" ] && ! sta_pid_alive "$dut"; then echo "$tag ABORTED the DUT died during the window: $(tail -1 "$OUT/dut_$tag.err" 2>/dev/null)" > "$res" rm -f "$OUT/.pid_dut"; return 0 fi diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 742f0021..1135a761 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -362,12 +362,6 @@ bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } inc() { inconclusive=$((inconclusive+1)); printf ' INCONCLUSIVE %s\n' "$*"; } info() { printf ' INFO %s\n' "$*"; } -# Is PID running? kill -0 also succeeds on an unreaped zombie. -proc_running() { - local st - st=$(sed 's/^.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f1) - [ -n "$st" ] && [ "$st" != Z ] && [ "$st" != X ] -} # Wait for an extended regex in a file. 0 found, 1 timed out. wait_for() { # $1 file, $2 regex, $3 seconds @@ -459,7 +453,7 @@ sta_up() { # $1 cell, $2 seconds, $3.. extra env sta_pid_record sta "$STA_PID" local t=0 until grep -q 'sta_client up:' "$OUT/sta_$cell.log" 2>/dev/null; do - proc_running "$STA_PID" || return 1 + sta_pid_alive "$STA_PID" || return 1 [ "$t" -ge "$READY_TIMEOUT" ] && return 2 sleep 1; t=$((t + 1)) done @@ -486,10 +480,10 @@ STA_RC="" sta_stop() { STA_RC="" [ -n "$STA_PID" ] || return 0 - if proc_running "$STA_PID"; then kill -INT "$STA_PID" 2>/dev/null; fi + if sta_pid_alive "$STA_PID"; then kill -INT "$STA_PID" 2>/dev/null; fi local t=0 - while proc_running "$STA_PID" && [ "$t" -lt 15 ]; do sleep 1; t=$((t + 1)); done - if proc_running "$STA_PID"; then + while sta_pid_alive "$STA_PID" && [ "$t" -lt 15 ]; do sleep 1; t=$((t + 1)); done + if sta_pid_alive "$STA_PID"; then # KILL, not TERM: TERM is handled exactly like INT. Still alive after it # means the DUT must not be re-enumerated under it. sta_pid_kill sta KILL || STA_HUNG=yes @@ -529,18 +523,18 @@ led() { sed -n "s/.*$2=\\([0-9][0-9]*\\).*/\\1/p" "$OUT/sta_$1.log" | tail -1; } # Ping the AP over the air. 0 = 0% loss, 1 = loss, 2 = the station was not # alive for the whole measurement (no verdict on the link). ping_ap() { # $1 tag - proc_running "$STA_PID" || return 2 + sta_pid_alive "$STA_PID" || return 2 ping -c 1 -W 3 -I "$TAP" "$APIP" >/dev/null 2>&1 # warm ARP ping -c 6 -W 1 -I "$TAP" "$APIP" > "$OUT/ping_$1.txt" 2>&1 - proc_running "$STA_PID" || return 2 + sta_pid_alive "$STA_PID" || return 2 grep -q ' 0% packet loss' "$OUT/ping_$1.txt" } # The same over a real window: PING_S seconds, two pings a second. ping_window() { # $1 tag - proc_running "$STA_PID" || return 2 + sta_pid_alive "$STA_PID" || return 2 ping -c 1 -W 3 -I "$TAP" "$APIP" >/dev/null 2>&1 # warm ARP ping -c $(( PING_S * 2 )) -i 0.5 -W 1 -I "$TAP" "$APIP" > "$OUT/ping_$1.txt" 2>&1 - proc_running "$STA_PID" || return 2 + sta_pid_alive "$STA_PID" || return 2 grep -q ' 0% packet loss' "$OUT/ping_$1.txt" } loss() { grep -oE '[0-9]+ packets transmitted, [0-9]+ received.*packet loss' "$OUT/ping_$1.txt" 2>/dev/null | head -1; } @@ -850,7 +844,7 @@ cell_open() { ping -I "$TAP" -i 1 "$APIP" >/dev/null 2>&1 & sta_pid_record probe $! if ! wait_for "$OUT/hostapd_open.log" "AP-STA-CONNECTED $own" 30; then - if proc_running "$STA_PID"; then bad "open: the AP never associated $own within 30 s"; cell_end + if sta_pid_alive "$STA_PID"; then bad "open: the AP never associated $own within 30 s"; cell_end else sta_pid_kill probe; station_gone open; sta_pid_kill_hard hostapd; fi return fi @@ -894,7 +888,7 @@ run_wpa2() { tap_up || { inc "$cell: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return 1; } if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then WPA2_LINK="no four-way" - if ! proc_running "$STA_PID"; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; fi + if ! sta_pid_alive "$STA_PID"; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; fi cell_end return 0 fi @@ -907,7 +901,7 @@ run_wpa2() { INJ_FOREIGN=""; INJ_OWN="" if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then inject_unicast "$cell" - proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } + sta_pid_alive "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } fi [ "$cell" = wpa2 ] || { cell_end; return 0; } @@ -915,13 +909,13 @@ run_wpa2() { # handshake completed" is logged for the initial four-way too, so a PTK # rekey is the SECOND such line. local t=0 gk=0 pk=0 lim=$(( REKEY_S + PTK_REKEY_S + 25 )) - while [ "$t" -lt "$lim" ] && proc_running "$STA_PID"; do + while [ "$t" -lt "$lim" ] && sta_pid_alive "$STA_PID"; do gk=$(grep -c 'group key handshake completed' "$OUT/hostapd_$cell.log" 2>/dev/null) pk=$(grep -c 'pairwise key handshake completed' "$OUT/hostapd_$cell.log" 2>/dev/null) [ "${gk:-0}" -ge 1 ] && [ "${pk:-0}" -ge 2 ] && break sleep 1; t=$((t + 1)) done - if ! proc_running "$STA_PID" && { [ "${gk:-0}" -lt 1 ] || [ "${pk:-0}" -lt 2 ]; }; then + if ! sta_pid_alive "$STA_PID" && { [ "${gk:-0}" -lt 1 ] || [ "${pk:-0}" -lt 2 ]; }; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1 fi if [ "${gk:-0}" -ge 1 ]; then ok "$cell: the AP completed a group rekey" @@ -1035,7 +1029,7 @@ run_reconnect() { local own; own=$(own_of "$cell") tap_up || { inc "$cell: no TAP, or the route to $APIP does not leave through $TAP"; cell_end; return; } if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then - if proc_running "$STA_PID"; then + if sta_pid_alive "$STA_PID"; then inc "$cell: the first association never completed - nothing to reconnect"; cell_end else station_gone "$cell"; sta_pid_kill_hard hostapd; fi return @@ -1055,7 +1049,7 @@ run_reconnect() { fi sleep "$DOWN_S" if ! grep -q '^ station link lost:' "$OUT/sta_$cell.log"; then - proc_running "$STA_PID" || { station_gone "$cell"; return; } + sta_pid_alive "$STA_PID" || { station_gone "$cell"; return; } fi ap_up wpa2norekey "${cell}2" || { inc "$cell: hostapd did not come back - see $OUT/hostapd_${cell}2.log"; cell_end; return; } # The bound runs from hostapd being started again, not from ap_up @@ -1073,7 +1067,7 @@ run_reconnect() { local ms=$(( $(date +%s%3N) - back )) ok "$cell: re-joined and re-keyed $(( ms / 1000 )).$(( ms % 1000 / 100 ))s after hostapd was started again (bound ${REJOIN_S}s)" else - if proc_running "$STA_PID"; then + if sta_pid_alive "$STA_PID"; then bad "$cell: no second four-way within ${REJOIN_S}s of the AP coming back"; cell_end else station_gone "$cell"; sta_pid_kill_hard hostapd; fi return @@ -1087,7 +1081,7 @@ run_reconnect() { if [ "$rejoined" = yes ]; then bad "$cell: re-joined with DEVOURER_STA_RECONNECT=0" else - proc_running "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return; } + sta_pid_alive "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return; } ok "$cell: no re-join within ${REJOIN_S}s with DEVOURER_STA_RECONNECT=0" fi fi From 3f5a40233b1ca0a139b78a66582fe710266bd19e Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 15:52:47 +0200 Subject: [PATCH 46/53] tests: finish the managed-filter injection before hostapd's first rekey A bench run of the wpa2 cell failed `ledger associations=2`. hostapd's Group Key Handshake 1/2 went unanswered four times while the injection's monitor vif was being torn down; the AP then deauthenticated and the station re-joined. With the defaults the overlap was structural. The group timer (20 s) starts with the AP. The injection began after the four-way and a 6 s ping, and ran 10 s, so it ended within a second of the first group rekey. The injection now runs straight after the four-way, before the ping, and only when hostapd's own -t stamps show it ends, with 5 s of margin for the vif and the early group timer, before both first rekeys: REKEY_S after AP-ENABLED and PTK_REKEY_S after the station's last four-way. Otherwise it is skipped and the filter check is INCONCLUSIVE, naming the knobs. The REKEY_S default moves from 20 to 30 s so that the defaults leave room. The cell still waits for both rekeys and pings after them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 8 ++++++- tests/sta_client_onair.sh | 47 ++++++++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index dbb51f31..90c15f5b 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -152,7 +152,13 @@ first line then reads `fault=1`. The injections (`INJECT_S`, `INJECT_PPS` each, `FOREIGN`) ride a monitor vif on the AP's phy (`tests/sta_unicast_inject.py`) and run only for an MT7612U DUT; a phy that cannot add one makes the filter check - INCONCLUSIVE, not the cell. + INCONCLUSIVE, not the cell. They run straight after the four-way, and + only when they end - vif deleted - at least 5 s before hostapd's first + group (`REKEY_S` after the AP comes up, default 30) and pairwise + (`PTK_REKEY_S` after the four-way, default 25) rekeys, read off hostapd's + own stamps; otherwise the filter check is INCONCLUSIVE. A group rekey + that lands in the vif teardown can go unanswered and cost the + association. The arm is per BSSID: a re-join to the same BSSID keeps it rather than arming again, and on Realtek the second association is the proof it still diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 1135a761..3fa5308a 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -37,7 +37,12 @@ # station refuses it on a WPA2 link and counts it (`plaintext refused`), and # it must reach half of what was injected, else the filter check is # INCONCLUSIVE. A phy that cannot add a monitor vif makes the check -# INCONCLUSIVE, not the cell. +# INCONCLUSIVE, not the cell. The injection runs straight after the four-way, +# before the ping, and only when it - monitor vif deleted - ends at least +# 5 s before hostapd's first group and pairwise rekeys, read off hostapd's +# own stamps; otherwise it is skipped and the check is INCONCLUSIVE. A group +# rekey that lands in the vif teardown can go unanswered and cost the +# association. # # Cells (each scored against its own witness): # open hostapd open, with a ping running from the start: the AP @@ -159,8 +164,10 @@ PSK="${PSK:-devourer123}" SECS="${SECS:-90}" # hostapd's group and pairwise rekey intervals for the wpa2 cell. Both must # fire inside the run: the rekeys travel inside the cipher, a path the -# four-way alone never exercises. -REKEY_S="${REKEY_S:-20}" +# four-way alone never exercises. The group timer starts with the AP and the +# pairwise one at the four-way; the injection (INJECT_S) has to fit, with +# its margin, before the first of them. +REKEY_S="${REKEY_S:-30}" PTK_REKEY_S="${PTK_REKEY_S:-25}" # The ping window behind every reported link line and the reconnect cell's # post-re-join check: PING_S seconds at 2 pings a second. @@ -578,14 +585,32 @@ check_cleared() { # $1 cell # statement after its launch, and every injector is bounded by `timeout -k` # whatever happens to the harness. Sets INJ_FOREIGN / INJ_OWN (empty when it # could not run). -INJ_FOREIGN=""; INJ_OWN="" +INJ_FOREIGN=""; INJ_OWN=""; INJ_SKIP="" inject_count() { sed -n 's/^injected \([0-9][0-9]*\) unicast frames.*/\1/p' "$1" 2>/dev/null | tail -1; } +# 0 when INJECT_S, plus 5 s for the monitor vif and hostapd's early group +# timer, ends before both first rekeys: the group one REKEY_S after +# AP-ENABLED, the pairwise one PTK_REKEY_S after the last four-way with $2. +# Read off hostapd's -t stamps. $1 cell. +rekey_clear() { + awk -v own="EAPOL-4WAY-HS-COMPLETED $2" -v g="$REKEY_S" -v p="$PTK_REKEY_S" \ + -v need="$(( INJECT_S + 5 ))" -v now="$(date +%s.%N)" ' + / AP-ENABLED/ && !e { e = $1 + 0 } + index($0, own) { f = $1 + 0 } + END { if (!e || !f) exit 1 + d = e + g; if (f + p < d) d = f + p + exit !(now + need < d) }' "$OUT/hostapd_$1.log" +} + inject_unicast() { # $1 cell - INJ_FOREIGN=""; INJ_OWN="" + INJ_FOREIGN=""; INJ_OWN=""; INJ_SKIP="" local bssid own pf po bssid=$(ip netns exec "$NS" cat "/sys/class/net/$AP_IF/address" 2>/dev/null) own=$(own_of "$1") [ -n "$bssid" ] && [ -n "$own" ] || return 1 + if ! rekey_clear "$1" "$own"; then + INJ_SKIP="the injection (${INJECT_S}s + 5s) would not end before hostapd's first rekey - raise REKEY_S / PTK_REKEY_S or lower INJECT_S" + return 1 + fi ip netns exec "$NS" iw dev "$MON" del 2>/dev/null if ! { ip netns exec "$NS" iw phy "$AP_PHY" interface add "$MON" type monitor 2>/dev/null && ip netns exec "$NS" ip link set "$MON" up 2>/dev/null; }; then @@ -621,6 +646,7 @@ HELD_FILTER_PASS="" check_filter() { local nfu ref nfu=$(led "$1" 'not-for-us'); ref=$(led "$1" 'plaintext refused') + if [ -n "$INJ_SKIP" ]; then inc "$1: $INJ_SKIP"; return; fi if [ "${INJ_FOREIGN:-0}" = 0 ] || [ "${INJ_OWN:-0}" = 0 ]; then inc "$1: the unicast injectors did not run (no monitor vif on $AP_PHY?) - see $OUT/inject_$1.log, $OUT/inject_own_$1.log" return @@ -892,17 +918,18 @@ run_wpa2() { cell_end return 0 fi + # The managed-filter stimulus first, so that it is over before the rekeys. + INJ_FOREIGN=""; INJ_OWN=""; INJ_SKIP="" + if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then + inject_unicast "$cell" + sta_pid_alive "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } + fi local p=0 if [ "$cell" = wpa2 ]; then ping_ap "$cell" || p=$? else ping_window "$cell" || p=$?; fi if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; fi WPA2_LINK="four-way completed, ping $(loss "$cell")" [ "$p" = 0 ] && WPA2_LINK="$WPA2_LINK OK" - INJ_FOREIGN=""; INJ_OWN="" - if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then - inject_unicast "$cell" - sta_pid_alive "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } - fi [ "$cell" = wpa2 ] || { cell_end; return 0; } # The rekeys: waited for while the station is alive. "pairwise key From 0ef78566e227e58a256a234c28e2901974ef09ef Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 15:55:31 +0200 Subject: [PATCH 47/53] docs(mt7612u-station-identity): the managed filter's on-air numbers; no history The witness section said "On-air numbers: TBD" and listed the managed filter under a live association as not established. It is measured. An MT7612U station against an RTL8812BU AP on two benches gave own-addressed 882/882, 943/943, 775/863 and 875/875 arrived, and foreign not-for-us 0/761, 0/927, 0/642 and 0/713. In every run the noarm control saw the foreign stream. The numbers go into a table, and the TBD bullet goes. Two passages narrated history: a retraction ("an earlier version of this page said") and "used to run promiscuous ... (issue #461)". Both now state the current behaviour: the PROMISC bit's decode, and why the armed station runs the managed filter. The provenance stays in git. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 39 ++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index c63cae1f..15153339 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -28,11 +28,11 @@ arm. That function writes `MT_RX_FILTR_CFG = PHY_ERR|CRC_ERR` and nothing else were identical by construction. Its null result is withdrawn. The reasoning that let it through was also wrong: in the managed filter `0x00015f97`, bit 3 (`OTHER_BSS`) is clear but bit **2** (`PROMISC`) is set, and bit 2 is the -address drop (mt76x2 sets it whenever the phy is not in monitor mode; an -earlier version of this page said mt76 maps it to `FIF_OTHER_BSS`, which is -not what `mt76x2u_config()` does - the decode is in the managed-filter section -below). The gate now leaves the managed value `mt_mac_start()` -programs, prints it per arm, and flags an arm that is not running it. +address drop: it drops unicast whose addr1 is not `MT_MAC_ADDR`, and mt76x2 +sets it whenever the phy is not in monitor mode (the decode is in the +managed-filter section below). The gate leaves the managed value +`mt_mac_start()` programs, prints it per arm, and flags an arm that is not +running it. Also withdrawn: a "0.8% retried vs 98% control" auto-ACK figure from the probe-response method (below), whose control ran with the monitor filter and @@ -286,12 +286,13 @@ Against it, and against the comparison: ## The managed receive filter belongs to the armed station -Every cell above ran the managed filter `0x00015f97`, while -`Mt7612uRadio::StartRxLoop` installs the monitor filter (`PHY_ERR|CRC_ERR`). -So a station driven through `IRadio` used to run promiscuous, and the -property that justifies the seam's refusal - "moving `MT_MAC_ADDR` makes a -station deaf" - did not hold for it: under the monitor filter it keeps -receiving and only stops acknowledging (issue #461). +Every cell above ran the managed filter `0x00015f97`. +`Mt7612uRadio::StartRxLoop` installs the monitor filter (`PHY_ERR|CRC_ERR`), +and under it the property that justifies the seam's refusal - "moving +`MT_MAC_ADDR` makes a station deaf" - does not hold: a station keeps +receiving and only stops acknowledging. So the armed station runs the +managed filter, and an unarmed one (`DEVOURER_STA_ARM=0`) the monitor +filter. **The role is selected by the arm, with no new API.** A successful `SetStationIdentity` reads `MT_RX_FILTR_CFG`, writes @@ -354,13 +355,21 @@ injected or the check is INCONCLUSIVE. Then armed (`wpa2`), `not-for-us` must stay under 1% of the foreign stream; unarmed (`noarm`, the monitor filter) at least half of it must arrive. Hardware gate: `mt7612uprobe staid` checks the filter value across arm, re-request, refusal, -clear and drop. On-air numbers: TBD. +clear and drop. + +On air, ch6, near field, an MT7612U station against an RTL8812BU AP (rtw88), +one run per row on two benches. In every run the `noarm` control of the same +run saw the foreign stream arrive (bench B's: own-addressed 612 of 706). + +| bench | own-addressed arrived | foreign `not-for-us` | +|---|---|---| +| A | 882 of 882 | 0 of 761 | +| A | 943 of 943 | 0 of 927 | +| B | 775 of 863 | 0 of 642 | +| B | 875 of 875 | 0 of 713 | ## What is not established -- **The managed filter under a live association is not yet measured on - air** (TBD, above). The cells that measured it ran unassociated, through - the bring-up tool. - **No BSSID/auto-ACK cell drove `SetStationIdentity` through `IRadio`.** The seam writes no identity register - `mt7612uprobe staid` reads `MT_MAC_ADDR`, `MT_MAC_BSSID` and all eight APC slots before and after From 42fab159dbddc958741c7e670b5146ba982a3ace Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 15:55:31 +0200 Subject: [PATCH 48/53] sta_client: call the nudge an SSID-specific probe request build_probe_req addresses the probe to broadcast and carries the SSID element, so it is SSID-specific, not directed. The comments, the docs and one check message use that term. Behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/station-client.md | 8 ++++---- tests/sta_client.cpp | 13 +++++++------ tests/sta_client_onair.sh | 6 +++--- tests/sta_client_selftest.inc | 12 ++++++------ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/station-client.md b/docs/station-client.md index 90c15f5b..d521daf5 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -72,10 +72,10 @@ unassociated until its time is up, and the ledger ends `Failed` with the reason. The moment an association response is accepted - open or WPA2 - the -station sends one probe request (the "nudge", counted as `nudges` in the -ledger): see "AP quirk" below. On WPA2 a second one follows if no EAPOL has -arrived 1 s later; the four-way timeout re-joins if even that is not -enough. +station sends one SSID-specific probe request (to broadcast, carrying our +SSID; the "nudge", counted as `nudges` in the ledger): see "AP quirk" below. +On WPA2 a second one follows if no EAPOL has arrived 1 s later; the four-way +timeout re-joins if even that is not enough. An open association is confirmed by the AP's first unicast data frame to the station - one that carries an MSDU; a (QoS) Null, which an AP sends for diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index e9da096e..ce5d0cbc 100644 --- a/tests/sta_client.cpp +++ b/tests/sta_client.cpp @@ -439,8 +439,9 @@ std::atomic g_nudges{0}; * status (ACK) is in: it counts the station associated, and on WPA2 starts * the four-way, only from that status. Nothing else need make the AP * transmit to us soon, so the association - and the four-way - can stall for - * seconds while the station's traffic is dropped. One probe request, which - * every AP answers, makes it transmit and releases the held status. Sent the + * seconds while the station's traffic is dropped. One SSID-specific probe + * request (to broadcast, carrying our SSID), which the AP answers, makes it + * transmit and releases the held status. Sent the * moment an Association Response is accepted, open or WPA2; on WPA2 a * second one follows if no EAPOL has arrived kNudgeAgainMs later (supervise), * and the four-way timeout re-joins if even that is not enough. Caller holds @@ -807,10 +808,10 @@ uint8_t scan_step(uint32_t now) { return g_scan_chans[g_scan_idx]; } -/* A directed probe request for the SSID we want, on the channel we are on: - * it finds a hidden BSS and shortens the wait on a swept channel. False when - * none could be built or the full queue dropped it; only a queued one is - * counted. Caller holds g_mu. */ +/* An SSID-specific probe request (to broadcast) for the SSID we want, on the + * channel we are on: it finds a hidden BSS and shortens the wait on a swept + * channel. False when none could be built or the full queue dropped it; only + * a queued one is counted. Caller holds g_mu. */ bool probe(uint8_t chan) { std::vector m = devourer::sta::build_probe_req(g_own, g_ssid, chan, chan > 14); diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 3fa5308a..ab3e1f7d 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -49,9 +49,9 @@ # associates OUR address (hostapd's AP-STA-CONNECTED - its own # record) within 30 s, which covers recovering a first # association the AP did not hold (sta_client nudges the AP with -# a probe request on associating, and re-joins when its ARP gets -# no unicast reply, kConfirmMs); ping 0% loss over -# the TAP; the ledger shows plaintext and no decryption; the arm +# an SSID-specific probe request on associating, and re-joins +# when its ARP gets no unicast reply, kConfirmMs); ping 0% loss +# over the TAP; the ledger shows plaintext and no decryption; the arm # line; the clear ran on exit and verified. # wpa2 hostapd WPA2-PSK with group and pairwise rekeys: four-way, both # rekeys completed at the AP, ping 0% loss before and after them, diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index d31b9ce8..220b6ba8 100644 --- a/tests/sta_client_selftest.inc +++ b/tests/sta_client_selftest.inc @@ -1059,11 +1059,11 @@ void test_a_non_key_eapol_reaches_the_host() { "...and is not counted as a rekey"); } -/* THE NUDGE: an accepted Association Response queues one probe request at - * once, open or WPA2, so an AP that holds the response's TX status until its - * next transmission is made to transmit (hostapd starts both the association - * and the four-way from that status). On WPA2 a second one follows if no - * EAPOL has arrived kNudgeAgainMs later, and none once it has. */ +/* THE NUDGE: an accepted Association Response queues one SSID-specific + * probe request at once, open or WPA2, so an AP that holds the response's TX + * status until its next transmission is made to transmit (hostapd starts both + * the association and the four-way from that status). On WPA2 a second one + * follows if no EAPOL has arrived kNudgeAgainMs later, and none once it has. */ int probes_in(const std::vector>& tx) { int n = 0; for (const std::vector& f : tx) @@ -2508,7 +2508,7 @@ void test_scan_selects_the_wanted_network() { check(g_bss.count() >= 1, "the neighbour is in the table"); supervise(0); check(g_joins.load() == 0, "nothing joinable yet, so no join"); - check(g_probe_tx.load() > 0, "...but a directed probe went out"); + check(g_probe_tx.load() > 0, "...but an SSID-specific probe went out"); const std::vector ours = beacon(true); rx_frame(ours.data(), ours.size(), -70, 1000); /* weaker, and ours */ From 232a6310e7d90a1f5f45c68375156ec70995ef41 Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 16:11:48 +0200 Subject: [PATCH 49/53] tests: mark the Realtek DUT opened before its unbind; bound the injectors Marking the DUT opened only after sta_dev_unbind_wifi succeeded left a driverless adapter in two cases: an unbind cut short by Ctrl-C during its sleep, and an adapter with two netdevs that frees one and then fails. In both, .opened_dut was absent, so the hand-back skipped the `authorized` toggle. The mark goes back before the unbind. sta_dev_record now refuses a held adapter, so the toggle cannot land under a live process, and a toggle on an adapter its kernel driver still holds is harmless. The injection guard said the injection "ends at least 5 s before" the rekeys. What the code checks is now + INJECT_S + margin < the first rekey, and that margin must also absorb the vif add/delete and the injectors' start. The injectors are now killed at INJECT_S + 2 s (KILL 1 s later) instead of + 10 / -k 5. The margin rises to 8 s, so it covers that bound, the vif, and a remainder before the rekey. The comments and the doc say exactly that. The identity doc's measurement table names each row's head, and notes that every row ran the earlier after-the-ping schedule with REKEY_S=20. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 15 +++++++++------ docs/station-client.md | 6 ++++-- tests/mt7612u_sta_lib.sh | 4 ++-- tests/sta_client_onair.sh | 28 ++++++++++++++++------------ 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index 15153339..e63731c1 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -360,13 +360,16 @@ clear and drop. On air, ch6, near field, an MT7612U station against an RTL8812BU AP (rtw88), one run per row on two benches. In every run the `noarm` control of the same run saw the foreign stream arrive (bench B's: own-addressed 612 of 706). +Every row ran the injection after the four-way's ping, with `REKEY_S=20`; +none has yet run the schedule that starts it straight after the four-way, +clear of the rekeys. -| bench | own-addressed arrived | foreign `not-for-us` | -|---|---|---| -| A | 882 of 882 | 0 of 761 | -| A | 943 of 943 | 0 of 927 | -| B | 775 of 863 | 0 of 642 | -| B | 875 of 875 | 0 of 713 | +| bench | head | own-addressed arrived | foreign `not-for-us` | +|---|---|---|---| +| A | 4335ee4 | 882 of 882 | 0 of 761 | +| A | c54226e | 943 of 943 | 0 of 927 | +| B | af19b9c | 775 of 863 | 0 of 642 | +| B | af19b9c | 875 of 875 | 0 of 713 | ## What is not established diff --git a/docs/station-client.md b/docs/station-client.md index d521daf5..f582c176 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -153,10 +153,12 @@ first line then reads `fault=1`. vif on the AP's phy (`tests/sta_unicast_inject.py`) and run only for an MT7612U DUT; a phy that cannot add one makes the filter check INCONCLUSIVE, not the cell. They run straight after the four-way, and - only when they end - vif deleted - at least 5 s before hostapd's first + start only when now + `INJECT_S` + 8 s is still before hostapd's first group (`REKEY_S` after the AP comes up, default 30) and pairwise (`PTK_REKEY_S` after the four-way, default 25) rekeys, read off hostapd's - own stamps; otherwise the filter check is INCONCLUSIVE. A group rekey + own stamps; otherwise the filter check is INCONCLUSIVE. The injectors are + killed at `INJECT_S` + 2 s, so the 8 s cover them, the monitor vif's add + and delete, and a margin. A group rekey that lands in the vif teardown can go unanswered and cost the association. diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 12b5d6ac..4e35a69e 100644 --- a/tests/mt7612u_sta_lib.sh +++ b/tests/mt7612u_sta_lib.sh @@ -280,8 +280,8 @@ sta_usb_id() { # sta_dev_record NAME SYSFS VID PID - before the run: refuse a hub, any # device that is not VID:PID, and one a live process holds # (sta_usb_unheld), and note its idVendor:idProduct:serial; -# sta_dev_opened NAME - just before a process opens it, and after any -# sta_dev_unbind_wifi() has succeeded; +# sta_dev_opened NAME - before any sta_dev_unbind_wifi() and before a +# process opens it, so an unbind cut short is still handed back; # sta_dev_handback NAME SYSFS - re-enumerate it with an `authorized` 0/1 # toggle so its kernel driver binds again - only when this run opened it, # and only while SYSFS still reports the recorded identity, so a device diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index ab3e1f7d..d986399d 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -38,9 +38,11 @@ # it must reach half of what was injected, else the filter check is # INCONCLUSIVE. A phy that cannot add a monitor vif makes the check # INCONCLUSIVE, not the cell. The injection runs straight after the four-way, -# before the ping, and only when it - monitor vif deleted - ends at least -# 5 s before hostapd's first group and pairwise rekeys, read off hostapd's -# own stamps; otherwise it is skipped and the check is INCONCLUSIVE. A group +# before the ping. It starts only when now + INJECT_S + 8 s is still before +# hostapd's first group and pairwise rekeys, read off hostapd's own stamps; +# otherwise it is skipped and the check is INCONCLUSIVE. The injectors are +# killed at INJECT_S + 2 s (KILL 1 s later), so the 8 s cover them, the +# monitor vif's add and delete, and leave a margin before the rekey. A group # rekey that lands in the vif teardown can go unanswered and cost the # association. # @@ -338,11 +340,12 @@ trap 'cleanup; exit 3' INT TERM if [ "$DUT_KIND" = mt7612u ]; then sta_dut_take || exit 2 else - # Marked opened only once the unbind has succeeded, so cleanup toggles - # `authorized` only on an adapter this run freed (and nothing held). + # Marked opened BEFORE the unbind: an unbind cut short (INT, or one of two + # netdevs freed) must still be handed back. sta_dev_record has refused a + # held adapter, so the toggle never lands under a live process. sta_dev_record dut "$DUT_SYSFS" "$DUT_VID" "$DUT_PID" || exit 2 - sta_dev_unbind_wifi "$DUT_SYSFS" || exit 2 sta_dev_opened dut + sta_dev_unbind_wifi "$DUT_SYSFS" || exit 2 fi if command -v nmcli >/dev/null 2>&1; then @@ -587,13 +590,14 @@ check_cleared() { # $1 cell # could not run). INJ_FOREIGN=""; INJ_OWN=""; INJ_SKIP="" inject_count() { sed -n 's/^injected \([0-9][0-9]*\) unicast frames.*/\1/p' "$1" 2>/dev/null | tail -1; } -# 0 when INJECT_S, plus 5 s for the monitor vif and hostapd's early group -# timer, ends before both first rekeys: the group one REKEY_S after +# 0 when now + INJECT_S + 8 s is before both first rekeys - the injectors' +# bound (INJECT_S + 2, KILL at + 3), the monitor vif's add and delete, and a +# margin for hostapd's early group timer: the group one REKEY_S after # AP-ENABLED, the pairwise one PTK_REKEY_S after the last four-way with $2. # Read off hostapd's -t stamps. $1 cell. rekey_clear() { awk -v own="EAPOL-4WAY-HS-COMPLETED $2" -v g="$REKEY_S" -v p="$PTK_REKEY_S" \ - -v need="$(( INJECT_S + 5 ))" -v now="$(date +%s.%N)" ' + -v need="$(( INJECT_S + 8 ))" -v now="$(date +%s.%N)" ' / AP-ENABLED/ && !e { e = $1 + 0 } index($0, own) { f = $1 + 0 } END { if (!e || !f) exit 1 @@ -608,7 +612,7 @@ inject_unicast() { # $1 cell own=$(own_of "$1") [ -n "$bssid" ] && [ -n "$own" ] || return 1 if ! rekey_clear "$1" "$own"; then - INJ_SKIP="the injection (${INJECT_S}s + 5s) would not end before hostapd's first rekey - raise REKEY_S / PTK_REKEY_S or lower INJECT_S" + INJ_SKIP="the injection (${INJECT_S}s + 8s) would not end before hostapd's first rekey - raise REKEY_S / PTK_REKEY_S or lower INJECT_S" return 1 fi ip netns exec "$NS" iw dev "$MON" del 2>/dev/null @@ -617,11 +621,11 @@ inject_unicast() { # $1 cell ip netns exec "$NS" iw dev "$MON" del 2>/dev/null return 1 fi - ip netns exec "$NS" timeout -k 5 $(( INJECT_S + 10 )) \ + ip netns exec "$NS" timeout -k 1 $(( INJECT_S + 2 )) \ python3 "$ROOT/tests/sta_unicast_inject.py" "$MON" "$FOREIGN" "$bssid" \ "$INJECT_S" "$INJECT_PPS" > "$OUT/inject_$1.log" 2>&1 & pf=$!; sta_pid_record inject "$pf" - ip netns exec "$NS" timeout -k 5 $(( INJECT_S + 10 )) \ + ip netns exec "$NS" timeout -k 1 $(( INJECT_S + 2 )) \ python3 "$ROOT/tests/sta_unicast_inject.py" "$MON" "$own" "$bssid" \ "$INJECT_S" "$INJECT_PPS" 2048 > "$OUT/inject_own_$1.log" 2>&1 & po=$!; sta_pid_record inject_own "$po" From b84f5da1cda81d232e8d1af9eef490f97c148e9c Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 16:40:32 +0200 Subject: [PATCH 50/53] docs(mt7612u-station-identity): the managed filter on the new injection schedule The table gains three bench-A rows from 42fab15 and 232a631. In these runs the injection starts straight after the four-way, clear of the rekeys, with REKEY_S=30. Own-addressed 833/833, 751/751 and 746/746 arrived; foreign not-for-us was 0/721, 0/683 and 0/684. The noarm control saw the foreign stream each time (own 746/746, 944/944, 821/821). A schedule column and a noarm column are added. The note says which heads ran which schedule. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index e63731c1..1a40152a 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -359,17 +359,21 @@ clear and drop. On air, ch6, near field, an MT7612U station against an RTL8812BU AP (rtw88), one run per row on two benches. In every run the `noarm` control of the same -run saw the foreign stream arrive (bench B's: own-addressed 612 of 706). -Every row ran the injection after the four-way's ping, with `REKEY_S=20`; -none has yet run the schedule that starts it straight after the four-way, -clear of the rekeys. - -| bench | head | own-addressed arrived | foreign `not-for-us` | -|---|---|---|---| -| A | 4335ee4 | 882 of 882 | 0 of 761 | -| A | c54226e | 943 of 943 | 0 of 927 | -| B | af19b9c | 775 of 863 | 0 of 642 | -| B | af19b9c | 875 of 875 | 0 of 713 | +run saw the foreign stream arrive; its own-addressed count is given where it +was recorded. The rows from 4335ee4, c54226e and af19b9c ran the injection +after the four-way's ping, with `REKEY_S=20`. The rows from 42fab15 and +232a631 ran it straight after the four-way, clear of the rekeys, with +`REKEY_S=30`. + +| bench | head | schedule | own-addressed arrived | foreign `not-for-us` | `noarm` own-addressed | +|---|---|---|---|---|---| +| A | 4335ee4 | after the ping | 882 of 882 | 0 of 761 | | +| A | c54226e | after the ping | 943 of 943 | 0 of 927 | | +| B | af19b9c | after the ping | 775 of 863 | 0 of 642 | 612 of 706 | +| B | af19b9c | after the ping | 875 of 875 | 0 of 713 | | +| A | 42fab15 | after the four-way | 833 of 833 | 0 of 721 | 746 of 746 | +| A | 232a631 | after the four-way | 751 of 751 | 0 of 683 | 944 of 944 | +| A | 232a631 | after the four-way | 746 of 746 | 0 of 684 | 821 of 821 | ## What is not established From ddec63275a78deee88e16d543766d77d599499a7 Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 17:46:03 +0200 Subject: [PATCH 51/53] docs, tests: correct the managed-filter table; a missing stamp is its own skip The managed-filter table had the 42fab15 row and the second 232a631 row swapped. Each row now matches its own log. The table also cites 0fa46cc's held PASS (943/943, 0/609) in place of the pre-rebase 4335ee4. It fills in the noarm counts that were recorded: 0fa46cc 724/740, c54226e 728/739. Bench B's single control figure (612/706) covered both of its runs, so it now sits in the note, not on one row. The unarmed control counts 9-31x as many foreign frames as were injected, while the own stream arrives 1:1. The AP very likely retransmits frames nobody acknowledges. The doc says so. The injector's docstring and the harness comment had claimed the frames never set Retry and that mac80211 injects them no-ack; both claims now stay within what the counts allow. rekey_clear returns its own codes for an unreadable log (2) and for a missing AP-ENABLED or four-way stamp (3). The skip names that case, not "would not end before the first rekey". The log is checked in the shell, because busybox awk exits 1 on a missing file. REKEY_S and PTK_REKEY_S must be at least 1: 0 switches hostapd's rekey off. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 29 ++++++++++++++++++----------- tests/sta_client_onair.sh | 28 ++++++++++++++++++++-------- tests/sta_unicast_inject.py | 18 ++++++++++-------- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index 1a40152a..9ba0dbbb 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -359,21 +359,28 @@ clear and drop. On air, ch6, near field, an MT7612U station against an RTL8812BU AP (rtw88), one run per row on two benches. In every run the `noarm` control of the same -run saw the foreign stream arrive; its own-addressed count is given where it -was recorded. The rows from 4335ee4, c54226e and af19b9c ran the injection -after the four-way's ping, with `REKEY_S=20`. The rows from 42fab15 and -232a631 ran it straight after the four-way, clear of the rekeys, with -`REKEY_S=30`. +run saw the foreign stream arrive. The rows from 0fa46cc, c54226e and af19b9c +ran the injection after the four-way's ping, with `REKEY_S=20`; the rows from +42fab15 and 232a631 ran it straight after the four-way, clear of the rekeys, +with `REKEY_S=30`. Bench B reported one `noarm` figure for its two runs +together: own-addressed 612 of 706. | bench | head | schedule | own-addressed arrived | foreign `not-for-us` | `noarm` own-addressed | |---|---|---|---|---|---| -| A | 4335ee4 | after the ping | 882 of 882 | 0 of 761 | | -| A | c54226e | after the ping | 943 of 943 | 0 of 927 | | -| B | af19b9c | after the ping | 775 of 863 | 0 of 642 | 612 of 706 | -| B | af19b9c | after the ping | 875 of 875 | 0 of 713 | | -| A | 42fab15 | after the four-way | 833 of 833 | 0 of 721 | 746 of 746 | +| A | 0fa46cc | after the ping | 943 of 943 | 0 of 609 | 724 of 740 | +| A | c54226e | after the ping | 943 of 943 | 0 of 927 | 728 of 739 | +| B | af19b9c | after the ping | 775 of 863 | 0 of 642 | (see above) | +| B | af19b9c | after the ping | 875 of 875 | 0 of 713 | (see above) | +| A | 42fab15 | after the four-way | 746 of 746 | 0 of 684 | 821 of 821 | | A | 232a631 | after the four-way | 751 of 751 | 0 of 683 | 944 of 944 | -| A | 232a631 | after the four-way | 746 of 746 | 0 of 684 | 821 of 821 | +| A | 232a631 | after the four-way | 833 of 833 | 0 of 721 | 746 of 746 | + +Unarmed, `not-for-us` runs far above the foreign count injected (bench A: +6836 of 501 on 0fa46cc, up to 27125 of 869 on 42fab15). The rtw88 AP very +likely retransmits each foreign frame, which nothing acknowledges, and every +copy is counted. The own-addressed stream, which the station acknowledges, +arrives 1:1. The control needs only half of the foreign count, so the excess +does not change its verdict. ## What is not established diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index d986399d..9407e7e8 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -206,6 +206,9 @@ for v in CH SECS REKEY_S PTK_REKEY_S PING_S DOWN_S REJOIN_S AP_OFDM_ONLY HOSTAPD case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac done [ "$PING_S" -ge 1 ] || { echo "PING_S must be at least 1"; exit 2; } +# 0 would switch hostapd's rekey off, and the wpa2 cell exists to see both. +[ "$REKEY_S" -ge 1 ] && [ "$PTK_REKEY_S" -ge 1 ] || + { echo "REKEY_S and PTK_REKEY_S must be at least 1"; exit 2; } if [ "$INJECT_S" -lt 1 ] || [ "$INJECT_PPS" -lt 1 ] || [ "$INJECT_PPS" -gt 2000 ]; then echo "INJECT_S must be at least 1, INJECT_PPS 1..2000 (the injector's cap)"; exit 2 fi @@ -581,8 +584,10 @@ check_cleared() { # $1 cell # The managed-filter stimulus (header): the two streams off a monitor vif on # the AP's own phy, while the station is associated. They share a transmitter -# address and get disjoint sequence ranges (0 and 2048) as a precaution only: -# neither sets Retry, so duplicate detection should not merge them anyway. +# address and get disjoint sequence ranges (0 and 2048), so duplicate +# detection cannot merge them: the injector sets no Retry bit, but the AP's +# hardware retransmits an unacknowledged frame with it set (the noarm +# control counts many times more foreign frames than were injected). # The injector counts frames it SUBMITTED, not frames that aired - # hence the own stream as the positive witness. Each PID is recorded on the # statement after its launch, and every injector is bounded by `timeout -k` @@ -594,13 +599,15 @@ inject_count() { sed -n 's/^injected \([0-9][0-9]*\) unicast frames.*/\1/p' "$1" # bound (INJECT_S + 2, KILL at + 3), the monitor vif's add and delete, and a # margin for hostapd's early group timer: the group one REKEY_S after # AP-ENABLED, the pairwise one PTK_REKEY_S after the last four-way with $2. -# Read off hostapd's -t stamps. $1 cell. +# Read off hostapd's -t stamps. $1 cell. 1 when the injection would not fit; +# 2 or 3 when the log, its AP-ENABLED or that four-way cannot be read. rekey_clear() { + [ -r "$OUT/hostapd_$1.log" ] || return 2 # busybox awk exits 1 on it awk -v own="EAPOL-4WAY-HS-COMPLETED $2" -v g="$REKEY_S" -v p="$PTK_REKEY_S" \ -v need="$(( INJECT_S + 8 ))" -v now="$(date +%s.%N)" ' / AP-ENABLED/ && !e { e = $1 + 0 } index($0, own) { f = $1 + 0 } - END { if (!e || !f) exit 1 + END { if (!e || !f) exit 3 d = e + g; if (f + p < d) d = f + p exit !(now + need < d) }' "$OUT/hostapd_$1.log" } @@ -611,10 +618,15 @@ inject_unicast() { # $1 cell bssid=$(ip netns exec "$NS" cat "/sys/class/net/$AP_IF/address" 2>/dev/null) own=$(own_of "$1") [ -n "$bssid" ] && [ -n "$own" ] || return 1 - if ! rekey_clear "$1" "$own"; then - INJ_SKIP="the injection (${INJECT_S}s + 8s) would not end before hostapd's first rekey - raise REKEY_S / PTK_REKEY_S or lower INJECT_S" - return 1 - fi + local rc=0 + rekey_clear "$1" "$own" 2>/dev/null || rc=$? + case "$rc" in + 0) ;; + 1) INJ_SKIP="the injection (${INJECT_S}s + 8s) would not end before hostapd's first rekey - raise REKEY_S / PTK_REKEY_S or lower INJECT_S" + return 1 ;; + *) INJ_SKIP="no AP-ENABLED or four-way stamp for $own in $OUT/hostapd_$1.log - the rekeys cannot be placed, so the injection was not run" + return 1 ;; + esac ip netns exec "$NS" iw dev "$MON" del 2>/dev/null if ! { ip netns exec "$NS" iw phy "$AP_PHY" interface add "$MON" type monitor 2>/dev/null && ip netns exec "$NS" ip link set "$MON" up 2>/dev/null; }; then diff --git a/tests/sta_unicast_inject.py b/tests/sta_unicast_inject.py index 4f2b6208..b3a65647 100755 --- a/tests/sta_unicast_inject.py +++ b/tests/sta_unicast_inject.py @@ -15,14 +15,16 @@ sta_unicast_inject.py [seconds] [pps] [seq0] seq0 (0..4095, default 0) is the first sequence number. Two injectors sharing -a transmitter address can be given disjoint ranges. That is a precaution, not -a measured need: these frames never set Retry, so 802.11 duplicate detection -should not merge the two streams anyway. - -Note what this does NOT do: mac80211 marks injected frames no-ack by default, -so these do not solicit an acknowledgement and cannot be used to measure one. -Acknowledgement is measured by tests/mt7612u_sta_autoack.sh, which asks the -transmitter (a Realtek peer's per-frame CCX reports). +a transmitter address can be given disjoint ranges, so 802.11 duplicate +detection cannot merge the two streams. This script sets no Retry bit, but the +AP's hardware may retransmit a frame nobody acknowledges, with Retry set: in +tests/sta_client_onair.sh an unarmed station counts 10-30x as many frames to +an address nobody holds as were injected, while the stream to its own +address, which it acknowledges, arrives 1:1. + +Note what this does NOT do: it never sees an acknowledgement, so it cannot +measure one. Acknowledgement is measured by tests/mt7612u_sta_autoack.sh, +which asks the transmitter (a Realtek peer's per-frame CCX reports). """ import signal import socket From a66f659d76c33f67d63ddd4da2cb26e160de813b Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 20:03:53 +0200 Subject: [PATCH 52/53] tests: inject after the cell's ping, with rekey defaults sized for it Injected before the ping, the managed-filter stimulus cost the wpa2 cell its first echo on a reviewer's bench, 2 of 2 runs: seq 1 "Destination Host Unreachable", seq 2 at 464 ms. The noarm window lost one of 60. The rtw88 AP keeps retransmitting the unacknowledged foreign stream (6030 seen unarmed for 770 injected), and the ARP and the first echo sit behind that backlog. Ping-then-inject was clean in every run. So the injection runs after the ping again, in wpa2 and noarm alike. It is still gated by rekey_clear. The defaults are sized for the longer case, noarm: the four-way ~2 s after AP-ENABLED, a warm-up ping of up to 3 s, a 30 s PING_S window, then INJECT_S + 8 = 18 s. That is about 53 s from AP-ENABLED and 51 s from the four-way. REKEY_S=90 and PTK_REKEY_S=80 leave about 30 s for a slower join or host. The only ping after an injection is wpa2's ping after the rekeys, about a minute later. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 12 +++++++----- docs/station-client.md | 8 +++++--- tests/sta_client_onair.sh | 30 +++++++++++++++++++----------- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index 9ba0dbbb..bf2a87c2 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -361,8 +361,10 @@ On air, ch6, near field, an MT7612U station against an RTL8812BU AP (rtw88), one run per row on two benches. In every run the `noarm` control of the same run saw the foreign stream arrive. The rows from 0fa46cc, c54226e and af19b9c ran the injection after the four-way's ping, with `REKEY_S=20`; the rows from -42fab15 and 232a631 ran it straight after the four-way, clear of the rekeys, -with `REKEY_S=30`. Bench B reported one `noarm` figure for its two runs +42fab15 and 232a631 ran it before the ping, with `REKEY_S=30`. The harness +runs it after the ping and clear of the rekeys (`REKEY_S=90`, +`PTK_REKEY_S=80`): injected before the ping, the AP's retransmissions of the +foreign stream delay the ping's first echo. Bench B reported one `noarm` figure for its two runs together: own-addressed 612 of 706. | bench | head | schedule | own-addressed arrived | foreign `not-for-us` | `noarm` own-addressed | @@ -371,9 +373,9 @@ together: own-addressed 612 of 706. | A | c54226e | after the ping | 943 of 943 | 0 of 927 | 728 of 739 | | B | af19b9c | after the ping | 775 of 863 | 0 of 642 | (see above) | | B | af19b9c | after the ping | 875 of 875 | 0 of 713 | (see above) | -| A | 42fab15 | after the four-way | 746 of 746 | 0 of 684 | 821 of 821 | -| A | 232a631 | after the four-way | 751 of 751 | 0 of 683 | 944 of 944 | -| A | 232a631 | after the four-way | 833 of 833 | 0 of 721 | 746 of 746 | +| A | 42fab15 | before the ping | 746 of 746 | 0 of 684 | 821 of 821 | +| A | 232a631 | before the ping | 751 of 751 | 0 of 683 | 944 of 944 | +| A | 232a631 | before the ping | 833 of 833 | 0 of 721 | 746 of 746 | Unarmed, `not-for-us` runs far above the foreign count injected (bench A: 6836 of 501 on 0fa46cc, up to 27125 of 869 on 42fab15). The rtw88 AP very diff --git a/docs/station-client.md b/docs/station-client.md index f582c176..f83cdb05 100644 --- a/docs/station-client.md +++ b/docs/station-client.md @@ -152,10 +152,12 @@ first line then reads `fault=1`. The injections (`INJECT_S`, `INJECT_PPS` each, `FOREIGN`) ride a monitor vif on the AP's phy (`tests/sta_unicast_inject.py`) and run only for an MT7612U DUT; a phy that cannot add one makes the filter check - INCONCLUSIVE, not the cell. They run straight after the four-way, and + INCONCLUSIVE, not the cell. They run after the cell's ping - the AP keeps + retransmitting the unacknowledged foreign stream for seconds after the + injectors stop, and a ping behind that backlog loses its first echo - and start only when now + `INJECT_S` + 8 s is still before hostapd's first - group (`REKEY_S` after the AP comes up, default 30) and pairwise - (`PTK_REKEY_S` after the four-way, default 25) rekeys, read off hostapd's + group (`REKEY_S` after the AP comes up, default 90) and pairwise + (`PTK_REKEY_S` after the four-way, default 80) rekeys, read off hostapd's own stamps; otherwise the filter check is INCONCLUSIVE. The injectors are killed at `INJECT_S` + 2 s, so the 8 s cover them, the monitor vif's add and delete, and a margin. A group rekey diff --git a/tests/sta_client_onair.sh b/tests/sta_client_onair.sh index 9407e7e8..f1ab1ba2 100755 --- a/tests/sta_client_onair.sh +++ b/tests/sta_client_onair.sh @@ -37,8 +37,10 @@ # station refuses it on a WPA2 link and counts it (`plaintext refused`), and # it must reach half of what was injected, else the filter check is # INCONCLUSIVE. A phy that cannot add a monitor vif makes the check -# INCONCLUSIVE, not the cell. The injection runs straight after the four-way, -# before the ping. It starts only when now + INJECT_S + 8 s is still before +# INCONCLUSIVE, not the cell. The injection runs after the cell's ping, not +# before it: the AP keeps retransmitting the unacknowledged foreign stream +# for seconds after the injectors stop, and a ping behind that backlog loses +# its first echo. It starts only when now + INJECT_S + 8 s is still before # hostapd's first group and pairwise rekeys, read off hostapd's own stamps; # otherwise it is skipped and the check is INCONCLUSIVE. The injectors are # killed at INJECT_S + 2 s (KILL 1 s later), so the 8 s cover them, the @@ -168,9 +170,13 @@ SECS="${SECS:-90}" # fire inside the run: the rekeys travel inside the cipher, a path the # four-way alone never exercises. The group timer starts with the AP and the # pairwise one at the four-way; the injection (INJECT_S) has to fit, with -# its margin, before the first of them. -REKEY_S="${REKEY_S:-30}" -PTK_REKEY_S="${PTK_REKEY_S:-25}" +# its margin, before the first of them, and it runs after the cell's ping. +# The longer case is noarm: four-way ~2 s after AP-ENABLED, the warm-up ping +# (<= 3 s) and the PING_S window (30 s), then INJECT_S + 8 s (18 s) - about +# 53 s from AP-ENABLED, 51 s from the four-way. 90 and 80 leave ~30 s for a +# slower join or host. +REKEY_S="${REKEY_S:-90}" +PTK_REKEY_S="${PTK_REKEY_S:-80}" # The ping window behind every reported link line and the reconnect cell's # post-re-join check: PING_S seconds at 2 pings a second. PING_S="${PING_S:-30}" @@ -934,18 +940,20 @@ run_wpa2() { cell_end return 0 fi - # The managed-filter stimulus first, so that it is over before the rekeys. - INJ_FOREIGN=""; INJ_OWN=""; INJ_SKIP="" - if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then - inject_unicast "$cell" - sta_pid_alive "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } - fi local p=0 if [ "$cell" = wpa2 ]; then ping_ap "$cell" || p=$? else ping_window "$cell" || p=$?; fi if [ "$p" = 2 ]; then station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; fi WPA2_LINK="four-way completed, ping $(loss "$cell")" [ "$p" = 0 ] && WPA2_LINK="$WPA2_LINK OK" + # The managed-filter stimulus after the ping (the AP's retransmission + # backlog of the foreign stream must not sit in front of it), and before + # the rekeys (rekey_clear). + INJ_FOREIGN=""; INJ_OWN=""; INJ_SKIP="" + if [ "$DUT_KIND" = mt7612u ] && { [ "$cell" = wpa2 ] || [ "$cell" = noarm ]; }; then + inject_unicast "$cell" + sta_pid_alive "$STA_PID" || { station_gone "$cell"; sta_pid_kill_hard hostapd; return 1; } + fi [ "$cell" = wpa2 ] || { cell_end; return 0; } # The rekeys: waited for while the station is alive. "pairwise key From fba6700a774cd6254dc6a614179dd8c33c409178 Mon Sep 17 00:00:00 2001 From: snokvist Date: Thu, 8 Oct 2026 20:18:49 +0200 Subject: [PATCH 53/53] docs: the identity table carries the ddec632 and a66f659 runs Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VNC8xhn1rNCi5t6uLvE6M3 --- docs/mt7612u-station-identity.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index bf2a87c2..d89c33ee 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -361,10 +361,11 @@ On air, ch6, near field, an MT7612U station against an RTL8812BU AP (rtw88), one run per row on two benches. In every run the `noarm` control of the same run saw the foreign stream arrive. The rows from 0fa46cc, c54226e and af19b9c ran the injection after the four-way's ping, with `REKEY_S=20`; the rows from -42fab15 and 232a631 ran it before the ping, with `REKEY_S=30`. The harness -runs it after the ping and clear of the rekeys (`REKEY_S=90`, -`PTK_REKEY_S=80`): injected before the ping, the AP's retransmissions of the -foreign stream delay the ping's first echo. Bench B reported one `noarm` figure for its two runs +42fab15, 232a631 and ddec632 ran it before the ping, with `REKEY_S=30`; the +rows from a66f659 run it as the harness does, after the ping and clear of the +rekeys (`REKEY_S=90`, `PTK_REKEY_S=80`). Injected before the ping, the ping +lost its first echo on bench B, most likely behind the AP's retransmissions +of the foreign stream. Bench B reported one `noarm` figure for its two runs together: own-addressed 612 of 706. | bench | head | schedule | own-addressed arrived | foreign `not-for-us` | `noarm` own-addressed | @@ -376,6 +377,9 @@ together: own-addressed 612 of 706. | A | 42fab15 | before the ping | 746 of 746 | 0 of 684 | 821 of 821 | | A | 232a631 | before the ping | 751 of 751 | 0 of 683 | 944 of 944 | | A | 232a631 | before the ping | 833 of 833 | 0 of 721 | 746 of 746 | +| A | ddec632 | before the ping | 764 of 764 | 0 of 739 | 870 of 870 | +| A | a66f659 | after the ping | 893 of 893 | 0 of 796 | 720 of 943 | +| A | a66f659 | after the ping | 942 of 942 | 0 of 789 | 700 of 744 | Unarmed, `not-for-us` runs far above the foreign count injected (bench A: 6836 of 501 on 0fa46cc, up to 27125 of 869 on 42fab15). The rtw88 AP very