# Radio1 5GHz/6GHz Rogue AP — Design Record - **Date:** 2026-08-17 - **Status:** Implemented and verified on-device (see §9) - **Owner:** Hak5 WiFi Pineapple Pager expansion project - **Scope:** Mark VIII WebUI (`http://172.16.52.1:8080/`) running a rogue AP on the Pager's second radio (`radio1` = MT7921U Wi-Fi 6E) on 5GHz and 6GHz, with band-aware channel pickers, without breaking the stock Pager UI's control of its own interfaces. ## 1. Goal Today Mark VIII's PineAP Open AP and Evil WPA pages configure only `wlan0open` / `wlan0wpa` on `radio0` (2.4GHz) via the stock daemon's `set_ap` endpoint. The Pager carries a second radio — an MT7921U Wi-Fi 6E on internal USB — that is otherwise only used by the stock daemon's hopping monitor `wlan1mon`. This feature lets Mark VIII run an Open AP or Evil WPA (WPA2-PSK / WPA3-SAE / WPA3-OWE) on `radio1` at 5GHz and 6GHz, configured directly via UCI, with a UI that picks channels by band and enforces WPA3 on 6GHz. ## 2. Hardware findings Verified against the committed implementation (`server.py`, `views.js`) and the plan's on-device groundwork: - **`radio0`** — MT7628, **2.4GHz only**. The stock PineAP daemon owns `wlan0open`, `wlan0wpa`, `wlan0cli`, `wlan0mon`, `wlan0mgmt`. This feature does not change that ownership. - **`radio1`** — MT7921U Wi-Fi 6E on internal USB (`phy1`), capable of 2.4/5/6GHz. The stock daemon owns `wlan1mon`, a daemon-managed hopping monitor interface: `pineapd.wlan1mon` has `bands='2,5,6'` and `hop='1'`. - Because `radio1` has a single channel shared by all its virtual interfaces, a `wlan1` AP and the hopping `wlan1mon` cannot both be active with an operator-chosen channel — hence the hop pause/resume mechanism (§6). - UCI state (`wireless.radio1`): stock defaults are `band='5g'`, `channel='auto'`, `htmode='VHT80'`, `country` set for the device region. ## 3. Band / channel model `server.py` defines the authoritative mapping (helpers added near `_uci_wifi_iface`): | Band | `BAND_*` | Channels | `band_htmode` | `band_radio` | |---|---|---|---|---| | 2.4GHz | `BAND_2G = '2.4'` | `1–14` | `HT20` | `radio0` | | 5GHz | `BAND_5G = '5'` | `36–177` | `VHT80` | `radio1` | | 6GHz | `BAND_6G = '6'` | `181–233` (step 4) | `HE80` | `radio1` | - `channel_band(ch)` classifies **2.4GHz (1–14) first, then 5GHz (36–177)**, and only then 6GHz (`1 ≤ ch ≤ 233` and `(ch − 1) % 4 == 0`). Because 2.4 and 5GHz take precedence, **the only reachable 6GHz channels are `181, 185, …, 233`** (the low 6E channels `1,5,…,177` are swallowed by the 2.4/5GHz ranges). The UI's 6GHz group therefore offers exactly `181..233 step 4`. - `CHANNEL_BANDS` mirrors this: `range(1, 15)`, `range(36, 178)`, `range(181, 234, 4)` — held consistent by tests. - `DFS_CHANNELS` = `52..64` and `100..144` (step 4). DFS channels are surfaced to the operator in the UI with a `(DFS)` marker; this feature does not attempt radar-CAC handling on-device. - `channel_freq(band, ch)`: 2.4 → `2412 + (ch−1)·5`; 5 → `5180 + (ch−36)·5`; 6 → `5955 + (ch−1)·5`. - `band_htmode` maps 2.4/5/6 → `HT20` / `VHT80` / `HE80` (written to `wireless.radio1.htmode`). `band_radio` maps 2.4 → `radio0`, 5/6 → `radio1`. ## 4. API behavior ### 4.1 `h_pineap_wifi_get_ap` (POST `/api/pineap/wifi/get_ap`) - If `wlan1open` **or** `wlan1wpa` exists in `wireless`, state is read from `radio1` (`wlan1open`/`wlan1wpa`); otherwise from `radio0` (`wlan0open`/`wlan0wpa`) — the 2.4GHz response shape is unchanged. - `open.channel` and `wpa.channel` are reported **per interface**, falling back to the owning radio's channel when the iface section has no channel option (regression-tested). `wpa.enctype` is normalized to `psk2` / `sae` / `owe`. - A new `radio1` object reports the raw `wireless.radio1` state: `{band, channel, htmode, country}`, with `band` normalized from `2g`/`5g`/`6g` to `'2.4'`/`'5'`/`'6'` (default `'5'`), and `channel` left as the raw string (`'auto'` or a channel number) — the UI converts; `'auto'` is not int-coerced. ### 4.2 `h_pineap_wifi_set_ap` (POST `/api/pineap/wifi/set_ap`) `channel` is now accepted in both `open` and `wpa` payloads. Behavior matrix: - **2.4GHz channels (1–14):** exactly today's path — daemon `PUT /api/settings/wifi/set_ap` for `wlan0open`/`wlan0wpa` followed by `_apply_open_radio` (which persists `radio0.channel`/`country`). If a `wlan1*` AP exists it is removed first (`_remove_radio1_ap`, §6) so a 2.4GHz save tears down a stale radio1 AP. - **5GHz (36–177) / 6GHz (181–233):** `_apply_radio1_ap` (below). - **Mixed request:** a request carrying both a 2.4GHz object and a 5/6GHz object returns `400 'cannot configure 2.4GHz and radio1 APs in one request'` (radio1 is one physical radio — one band/channel per request). - **Disable:** a radio1 request with no active 5/6GHz object (or a 2.4GHz save) runs `_remove_radio1_ap()` + `wifi reload` and returns `200`. `_apply_radio1_ap(openap, wpa)` (one of the two is active): 1. Validates the band — a radio1 AP requires a 5GHz or 6GHz channel (`ValueError` → HTTP 400). 2. **6GHz requires WPA3:** when the active AP is a WPA AP on 6GHz, `enctype` must be `sae` or `owe`; `psk2` is rejected with HTTP 400 (`'6GHz requires WPA3 (sae or owe)'`). An **open** 6GHz AP is accepted by the backend, but the UI warns that real clients generally won't associate to an open 6GHz network. 3. Deletes any existing `wlan1open`/`wlan1wpa` (idempotent), then writes UCI: - `wireless.radio1.band` = `5g`/`6g`, `wireless.radio1.channel`, `wireless.radio1.htmode` (`band_htmode`), `wireless.radio1.country` (when supplied); - a `wifi-iface` section `wlan1open` (encryption `none`, optional BSSID) or `wlan1wpa` (`encryption` + `key`) on `device=radio1`, `mode=ap`, with the interface-level `channel`/`hidden`/`ssid`. 4. `uci commit wireless`, `_pause_hop()` (§6), then `wifi reload`. ## 5. Coexistence rules — "don't break stock" - **Mark VIII owns:** `wireless.wlan1open`, `wireless.wlan1wpa`, and `wireless.radio1.{channel,band,htmode,country}`. These are new sections / values it creates and tears down. - **Stock owns (never modified by Mark VIII):** `wireless.wlan0open`, `wlan0wpa`, `wlan0mgmt`, `wlan0cli`, `wlan0mon`, `wireless.wlan1mon`, and all `pineapd.*` UCI values (bands configuration included). - **The only stock-owned value this feature writes is `pineapd.wlan1mon.hop`** — and it is always restored to its prior value (`_resume_hop` sets it back to `1` only if it was `0`; `_pause_hop` sets it to `0` only if it was not already `0`). - The 2.4GHz daemon path (`PUT /api/settings/wifi/set_ap` for `wlan0open` / `wlan0wpa`) is byte-for-byte unchanged. - **Known risk (accepted):** the stock daemon's `set_ap`/pager-UI writes may rewrite `wireless` wholesale and drop the `wlan1*` sections. Mitigation is UCI-commit persistence, hop restore on disable, and on-device verification (§9). If clobbering is observed, the deferred fix is a reconcile-on-load step in `get_ap` that re-applies a saved radio1 AP from `PINEAP_STATE_FILE`. ## 6. Hop pause / resume `wlan1mon` is the stock daemon's channel-hopping monitor. With a radio1 AP active, hopping would fight the AP's fixed channel, so it is paused while the AP is enabled: - `_read_hop()` reads the value via `uci get pineapd.wlan1mon.hop` (a leaf read — not `_uci_wifi_iface`, which forces the `wireless.` prefix). - `_pause_hop()`: if `hop != '0'`, set `pineapd.wlan1mon.hop=0`, `uci commit pineapd`, reload `/etc/init.d/pineapd`. - `_resume_hop()`: if `hop == '0'`, set it back to `1`, commit, reload. - `_apply_radio1_ap` calls `_pause_hop()` before `wifi reload`; `_remove_radio1_ap` calls `_resume_hop()` after resetting `radio1.channel=auto` / `radio1.band=5g`. Every code path that pauses hopping also restores it. ## 7. Frontend (`www/js/views.js`) - `BAND_GROUPS` drives the channel pickers shared by the Open AP and Evil WPA views: a `2.4 GHz` optgroup (1–11), a `5 GHz` optgroup (36–165, DFS channels `52..64` / `100..144` labelled `(DFS)`), and a `6 GHz (WPA3/OWE only)` optgroup (`181..233` step 4). - `chanFreq`/`chanLabel` render `Channel N (… MHz)` (+` (DFS)`), `chanSelect` builds the optgroups and restores a stored value when in range, `bandOfChannel` mirrors `channel_band`. - **Open AP:** channel select + a hint that appears on 6GHz ("…most devices will not associate to an open 6 GHz network."). - **Evil WPA:** a channel select added to the config card; selecting a 6GHz channel disables the `psk2` option and switches to `sae`, with a "6 GHz requires WPA3 (SAE or OWE)." hint. Save payloads for both views include `channel` (Open AP also `country`). ## 8. Automated verification - `tests/test_pineap_bands.py` covers the channel/band helpers (`ChannelBandTest`, `ChannelBandsConsistencyTest`, `ChannelFreqTest`, `BandAuxTest` incl. DFS marker), `get_ap` (`GetApRadio1Test`, `GetApRadio1AbsentTest`, `GetApRadioChannelFallbackTest`) and `set_ap` (`SetApRadio1Test`: 5GHz open writes `radio1` sections + hop pause; 6GHz WPA3-SAE accepted; 6GHz `psk2` rejected; disable removes the radio1 AP and restores hop; 2.4GHz still uses the daemon path; 2.4GHz save removes a stale radio1 AP; mixed 2.4GHz + radio1 rejected). - **All 14 test modules pass at HEAD (`af5ff80`)**, run per-module in separate processes per the repo convention (`test_auth`, `test_core`, `test_loot`, `test_misc`, `test_pineap_bands`, `test_pineap_clients`, `test_pineap_enterprise`, `test_pineap_modes`, `test_pineap_pool`, `test_pineap_proxy`, `test_pineap_settings`, `test_recon`, `test_status`, `test_ws`). ## 9. On-device verification Run against the user's Pager at `172.16.52.1` (Pineapple Pager 24.10.1). All checks passed: - **2.4GHz unchanged:** Open AP save (channel 1) leaves `wlan0open`/`radio0` intact and `pineapd.wlan1mon` untouched (`bands=2,5,6`, `hop=1`). - **5GHz Evil WPA (WPA3-SAE, channel 36, VHT80):** `radio1.band=5g`, `channel=36`, `htmode=VHT80`; `wlan1wpa` (netdev named `wlan1wpa` via `option ifname`) comes up beaconing `Test5G` / WPA3 SAE (CCMP); `pineapd.wlan1mon.hop=0`. `get_ap` reports `wpa.enabled=true` (after the `disabled=0` fix). - **Stock Pager UI coexistence:** the stock daemon's own `set_ap` (what the pager UI uses to change the 2.4GHz Evil WPA) tears down the radio1 AP netdev; the `wlan1wpa` UCI section survives and `get_ap` self-heals it with a `wifi reload` (`/sys/class/net/` missing check). Under rapid reload churn the `mt7921u` driver can transiently return EBUSY; a later reload succeeds. The pager UI itself is unaffected. - **Handshake capture:** `Examine` on channel 36 returns success; handshake logging (`loghandshake`/`logpartialhandshake`) confirmed on. A live WPA handshake file requires a physical client (not exercised). - **Reboot persistence:** `wlan1wpa` UCI, `disabled=0`, `hop=0`, the procd Mark VIII service, and the AP itself all survive reboot. - **Disable path:** removing the 5GHz AP deletes `wlan1wpa`, resets `radio1.channel=auto`/`band=5g`, restores `hop=1`; `wlan1mon` hopping resumes (observed 6GHz ch13 → ch221 in 30s). - **5GHz Open AP:** `wlan1open` (channel 44, open) brings up `Test5GOpen` with `hop=0`. - **6GHz AP:** `radio1.band=6g`, `htmode=HE80`, WPA3 SAE on channel 181 (6.855 GHz) comes up. - **Cleanup:** disable restores the 2.4GHz baseline (`pager-open`, channel 1, `hop=1`). Known follow-ups: the Clients tab lists only `wlan0*` interfaces, so 5GHz AP clients are not yet shown; live-client handshake capture is untested without a physical client.