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.
This commit is contained in:
2026-08-19 10:42:26 -05:00
parent 5eb81b90f1
commit 5a72566381
3 changed files with 55 additions and 2 deletions
@@ -1359,6 +1359,7 @@ def h_recon_status(ctx):
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale, 'scanning': scanning, 'scan_remaining': remaining, 'stale': stale,
'hopper_online': _hopper_online(), 'hopper_online': _hopper_online(),
'wlan0_pinned': _wlan0_pinned(), 'wlan0_pinned': _wlan0_pinned(),
'wlan0_sta': _sta_uplink_enabled(),
'wlan0_hopping': _wlan0_hopping(), 'wlan0_hopping': _wlan0_hopping(),
'history_reset': _recon_history_reset()} 'history_reset': _recon_history_reset()}
@@ -3717,6 +3718,20 @@ def _wlan0_pinned():
return False 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(): def _pineap_interfaces():
"""Runtime pineapd interface table from ``_pineap INTERFACE LIST``: """Runtime pineapd interface table from ``_pineap INTERFACE LIST``:
{name: {'channels': int, 'bands': str, 'hop': str, 'pkts': int}}.""" {name: {'channels': int, 'bands': str, 'hop': str, 'pkts': int}}."""
@@ -3790,6 +3805,13 @@ def env_check():
else: else:
_env_step(report, 'warn', pool_detail) _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() raised = _raise_monitors()
if raised: if raised:
_env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised)) _env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised))
@@ -1462,7 +1462,7 @@ views.recon = (root) => {
compare: [], history: {}, mapBand: null, compare: [], history: {}, mapBand: null,
archive: null, archives: [], scanRemaining: null, archive: null, archives: [], scanRemaining: null,
hopperOnline: null, historyReset: false, scanErr: null, hopperOnline: null, historyReset: false, scanErr: null,
wlan0Pinned: false, wlan0Hopping: null }; wlan0Pinned: false, wlan0Sta: false, wlan0Hopping: null };
const cols = reconLoadCols(); const cols = reconLoadCols();
// ---- title cards (stat cards with optional mini charts) ---- // ---- 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.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.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.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.wlan0Hopping === false) bits.push('2.4GHz starved — wlan0mon hopping is off');
if (state.scanErr) bits.push(state.scanErr); if (state.scanErr) bits.push(state.scanErr);
scanStatus.textContent = bits.join(' · '); 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', () => { scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
@@ -2527,6 +2528,7 @@ views.recon = (root) => {
state.hopperOnline = r.data.hopper_online; state.hopperOnline = r.data.hopper_online;
state.historyReset = !!r.data.history_reset; state.historyReset = !!r.data.history_reset;
state.wlan0Pinned = !!r.data.wlan0_pinned; state.wlan0Pinned = !!r.data.wlan0_pinned;
state.wlan0Sta = !!r.data.wlan0_sta;
state.wlan0Hopping = r.data.wlan0_hopping; state.wlan0Hopping = r.data.wlan0_hopping;
if (!pendingScan) scanToggle.checked = scanning; if (!pendingScan) scanToggle.checked = scanning;
renderScanBar(); renderScanBar();
+29
View File
@@ -217,6 +217,35 @@ class EnvCheckTest(unittest.TestCase):
report = server.env_check() report = server.env_check()
self.assertEqual(self.steps(report, 'wlan0mon hopping on')[0]['ok'], 'pass') 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): def test_cli_exits_zero_on_pass(self):
self.safe_set() self.safe_set()
buf = io.StringIO() buf = io.StringIO()