From 5a725663813ecd518100dfafc3367656860ba54d Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Wed, 19 Aug 2026 10:42:26 -0500 Subject: [PATCH] feat: env check auto-disables dummy_radio0 STA (2.4GHz recon root cause) The stock STA client interface (wlan0) holds phy0's channel, pinning wlan0mon so 2.4GHz recon captures nothing (verified: iw set channel -> Resource busy until wlan0 is down). env_check now disables it (uci wireless.dummy_radio0 disabled=1 + wifi reload) at startup and /api/recon/status exposes wlan0_sta so the recon page can warn if it regresses. --- .../user/remote_access/pager-webui/server.py | 22 ++++++++++++++ .../remote_access/pager-webui/www/js/views.js | 6 ++-- tests/test_env_check.py | 29 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 6e65cae..bf9065e 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -1359,6 +1359,7 @@ def h_recon_status(ctx): 'scanning': scanning, 'scan_remaining': remaining, 'stale': stale, 'hopper_online': _hopper_online(), 'wlan0_pinned': _wlan0_pinned(), + 'wlan0_sta': _sta_uplink_enabled(), 'wlan0_hopping': _wlan0_hopping(), 'history_reset': _recon_history_reset()} @@ -3717,6 +3718,20 @@ def _wlan0_pinned(): return False +def _sta_uplink_enabled(): + """True when the stock dummy_radio0 STA client interface is enabled. The + STA holds phy0's channel, which pins wlan0mon and starves 2.4GHz recon + entirely (iw set channel fails with Resource busy while it is up).""" + cfg = _uci_values('wireless.dummy_radio0') or {} + return cfg.get('mode') == 'sta' and cfg.get('disabled') != '1' + + +def _disable_sta_uplink(): + device_run(['uci', 'set', 'wireless.dummy_radio0.disabled=1']) + device_run(['uci', 'commit', 'wireless']) + device_run(['wifi', 'reload'], timeout=30) + + def _pineap_interfaces(): """Runtime pineapd interface table from ``_pineap INTERFACE LIST``: {name: {'channels': int, 'bands': str, 'hop': str, 'pkts': int}}.""" @@ -3790,6 +3805,13 @@ def env_check(): else: _env_step(report, 'warn', pool_detail) + if _sta_uplink_enabled(): + _disable_sta_uplink() + _env_step(report, 'fixed', '2.4GHz recon: dummy_radio0 STA uplink disabled ' + '(it was pinning phy0 so wlan0mon could not hop)') + else: + _env_step(report, 'pass', 'no STA uplink pinning phy0') + raised = _raise_monitors() if raised: _env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised)) diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index 425e35b..2e2692b 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -1462,7 +1462,7 @@ views.recon = (root) => { compare: [], history: {}, mapBand: null, archive: null, archives: [], scanRemaining: null, hopperOnline: null, historyReset: false, scanErr: null, - wlan0Pinned: false, wlan0Hopping: null }; + wlan0Pinned: false, wlan0Sta: false, wlan0Hopping: null }; const cols = reconLoadCols(); // ---- title cards (stat cards with optional mini charts) ---- @@ -1661,10 +1661,11 @@ views.recon = (root) => { if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen'); if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)'); if (state.wlan0Pinned) bits.push('2.4GHz under-sampled — OpenAP/Evil WPA holds wlan0mon'); + if (state.wlan0Sta) bits.push('2.4GHz starved — client STA (wlan0) pins phy0'); if (state.wlan0Hopping === false) bits.push('2.4GHz starved — wlan0mon hopping is off'); if (state.scanErr) bits.push(state.scanErr); scanStatus.textContent = bits.join(' · '); - scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || state.wlan0Hopping === false || !!state.scanErr); + scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || state.wlan0Sta || state.wlan0Hopping === false || !!state.scanErr); } scanToggle.addEventListener('change', () => { if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } @@ -2527,6 +2528,7 @@ views.recon = (root) => { state.hopperOnline = r.data.hopper_online; state.historyReset = !!r.data.history_reset; state.wlan0Pinned = !!r.data.wlan0_pinned; + state.wlan0Sta = !!r.data.wlan0_sta; state.wlan0Hopping = r.data.wlan0_hopping; if (!pendingScan) scanToggle.checked = scanning; renderScanBar(); diff --git a/tests/test_env_check.py b/tests/test_env_check.py index 6e9b4f0..5cbfe2d 100644 --- a/tests/test_env_check.py +++ b/tests/test_env_check.py @@ -217,6 +217,35 @@ class EnvCheckTest(unittest.TestCase): report = server.env_check() self.assertEqual(self.steps(report, 'wlan0mon hopping on')[0]['ok'], 'pass') + def test_sta_uplink_disabled_when_enabled(self): + self.safe_set() + self.uci_state['wireless.dummy_radio0.mode'] = 'sta' + self.uci_state['wireless.dummy_radio0.ifname'] = 'wlan0' + self.uci_state['wireless.dummy_radio0.disabled'] = '0' + report = server.env_check() + self.assertEqual(self.steps(report, 'dummy_radio0 STA uplink disabled')[0]['ok'], 'fixed') + self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '1') + self.assertIn(['wifi', 'reload'], self.runs) + + def test_sta_uplink_pass_when_absent(self): + self.safe_set() + report = server.env_check() + self.assertEqual(self.steps(report, 'no STA uplink pinning phy0')[0]['ok'], 'pass') + + def test_sta_uplink_pass_when_disabled(self): + self.safe_set() + self.uci_state['wireless.dummy_radio0.mode'] = 'sta' + self.uci_state['wireless.dummy_radio0.disabled'] = '1' + report = server.env_check() + self.assertEqual(self.steps(report, 'no STA uplink pinning phy0')[0]['ok'], 'pass') + + def test_recon_status_exposes_sta(self): + self.safe_set() + self.uci_state['wireless.dummy_radio0.mode'] = 'sta' + status, payload = server.h_recon_status(type('C', (), {'query': {}})()) + self.assertEqual(status, 200) + self.assertTrue(payload['wlan0_sta']) + def test_cli_exits_zero_on_pass(self): self.safe_set() buf = io.StringIO()