diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a666623..4b7dd096 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1091,7 +1091,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/mt7612u-station-identity.md b/docs/mt7612u-station-identity.md index 75d0f56e..d89c33ee 100644 --- a/docs/mt7612u-station-identity.md +++ b/docs/mt7612u-station-identity.md @@ -27,9 +27,12 @@ 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()` -programs, prints it per arm, and flags an arm that is not running it. +(`OTHER_BSS`) is clear but bit **2** (`PROMISC`) is set, and bit 2 is the +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 @@ -281,20 +284,119 @@ 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`. +`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 +`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 (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; 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()` 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`. + +**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/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 +`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, 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, 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 | +|---|---|---|---|---|---| +| 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 | 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 +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 -- **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. +- **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/docs/station-client.md b/docs/station-client.md index 8e7b917b..f83cdb05 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 @@ -50,7 +57,59 @@ 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". 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) 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. +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. + +The moment an association response is accepted - open or WPA2 - the +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 +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 +`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 +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=`). 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`, @@ -68,33 +127,90 @@ 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` | 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) | + | `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 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. 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 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 + 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 + 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) 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. + +## 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 -- 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 @@ -102,3 +218,13 @@ first line then reads `fault=1`. - 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/src/AdapterCaps.h b/src/AdapterCaps.h index ef7dc4a8..334abc38 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 d5e6f7e3..5b54b7dd 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 bc9ef7ce..1067f5b8 100644 --- a/src/mt7612u/Mt7612uRadio.cpp +++ b/src/mt7612u/Mt7612uRadio.cpp @@ -393,6 +393,11 @@ 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 + * 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 * lock StopRxLoop uses - and released before the sleep loop below, which @@ -440,6 +445,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 +466,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 at RX start)", + _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 @@ -1035,7 +1043,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, @@ -1045,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 @@ -1069,10 +1078,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..ba192bb9 100644 --- a/src/mt7612u/StationIdentity.h +++ b/src/mt7612u/StationIdentity.h @@ -130,6 +130,15 @@ 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`, + * `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`. @@ -143,13 +152,34 @@ 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. 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) + const uint8_t *own, const uint8_t *bssid, + uint32_t cur_filtr) { + 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) @@ -179,6 +209,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->stranded) + 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 6ddf6cbb..3dd958ac 100644 --- a/src/mt7612u/include/mt7612u/mt7612u.h +++ b/src/mt7612u/include/mt7612u/mt7612u.h @@ -258,6 +258,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); @@ -324,15 +328,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..7990f1aa 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,53 @@ 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) { + /* 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); 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 || 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 " + "retries it", dev->sta.rx_filtr_restore); + return -1; + } mt7612u_sta_clear(&dev->sta); + return 0; } /* @@ -175,30 +228,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 0532c34f..31363bd9 100644 --- a/src/mt7612u/tools/bringup.cpp +++ b/src/mt7612u/tools/bringup.cpp @@ -4515,9 +4515,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). @@ -5277,11 +5278,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]; }; @@ -5302,6 +5304,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) @@ -5318,8 +5331,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"); @@ -5333,8 +5351,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"); @@ -5342,8 +5362,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, @@ -5361,14 +5389,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 - @@ -5385,7 +5424,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 @@ -5402,12 +5442,37 @@ 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); + /* 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 - 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); + 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++; + mt7612u_clear_station_identity(&dev); } - mt7612u_clear_station_identity(&dev); #undef CHK printf("\nGATE STAID: %d passed, %d failed\n", pass, fail); 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..55bd3de3 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. @@ -321,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; @@ -565,6 +567,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 +601,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 +699,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/mt7612u_ap_onair.sh b/tests/mt7612u_ap_onair.sh index 2289c157..756db33f 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. @@ -65,24 +68,34 @@ 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. 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 + 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 - KIDS="" - return 0 + 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" ] } # Returns 1 when it could not reset the AP (a process outlived TERM): the @@ -133,10 +146,25 @@ 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 130' INT TERM +# 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 +# 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) @@ -343,10 +371,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 52f37ce3..bf34d3c7 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 @@ -61,7 +64,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' "$*"; } @@ -71,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 @@ -94,9 +100,9 @@ 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. -trap 'cleanup; exit 130' INT TERM +# 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 7d3ebe98..62e778d9 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 @@ -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 @@ -102,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 @@ -339,15 +345,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 diff --git a/tests/mt7612u_sta_lib.sh b/tests/mt7612u_sta_lib.sh index 45c8a338..4e35a69e 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: # @@ -10,17 +11,29 @@ # 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. Two runs with different +# 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 +# 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. -# - 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 @@ -56,41 +69,32 @@ 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. + rm -f "$OUT"/.id_* "$OUT"/.opened_* return 0 } sta_lock_release() { [ "$STA_LOCKED" = yes ] || return 0 STA_LOCKED=no - rm -rf "$OUT/.lock" + exec 9<&- } sta_is_mt7612u() { @@ -100,17 +104,69 @@ 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 -# 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. +# 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 + # 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: +# 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 - if [ -e "/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" ]; then + _sta_drv="/sys/bus/usb/devices/$DUT_SYSFS:1.0/driver" + 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_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 fi @@ -136,6 +192,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 @@ -150,6 +217,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 ] } @@ -159,13 +228,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 @@ -175,10 +245,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 @@ -191,68 +273,135 @@ 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 - fi - if [ "$(cat "$_sta_pd/bDeviceClass" 2>/dev/null)" = "09" ]; then - echo "refusing PEER_SYSFS=$PEER_SYSFS - a hub"; 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, 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 - 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 +# 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 - _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_unheld "$1" "$2" || return 1 + 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 - 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" +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 - 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 # 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 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 + _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 [ ! -f "$_sta_p" ] || [ ! -r "$_sta_p" ] || [ ! -s "$_sta_p" ]; then + echo "refusing: $_sta_p is not a readable, non-empty file" + return 1 + fi + done + return 0 } sta_fw_unlink() { diff --git a/tests/mt7612u_sta_onair.sh b/tests/mt7612u_sta_onair.sh deleted file mode 100755 index f4bc009d..00000000 --- a/tests/mt7612u_sta_onair.sh +++ /dev/null @@ -1,559 +0,0 @@ -#!/usr/bin/env bash -# mt7612u_sta_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 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 -# never touches the air. The phy moves with `iw phy set netns`, and -# every data-plane check first asserts that `ip route get` leaves through the -# station's TAP. -# -# 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. -# 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). -# 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). -# 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. -# -# 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 -# loss on a working link. A station that exits, or is not up within -# 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. -# -# 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 -# -# 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. -# 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). -# 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). - -# The cells are reached as "cell_$c" and cleanup through the traps. -# shellcheck disable=SC2317 -set -u -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -BUILD="${BUILD:-$ROOT/build}" -DUT_SYSFS="${DUT_SYSFS:-}" -AP_SYSFS="${AP_SYSFS:-}" -CH="${CH:-6}" -SSID="${SSID:-devourerSTA}" -PSK="${PSK:-devourer123}" -# Budget from process start, not from association: firmware load, the TAP and -# the association all come before the measurement. -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}" -PTK_REKEY_S="${PTK_REKEY_S:-25}" -FW_DIR="${FW_DIR:-/lib/firmware/mediatek}" -NS="${NS:-staonair}" -TAP="${TAP:-dvsta0}" -READY_TIMEOUT="${READY_TIMEOUT:-30}" -OUT="${OUT:-}" -APIP=192.168.98.1 -STAIP=192.168.98.2 - -CELLS="${*:-all}" -[ "$CELLS" = all ] && CELLS="open wpa2 noarm retry0" -for c in $CELLS; do - case "$c" in open|wpa2|noarm|retry0) ;; *) 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 - case "${!v}" in ''|*[!0-9]*) echo "$v must be a non-negative integer"; exit 2 ;; esac -done -[ -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; } -command -v hostapd >/dev/null || { echo "hostapd is required"; exit 2; } -[ -x "$BUILD/sta_client" ] || { - echo "$BUILD/sta_client is not built (cmake --build build --target StaClientSelftest)"; exit 2; } -if [ -e "/sys/class/net/$TAP" ]; then - echo "TAP=$TAP already exists - refusing to use or delete it (set TAP=)"; exit 2 -fi -ns_exists() { ip netns list 2>/dev/null | awk '{print $1}' | grep -qx "$NS"; } -if ns_exists; then - echo "netns $NS already exists - recover or remove it first (set NS= to use another name)" - exit 2 -fi - -# shellcheck source=tests/mt7612u_sta_lib.sh -. "$ROOT/tests/mt7612u_sta_lib.sh" -sta_out_prepare || exit 2 -sta_lock_take || exit 2 -sta_pid_init sta hostapd - -# --- 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; } -[ -n "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" ] || - ap_refuse "not a USB device - if its driver just loaded it may have moved; re-read lsusb -t" -[ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/bDeviceClass" 2>/dev/null)" != "09" ] || ap_refuse "a hub" -AP_IF=$(sta_first_netdev "$AP_SYSFS") -[ -n "$AP_IF" ] || ap_refuse "no network interface on it" -[ -e "/sys/class/net/$AP_IF/phy80211" ] || ap_refuse "$AP_IF is not wireless" -for fam in -4 -6; do - ip "$fam" route show default 2>/dev/null | grep -qw "dev $AP_IF" && - ap_refuse "$AP_IF carries a default route" -done -AP_PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") -iw phy "$AP_PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($AP_PHY) does not support AP mode" -iw phy "$AP_PHY" info 2>/dev/null | grep -q set_wiphy_netns || - ap_refuse "the AP phy cannot change network namespace (in-kernel cfg80211 driver needed, e.g. rtw88/mt76; out-of-tree rtl88x2cu/88x2bu cannot)" -# Restored after the phy comes back: the move takes the interface down. -AP_WAS_UP=no -ip link show "$AP_IF" 2>/dev/null | grep -q '[<,]UP[,>]' && AP_WAS_UP=yes - -# Flags and traps BEFORE anything is taken, so every exit from here on hands -# back what was. -NM_AP=no; NS_OURS=no; CLEANED=no; STA_HUNG=no; STA_PID=""; CELL="" -cleanup() { - [ "$CLEANED" = yes ] && return 0 - CLEANED=yes - local sta_gone=0 - # 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 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 - # brings it back). So the delete is conditional on the move having worked. - if [ "$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 - echo "WARNING: a phy is still in netns $NS - NOT deleting it. Recover with:" - echo " sudo ip netns exec $NS iw phy $AP_PHY set netns 1; sudo ip netns del $NS" - else - ip netns del "$NS" 2>/dev/null - [ "$AP_WAS_UP" = yes ] && ip link set "$AP_IF" up 2>/dev/null - fi - fi - [ "$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 - sta_lock_release -} -trap cleanup EXIT -trap 'cleanup; exit 3' INT TERM - -sta_dut_take || exit 2 - -if command -v nmcli >/dev/null 2>&1; then - case "$(nmcli -t -f DEVICE,STATE device 2>/dev/null | grep "^$AP_IF:")" in - "$AP_IF:unmanaged"|"") : ;; - *) nmcli device set "$AP_IF" managed no >/dev/null 2>&1 && NM_AP=yes ;; - esac -fi -rfkill unblock wlan 2>/dev/null -NS_OURS=yes -ip netns add "$NS" || { echo "could not create netns $NS"; exit 2; } -iw phy "$AP_PHY" set netns name "$NS" || { echo "could not move $AP_PHY into $NS"; exit 2; } -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 "AP $AP_IF ($AP_PHY) at $AP_SYSFS, in netns $NS" -echo "ch$CH ssid '$SSID' tap $TAP cells: $CELLS" -echo "logs: $OUT" - -pass=0; fail=0; inconclusive=0 -ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } -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 - local t=0 - until grep -qE "$2" "$1" 2>/dev/null; do - [ "$t" -ge "$3" ] && return 1 - sleep 1; t=$((t + 1)) - done -} - -# --- the AP ----------------------------------------------------------------- -# 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 - { - 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 - printf 'wpa=2\nwpa_passphrase=%s\nwpa_key_mgmt=WPA-PSK\nrsn_pairwise=CCMP\n' "$PSK" - printf 'wpa_group_rekey=%s\nwpa_ptk_rekey=%s\n' "$REKEY_S" "$PTK_REKEY_S" - 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 - # interface flags: No such device" / "nl80211 driver initialization - # failed"). Wait, bounded, until the netdev is present, and FORCE it to a - # station once it is: a driver may leave the vif in AP type after hostapd - # exits, and hostapd then fails with "Match already configured" rather - # than anything that names the problem (tests/mt7612u_sta_identity.sh). - local t=0 info - while :; do - info=$(ip netns exec "$NS" iw dev "$AP_IF" info 2>/dev/null) - case "$info" in - *'type managed'*) break ;; - '') ;; # not back yet - *) ip netns exec "$NS" ip link set "$AP_IF" down 2>/dev/null - ip netns exec "$NS" iw dev "$AP_IF" set type managed 2>/dev/null ;; - esac - if [ "$t" -ge 100 ]; then - echo "rig: $AP_IF not back as a managed netdev in netns $NS within 10 s" \ - "- hostapd not started" | tee "$OUT/hostapd_$2.log" - # The loop may just have taken it down; leave it up (best effort). - ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null - return 1 - fi - sleep 0.1; t=$((t + 1)) - done - ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null - ip netns exec "$NS" hostapd -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 - [ "$t" -ge 15 ] && return 1 - sleep 1; t=$((t + 1)) - done - ip netns exec "$NS" ip addr flush dev "$AP_IF" 2>/dev/null - ip netns exec "$NS" ip addr add "$APIP/24" dev "$AP_IF" -} - -# --- the station -------------------------------------------------------------- -# 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 \ - 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" \ - "$@" "$BUILD/sta_client" "$secs" > "$OUT/sta_$cell.log" 2>&1 & - STA_PID=$! - 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 - [ "$t" -ge "$READY_TIMEOUT" ] && return 2 - sleep 1; t=$((t + 1)) - done -} - -# The station never printed `sta_client up:` - a rig / bring-up problem, not -# a verdict on the station, whatever the exit status. $1 cell, $2 sta_up's rc. -station_not_up() { - if [ "$2" = 2 ]; then - inc "$1: sta_client not up within READY_TIMEOUT=${READY_TIMEOUT}s (rig/bring-up) - see $OUT/sta_$1.log" - return - fi - sta_stop - if [ "$STA_RC" = 2 ]; then - inc "$1: sta_client REFUSED the adapter (station_mode_ok false): $(grep -m1 REFUSED "$OUT/sta_$1.log")" - else - inc "$1: sta_client exited before 'up' (status $STA_RC; rig/bring-up): $(tail -1 "$OUT/sta_$1.log" 2>/dev/null)" - fi -} - -# Stop the station (INT: it leaves the BSS, clears the identity and prints its -# ledger) and record its exit status in STA_RC. -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 - local t=0 - while proc_running "$STA_PID" && [ "$t" -lt 15 ]; do sleep 1; t=$((t + 1)); done - if proc_running "$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 - STA_RC=killed - else - wait "$STA_PID" 2>/dev/null; STA_RC=$? - rm -f "$OUT/.pid_sta" - fi - STA_PID="" - # Exit 3 is a fault the station caught and tore down cleanly: a FAIL - # wherever it happens, with the cause named. - if [ "$STA_RC" = 3 ] && [ -n "$CELL" ]; then - bad "$CELL: sta_client FAULT (exit 3): $(fault_cause "$CELL")" - fi -} - -# The TAP up, and the route to the AP proven to leave through it. -tap_up() { - local t=0 - until [ -d "/sys/class/net/$TAP" ]; do - [ "$t" -ge 20 ] && return 1 - sleep 1; t=$((t + 1)) - done - command -v nmcli >/dev/null 2>&1 && nmcli device set "$TAP" managed no >/dev/null 2>&1 - ip link set "$TAP" up 2>/dev/null - ip addr flush dev "$TAP" 2>/dev/null - ip addr add "$STAIP/24" dev "$TAP" 2>/dev/null - sleep 1 - case "$(ip route get "$APIP" 2>/dev/null)" in *"dev $TAP"*) return 0 ;; esac - return 1 -} - -own_of() { sed -n 's/^sta_client up: own \([0-9a-f:]\{17\}\) .*/\1/p' "$OUT/sta_$1.log" | head -1; } -# One numeric field from the station's exit ledger. -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 - 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 station exited after `sta_client up:` but before its measurement: -# status 0 ran out of SECS (INCONCLUSIVE); anything else is a FAIL. -station_gone() { # $1 cell - sta_stop - case "$STA_RC" in - 0) inc "$1: sta_client ran out of time before the measurement - raise SECS (now $SECS)" ;; - 3) ;; # a FAULT: reported by sta_stop - *) bad "$1: sta_client exited early (status $STA_RC) - see $OUT/sta_$1.log" ;; - esac -} - -# The cause of a sta_client FAULT (exit 3, `fault=1` in the ledger). -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. -check_cleared() { # $1 cell - local line - line=$(grep -m1 'station identity clear:' "$OUT/sta_$1.log" | sed 's/^ *//') - if [ -n "$line" ]; then - 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 -} - -# Arm and clear lines: the identity was armed for the AP's BSSID, and the -# clear ran on the way out. -check_armed() { # $1 cell - if grep -q '^ station identity armed for BSSID' "$OUT/sta_$1.log"; then - ok "$1: SetStationIdentity armed ($(grep -m1 '^ station identity armed' "$OUT/sta_$1.log" | sed 's/^ *//'))" - else - bad "$1: the identity was never armed ($(grep -m1 'station identity' "$OUT/sta_$1.log" || echo 'no arm line'))" - fi - check_cleared "$1" -} - -cell_end() { sta_stop; sta_pid_kill hostapd; } - -# --- open --------------------------------------------------------------------- -cell_open() { - CELL=open - echo; echo "== open: hostapd open network ==" - ap_up open open || { inc "open: hostapd did not bring $AP_IF up in AP mode - see $OUT/hostapd_open.log"; cell_end; return; } - local up=0 - sta_up open "$SECS" DEVOURER_STA_PSK= || up=$? - [ "$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; } - 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 - else station_gone open; sta_pid_kill hostapd; fi - return - fi - ok "open: the AP associated $own" - 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 ;; - esac - cell_end - local plain enc - plain=$(led open 'plaintext rx'); enc=$(led open 'encrypted rx') - if [ "${plain:-0}" -gt 0 ] && [ "${enc:-x}" = 0 ]; then - ok "open: ledger plaintext rx=$plain, encrypted rx=0" - else - bad "open: ledger plaintext rx=${plain:-?} encrypted rx=${enc:-?} (expected >0 and 0)" - fi - check_armed 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. -WPA2_LINK="" -run_wpa2() { - local cell="$1"; shift - CELL="$cell" - WPA2_LINK="" - ap_up wpa2 "$cell" || { inc "$cell: hostapd did not bring $AP_IF up in AP mode - see $OUT/hostapd_$cell.log"; cell_end; return 1; } - local secs=$(( SECS + 2 * REKEY_S + PTK_REKEY_S )) - local up=0 - sta_up "$cell" "$secs" DEVOURER_STA_PSK="$PSK" "$@" || up=$? - [ "$up" = 0 ] || { station_not_up "$cell" "$up"; cell_end; return 1; } - 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 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 - cell_end - return 0 - fi - local p=0 - ping_ap "$cell" || p=$? - 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" - [ "$cell" = wpa2 ] || { cell_end; return 0; } - - # The rekeys: waited for while the station is alive. "pairwise key - # 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 - 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 - station_gone "$cell"; sta_pid_kill 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 - if [ "${pk:-0}" -ge 2 ]; then ok "$cell: the AP completed a pairwise rekey ($pk pairwise handshakes)" - else bad "$cell: no pairwise rekey in ${lim}s (${pk:-0} pairwise handshake(s))"; fi - # Still carrying traffic after both rekeys. - 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 ;; - esac - cell_end - return 0 -} - -cell_wpa2() { - echo; echo "== wpa2: hostapd WPA2-PSK, group rekey ${REKEY_S}s, pairwise rekey ${PTK_REKEY_S}s ==" - run_wpa2 wpa2 || return - case "$WPA2_LINK" in - *OK) ok "wpa2: $WPA2_LINK" ;; - *) 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 - assoc=$(led wpa2 'associations'); mic=$(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)" - else - bad "wpa2: ledger associations=${assoc:-?} answered=${ans:-?} PTK=${ptk:-?} MIC failures=${mic:-?} (expected 1, >0, >=2, MIC <= PTK)" - fi - check_armed wpa2 - # 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" - else - ok "wpa2: no tx.retry_limit=0 warning ($(grep -m1 'tx.retry_limit' "$OUT/sta_wpa2.log" | sed 's/^ *//'))" - fi -} - -cell_noarm() { - echo; echo "== noarm (control): wpa2 with DEVOURER_STA_ARM=0 ==" - run_wpa2 noarm DEVOURER_STA_ARM=0 || return - if grep -q 'sta_client up:.* arm=0' "$OUT/sta_noarm.log" && - ! grep -q 'station identity' "$OUT/sta_noarm.log"; then - ok "noarm: no SetStationIdentity and no ClearStationIdentity ran" - 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)" -} - -cell_retry0() { - echo; echo "== retry0: wpa2 with DEVOURER_TX_RETRY_LIMIT=0 ==" - run_wpa2 retry0 DEVOURER_TX_RETRY_LIMIT=0 || return - local armed warn - armed=$(grep -n -m1 '^ station identity armed for BSSID' "$OUT/sta_retry0.log" | cut -d: -f1) - warn=$(grep -n -m1 'station identity armed with tx.retry_limit=0' "$OUT/sta_retry0.log" | cut -d: -f1) - if [ -z "$armed" ]; then - inc "retry0: the identity was never armed, so the arm-time warning could not fire - see $OUT/sta_retry0.log" - elif [ -n "$warn" ] && [ "$warn" -lt "$armed" ]; then - ok "retry0: the library warned at arm time: $(sed -n "${warn}p" "$OUT/sta_retry0.log" | cut -c1-100)..." - else - bad "retry0: armed with tx.retry_limit=0 and no arm-time warning" - fi - check_cleared retry0 - info "retry0: single-shot uplink: ${WPA2_LINK:-no result}" -} - -for c in $CELLS; do "cell_$c"; done - -echo -echo "=== $pass passed, $fail failed, $inconclusive inconclusive (logs: $OUT) ===" -[ "$fail" -gt 0 ] && exit 1 -[ "$inconclusive" -gt 0 ] && exit 2 -exit 0 diff --git a/tests/mt7612u_sta_uplink.sh b/tests/mt7612u_sta_uplink.sh index 759df93d..92bb21d6 100755 --- a/tests/mt7612u_sta_uplink.sh +++ b/tests/mt7612u_sta_uplink.sh @@ -46,6 +46,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)" @@ -83,7 +86,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' "$*"; } @@ -93,6 +96,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 @@ -113,9 +119,9 @@ 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. -trap 'cleanup; exit 130' INT TERM +# 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 echo "DUT MT7612U at $DUT_SYSFS transmitting to $TARGET" diff --git a/tests/mt7612u_station_selftest.cpp b/tests/mt7612u_station_selftest.cpp index 58f2ce1f..4a10f83c 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,104 @@ 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); + + /* 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); +} + +/* 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() { @@ -222,6 +324,9 @@ 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(); + test_stranded_undo_keeps_the_pre_arm_filter(); if (failures) { std::fprintf(stderr, "mt7612u_station_selftest: %d failure(s)\n", failures); diff --git a/tests/realtek_station_onair.sh b/tests/realtek_station_onair.sh index 3da2613c..810890cc 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="" @@ -211,19 +180,25 @@ 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 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 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 + 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). @@ -253,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 @@ -286,7 +251,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. @@ -327,7 +293,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) @@ -376,7 +342,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 +376,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" \ @@ -439,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 @@ -460,7 +426,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 +496,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 +586,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 ========================================= diff --git a/tests/sta_client.cpp b/tests/sta_client.cpp index 0da22a64..ce5d0cbc 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 @@ -58,7 +63,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 @@ -76,6 +81,8 @@ #include #include +#include + #include #include #include @@ -197,6 +204,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 */ @@ -256,7 +267,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; @@ -268,8 +280,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): @@ -285,6 +301,128 @@ 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 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. + * + * 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. + * + * 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; +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; +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; +std::atomic g_unconfirmed_lost{0}; + +/* 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; + 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 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; +} + /* 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,11 +430,48 @@ 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() { +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 + * 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 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 + * 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) { + if (probe(g_sm.channel() ? g_sm.channel() : g_chan)) g_nudges.fetch_add(1); + g_nudge_ms = now; +} + +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(); 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. */ + 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 @@ -376,8 +551,17 @@ 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(); + on_association(now); if (g_sm.connected()) note_keys(); /* The data plane runs only on a live association: a protected frame that @@ -430,7 +614,28 @@ 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; + } + /* 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 */ + } g_plain_rx.fetch_add(1); if (len > hlen) tap_up(da, sa, mpdu + hlen, len - hlen); return; @@ -545,7 +750,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); - 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. */ @@ -600,23 +808,69 @@ 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. Caller - * holds g_mu. */ -void probe(uint8_t chan) { +/* 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); - if (m.empty()) return; + 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; } +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); + /* 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. 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; + } + 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 at=%s\n", + g_uplink_unconfirmed, (unsigned)(now - g_uplink_first_ms), + wall_stamp(at, sizeof at)); + g_judge = false; + /* One more consecutive verdict on this BSS: the next association on it + * waits longer before it is judged. */ + 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(); + } + 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; @@ -626,6 +880,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; @@ -762,6 +1020,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 "?"; } @@ -770,28 +1029,53 @@ 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", + " 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()); + (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" @@ -815,11 +1099,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(), @@ -1265,6 +1550,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)) { @@ -1308,8 +1594,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_onair.sh b/tests/sta_client_onair.sh new file mode 100755 index 00000000..f1ab1ba2 --- /dev/null +++ b/tests/sta_client_onair.sh @@ -0,0 +1,1198 @@ +#!/usr/bin/env bash +# 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 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 +# never touches the air. The phy moves with `iw phy set netns`, and +# 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 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. 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 +# 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. +# +# Cells (each scored against its own witness): +# 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 nudges the AP with +# 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, +# 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). +# MT7612U also: the managed filter - the own stream arrives and +# `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, +# 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. 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 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 +# "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 +# 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 +# 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 +# loss on a working link. A station that exits, or is not up within +# 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, 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=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, 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 both from `lsusb -t` after the drivers have loaded (they can move). +# 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 +# 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, HOSTAPD_DEBUG, +# INJECT_S, INJECT_PPS, FOREIGN, 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 +set -u +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}" +# Budget from process start, not from association: firmware load, the TAP and +# the association all come before the measurement. +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. 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, 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}" +# 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}" +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}" +READY_TIMEOUT="${READY_TIMEOUT:-30}" +OUT="${OUT:-}" +APIP=192.168.98.1 +STAIP=192.168.98.2 + +CELLS="${*:-all}" +[ "$CELLS" = all ] && CELLS="open wpa2 noarm retry0 reconnect noreconnect" +for c in $CELLS; do + 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 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; } +# 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 +[ -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; } +command -v hostapd >/dev/null || { echo "hostapd is required"; exit 2; } +[ -x "$BUILD/sta_client" ] || { + echo "$BUILD/sta_client is not built (cmake --build build --target StaClientSelftest)"; exit 2; } +if [ -e "/sys/class/net/$TAP" ]; then + echo "TAP=$TAP already exists - refusing to use or delete it (set TAP=)"; exit 2 +fi +ns_exists() { ip netns list 2>/dev/null | awk '{print $1}' | grep -qx "$NS"; } +if ns_exists; then + echo "netns $NS already exists - recover or remove it first (set NS= to use another name)" + 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" +# 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. ./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 + 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 + +# --- 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; } +[ -n "$(cat "/sys/bus/usb/devices/$AP_SYSFS/idVendor" 2>/dev/null)" ] || + ap_refuse "not a USB device - if its driver just loaded it may have moved; re-read lsusb -t" +[ "$(cat "/sys/bus/usb/devices/$AP_SYSFS/bDeviceClass" 2>/dev/null)" != "09" ] || ap_refuse "a hub" +AP_IF=$(sta_first_netdev "$AP_SYSFS") +[ -n "$AP_IF" ] || ap_refuse "no network interface on it" +[ -e "/sys/class/net/$AP_IF/phy80211" ] || ap_refuse "$AP_IF is not wireless" +for fam in -4 -6; do + ip "$fam" route show default 2>/dev/null | grep -qw "dev $AP_IF" && + ap_refuse "$AP_IF carries a default route" +done +AP_PHY=$(basename "$(readlink -f "/sys/class/net/$AP_IF/phy80211")") +iw phy "$AP_PHY" info 2>/dev/null | grep -q '\* AP$' || ap_refuse "$AP_IF ($AP_PHY) does not support AP mode" +iw phy "$AP_PHY" info 2>/dev/null | grep -q set_wiphy_netns || + ap_refuse "the AP phy cannot change network namespace (in-kernel cfg80211 driver needed, e.g. rtw88/mt76; out-of-tree rtl88x2cu/88x2bu cannot)" +# Restored after the phy comes back: the move takes the interface down. +AP_WAS_UP=no +ip link show "$AP_IF" 2>/dev/null | grep -q '[<,]UP[,>]' && AP_WAS_UP=yes + +# Flags and traps BEFORE anything is taken, so every exit from here on hands +# 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 + # 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 inject + sta_pid_kill inject_own + 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. + # 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 + echo "WARNING: a phy is still in netns $NS - NOT deleting it. Recover with:" + echo " sudo ip netns exec $NS iw phy $AP_PHY set netns 1; sudo ip netns del $NS" + else + ip netns del "$NS" 2>/dev/null + [ "$AP_WAS_UP" = yes ] && ip link set "$AP_IF" up 2>/dev/null + fi + fi + [ "$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 + 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 + +if [ "$DUT_KIND" = mt7612u ]; then + sta_dut_take || exit 2 +else + # 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_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 + "$AP_IF:unmanaged"|"") : ;; + *) nmcli device set "$AP_IF" managed no >/dev/null 2>&1 && NM_AP=yes ;; + esac +fi +rfkill unblock wlan 2>/dev/null +NS_OURS=yes +ip netns add "$NS" || { echo "could not create netns $NS"; exit 2; } +iw phy "$AP_PHY" set netns name "$NS" || { echo "could not move $AP_PHY into $NS"; exit 2; } +sleep 2 +ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null + +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" + +pass=0; fail=0; inconclusive=0 +ok() { pass=$((pass+1)); printf ' PASS %s\n' "$*"; } +bad() { fail=$((fail+1)); printf ' FAIL %s\n' "$*"; } +inc() { inconclusive=$((inconclusive+1)); printf ' INCONCLUSIVE %s\n' "$*"; } +info() { printf ' INFO %s\n' "$*"; } + + +# Wait for an extended regex in a file. 0 found, 1 timed out. +wait_for() { # $1 file, $2 regex, $3 seconds + local t=0 + until grep -qE "$2" "$1" 2>/dev/null; do + [ "$t" -ge "$3" ] && return 1 + sleep 1; t=$((t + 1)) + done +} + +# --- the AP ----------------------------------------------------------------- +# 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 | 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 + printf 'channel=%s\nieee80211n=1\nauth_algs=1\nwmm_enabled=1\n' "$CH" + 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 + 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 + # interface flags: No such device" / "nl80211 driver initialization + # failed"). Wait, bounded, until the netdev is present, and FORCE it to a + # station once it is: a driver may leave the vif in AP type after hostapd + # exits, and hostapd then fails with "Match already configured" rather + # than anything that names the problem (tests/mt7612u_sta_identity.sh). + local t=0 info + while :; do + info=$(ip netns exec "$NS" iw dev "$AP_IF" info 2>/dev/null) + case "$info" in + *'type managed'*) break ;; + '') ;; # not back yet + *) ip netns exec "$NS" ip link set "$AP_IF" down 2>/dev/null + ip netns exec "$NS" iw dev "$AP_IF" set type managed 2>/dev/null ;; + esac + if [ "$t" -ge 100 ]; then + echo "rig: $AP_IF not back as a managed netdev in netns $NS within 10 s" \ + "- hostapd not started" | tee "$OUT/hostapd_$2.log" + # The loop may just have taken it down; leave it up (best effort). + ip netns exec "$NS" ip link set "$AP_IF" up 2>/dev/null + return 1 + fi + 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 + 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 + [ "$t" -ge 15 ] && return 1 + sleep 1; t=$((t + 1)) + done + ip netns exec "$NS" ip addr flush dev "$AP_IF" 2>/dev/null + ip netns exec "$NS" ip addr add "$APIP/24" dev "$AP_IF" +} + +# --- the station -------------------------------------------------------------- +# 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="$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" \ + "$@" "$BUILD/sta_client" "$secs" > "$OUT/sta_$cell.log" 2>&1 & + STA_PID=$! + sta_pid_record sta "$STA_PID" + local t=0 + until grep -q 'sta_client up:' "$OUT/sta_$cell.log" 2>/dev/null; do + sta_pid_alive "$STA_PID" || return 1 + [ "$t" -ge "$READY_TIMEOUT" ] && return 2 + sleep 1; t=$((t + 1)) + done +} + +# The station never printed `sta_client up:` - a rig / bring-up problem, not +# a verdict on the station, whatever the exit status. $1 cell, $2 sta_up's rc. +station_not_up() { + if [ "$2" = 2 ]; then + inc "$1: sta_client not up within READY_TIMEOUT=${READY_TIMEOUT}s (rig/bring-up) - see $OUT/sta_$1.log" + return + fi + sta_stop + if [ "$STA_RC" = 2 ]; then + inc "$1: sta_client REFUSED the adapter (station_mode_ok false): $(grep -m1 REFUSED "$OUT/sta_$1.log")" + else + inc "$1: sta_client exited before 'up' (status $STA_RC; rig/bring-up): $(tail -1 "$OUT/sta_$1.log" 2>/dev/null)" + fi +} + +# Stop the station (INT: it leaves the BSS, clears the identity and prints its +# ledger) and record its exit status in STA_RC. +STA_RC="" +sta_stop() { + STA_RC="" + [ -n "$STA_PID" ] || return 0 + if sta_pid_alive "$STA_PID"; then kill -INT "$STA_PID" 2>/dev/null; fi + local t=0 + 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 + STA_RC=killed + else + wait "$STA_PID" 2>/dev/null; STA_RC=$? + rm -f "$OUT/.pid_sta" + fi + STA_PID="" + # Exit 3 is a fault the station caught and tore down cleanly: a FAIL + # wherever it happens, with the cause named. + if [ "$STA_RC" = 3 ] && [ -n "$CELL" ]; then + bad "$CELL: sta_client FAULT (exit 3): $(fault_cause "$CELL")" + fi +} + +# The TAP up, and the route to the AP proven to leave through it. +tap_up() { + local t=0 + until [ -d "/sys/class/net/$TAP" ]; do + [ "$t" -ge 20 ] && return 1 + sleep 1; t=$((t + 1)) + done + command -v nmcli >/dev/null 2>&1 && nmcli device set "$TAP" managed no >/dev/null 2>&1 + ip link set "$TAP" up 2>/dev/null + ip addr flush dev "$TAP" 2>/dev/null + ip addr add "$STAIP/24" dev "$TAP" 2>/dev/null + sleep 1 + case "$(ip route get "$APIP" 2>/dev/null)" in *"dev $TAP"*) return 0 ;; esac + return 1 +} + +own_of() { sed -n 's/^sta_client up: own \([0-9a-f:]\{17\}\) .*/\1/p' "$OUT/sta_$1.log" | head -1; } +# One numeric field from the station's exit ledger. +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 + 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 + 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 + 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 + 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; } + +# The station exited after `sta_client up:` but before its measurement: +# status 0 ran out of SECS (INCONCLUSIVE); anything else is a FAIL. +station_gone() { # $1 cell + sta_stop + case "$STA_RC" in + 0) inc "$1: sta_client ran out of time before the measurement - raise SECS (now $SECS)" ;; + 3) ;; # a FAULT: reported by sta_stop + *) bad "$1: sta_client exited early (status $STA_RC) - see $OUT/sta_$1.log" ;; + esac +} + +# The cause of a sta_client FAULT (exit 3, `fault=1` in the ledger). +fault_cause() { + grep -m1 'FAULT\|threw' "$OUT/sta_$1.log" 2>/dev/null | sed 's/^ *//' +} + +# 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/^ *//') + 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 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` +# whatever happens to the harness. Sets INJ_FOREIGN / INJ_OWN (empty when it +# 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 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. 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 3 + 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_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 + 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 + ip netns exec "$NS" iw dev "$MON" del 2>/dev/null + return 1 + fi + 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 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" + 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. 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') + 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 + 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 + 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" + fi +} + +# Arm and clear lines: the identity was armed for the AP's BSSID, and the +# clear ran on the way out. +check_armed() { # $1 cell + if grep -q '^ station identity armed for BSSID' "$OUT/sta_$1.log"; then + ok "$1: SetStationIdentity armed ($(grep -m1 '^ station identity armed' "$OUT/sta_$1.log" | sed 's/^ *//'))" + else + bad "$1: the identity was never armed ($(grep -m1 'station identity' "$OUT/sta_$1.log" || echo 'no arm line'))" + fi + check_cleared "$1" +} + +# 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_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 +# 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, 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. 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, $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 "?" } + /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" -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. + function le(x, y, xs, ys) { + 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 + 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)) { + 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 + } + NF >= 2 { na++; A[na] = $1; V[na] = $2 } + END { + for (i = 1; i <= na; i++) { + 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 } + 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 (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 = "" + 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 "$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 + 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 + [ "$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 + while read -r tag k a v; do + [ "$tag" = BAD ] || continue + bad "$1: verdict $k (at $v) hit an association the AP held - hostapd logged AP-STA-CONNECTED $2 after it was made (at $a)" + done </dev/null 2>&1 & + sta_pid_record probe $! + if ! wait_for "$OUT/hostapd_open.log" "AP-STA-CONNECTED $own" 30; then + 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 + sta_pid_kill probe + ok "open: the AP associated $own" + 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_hard hostapd; return ;; + esac + cell_end + 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 + ok "open: ledger plaintext rx=$plain, encrypted rx=0" + else + bad "open: ledger plaintext rx=${plain:-?} encrypted rx=${enc:-?} (expected >0 and 0)" + fi + check_armed 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. 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" + WPA2_LINK="" + ap_up wpa2 "$cell" || { inc "$cell: hostapd did not bring $AP_IF up in AP mode - see $OUT/hostapd_$cell.log"; cell_end; return 1; } + local secs=$(( SECS + 2 * REKEY_S + PTK_REKEY_S )) + local up=0 + sta_up "$cell" "$secs" DEVOURER_STA_PSK="$PSK" "$@" || up=$? + [ "$up" = 0 ] || { station_not_up "$cell" "$up"; cell_end; return 1; } + 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 1; } + if ! wait_for "$OUT/hostapd_$cell.log" "EAPOL-4WAY-HS-COMPLETED $own" 30; then + WPA2_LINK="no four-way" + if ! sta_pid_alive "$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_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 + # 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" ] && 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 ! 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" + else bad "$cell: no group rekey completed in ${lim}s"; fi + if [ "${pk:-0}" -ge 2 ]; then ok "$cell: the AP completed a pairwise rekey ($pk pairwise handshakes)" + else bad "$cell: no pairwise rekey in ${lim}s (${pk:-0} pairwise handshake(s))"; fi + # Still carrying traffic after both rekeys. + 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_hard hostapd; return 1 ;; + esac + cell_end + return 0 +} + +cell_wpa2() { + echo; echo "== wpa2: hostapd WPA2-PSK, group rekey ${REKEY_S}s, pairwise rekey ${PTK_REKEY_S}s ==" + run_wpa2 wpa2 || return + case "$WPA2_LINK" in + *OK) ok "wpa2: $WPA2_LINK" ;; + *) 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; } + 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 ] && + [ "${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:-?} 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" + else + ok "wpa2: no tx.retry_limit=0 warning ($(grep -m1 'tx.retry_limit' "$OUT/sta_wpa2.log" | sed 's/^ *//'))" + fi +} + +cell_noarm() { + echo; echo "== noarm (control): wpa2 with DEVOURER_STA_ARM=0 ==" + run_wpa2 noarm DEVOURER_STA_ARM=0 || return + if grep -q 'sta_client up:.* arm=0' "$OUT/sta_noarm.log" && + ! grep -q 'station identity' "$OUT/sta_noarm.log"; then + ok "noarm: no SetStationIdentity and no ClearStationIdentity ran" + else + 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 + [ "$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 + # 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 [ "$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" + 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() { + echo; echo "== retry0: wpa2 with DEVOURER_TX_RETRY_LIMIT=0 ==" + run_wpa2 retry0 DEVOURER_TX_RETRY_LIMIT=0 || return + local armed warn + armed=$(grep -n -m1 '^ station identity armed for BSSID' "$OUT/sta_retry0.log" | cut -d: -f1) + warn=$(grep -n -m1 'station identity armed with tx.retry_limit=0' "$OUT/sta_retry0.log" | cut -d: -f1) + if [ -z "$armed" ]; then + inc "retry0: the identity was never armed, so the arm-time warning could not fire - see $OUT/sta_retry0.log" + elif [ -n "$warn" ] && [ "$warn" -lt "$armed" ]; then + ok "retry0: the library warned at arm time: $(sed -n "${warn}p" "$OUT/sta_retry0.log" | cut -c1-100)..." + else + bad "retry0: armed with tx.retry_limit=0 and no arm-time warning" + fi + check_cleared 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 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 + 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_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" + 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 + 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 + # 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 [ "$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 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 + 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_hard hostapd; return ;; + esac + else + if [ "$rejoined" = yes ]; then + bad "$cell: re-joined with DEVOURER_STA_RECONNECT=0" + else + 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 + 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_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) ===" +[ "$fail" -gt 0 ] && exit 1 +[ "$inconclusive" -gt 0 ] && exit 2 +exit 0 diff --git a/tests/sta_client_selftest.inc b/tests/sta_client_selftest.inc index 5b65e331..220b6ba8 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, @@ -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; @@ -106,6 +106,19 @@ void reset_all() { g_fault = 0; g_stop = 0; g_tap_read_err = 0; + g_end = RunEnd{}; + g_judge = 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; + 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 @@ -471,8 +484,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; @@ -1041,6 +1059,730 @@ void test_a_non_key_eapol_reaches_the_host() { "...and is not counted as a rekey"); } +/* 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) + 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"); +} + +/* 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), 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); + 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; } + 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 + * IPv6 echo requests to a multicast group) 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[80]; + icmpv6_to(e, kAllRouters6, 128); + tap_down_one(e, sizeof e); + } + drain_tx(); + supervise(kConfirmMs * 4); + 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. */ + 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 * 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 how + * an AP that dropped us shows - the first time on this BSS (the one + * 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); + tap_down_one(e, sizeof e); + } + drain_tx(); + 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, the first time on a BSS"); +} + +/* AN OPEN ASSOCIATION THE AP DOES NOT HOLD IS FOUND AND RE-JOINED. The host + * 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. */ +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]; + ipv4_to_peer(e, 1, 0); /* a ping */ + 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(10); /* the window opens */ + supervise(10 + kConfirmMs - 1); + check(g_sm.state() == StationSm::State::Connected, + "...and not judged before 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"); + 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 + 210); + supervise(kConfirmMs + 210); + 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. */ +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, 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, + "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"); +} + +/* 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, 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(); + 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(1000); + supervise(1000 + kConfirmMs * 2); + check(g_sm.state() == StationSm::State::Connected, + "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; 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] = 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); + 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, + what[k]); + } +} + +/* 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"); +} + +/* 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); + 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; +} + +/* 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); + 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_associations.load() == 2, + "...and the station re-joins"); + return t; +} + +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); + 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); + 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 && + ping_until(t, t + strike_backoff_ms(1) - 1) == 0, + "a broadcast from the AP does not reset the backoff"); +} + +/* 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(); + 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); + 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"); +} + +/* THE at= STAMPS are what the on-air harness orders against hostapd -t: the + * 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) { + 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); + /* 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"); + 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); + 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); + 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]; + 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() { + 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. */ +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; + 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. */ @@ -1067,6 +1809,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 @@ -1640,6 +2392,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 @@ -1744,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 */ @@ -1908,6 +2672,24 @@ 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_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_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(); + 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(); + 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(); diff --git a/tests/sta_unicast_inject.py b/tests/sta_unicast_inject.py index 6322055a..b3a65647 100755 --- a/tests/sta_unicast_inject.py +++ b/tests/sta_unicast_inject.py @@ -12,12 +12,19 @@ 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] - -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). + 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, 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 @@ -71,7 +78,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 +87,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 +113,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(' 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();