diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 6f5ec22..6e65cae 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_hopping': _wlan0_hopping(), 'history_reset': _recon_history_reset()} @@ -3716,6 +3717,40 @@ def _wlan0_pinned(): return False +def _pineap_interfaces(): + """Runtime pineapd interface table from ``_pineap INTERFACE LIST``: + {name: {'channels': int, 'bands': str, 'hop': str, 'pkts': int}}.""" + rc, out, err = _pineap('INTERFACE', 'LIST', timeout=15) + if rc != 0: + return {} + result = {} + for line in (out or '').splitlines(): + parts = line.split() + if len(parts) < 7 or not parts[0].startswith('wlan'): + continue + result[parts[0]] = { + 'channels': int(parts[1]) if parts[1].isdigit() else None, + 'bands': parts[2], + 'type': parts[3], + 'hop': parts[4], + 'chan': parts[5], + 'pkts': int(parts[6]) if parts[6].isdigit() else None, + } + return result + + +def _wlan0_hopping(): + """Whether pineapd is actually hopping wlan0mon at runtime. The band UCI + config can be correct while runtime hopping is off, which starves 2.4GHz + recon results entirely.""" + ifaces = _pineap_interfaces() + mon = ifaces.get('wlan0mon') + if not mon: + return None + hop = mon.get('hop') + return bool(hop) and hop not in ('0', '', 'none', 'false') + + def env_check(): """Full environment pass, run at service startup and via ``server.py --env-check``. Auto-fixes everything fixable, re-verifies, and @@ -3774,6 +3809,16 @@ def env_check(): else: _env_step(report, 'pass', 'no radio0 AP pins wlan0mon') + hopping = _wlan0_hopping() + if hopping is False: + _env_step(report, 'warn', '2.4GHz recon starved: wlan0mon hopping is off at ' + 'runtime (INTERFACE LIST hop=0)') + elif hopping is None: + _env_step(report, 'warn', 'could not read pineapd interface state ' + '(INTERFACE LIST failed)') + else: + _env_step(report, 'pass', 'wlan0mon hopping on (2.4GHz scanning active)') + ENV_CHECK_STATE['report'] = report ENV_CHECK_STATE['overall'] = _env_overall(report) ENV_CHECK_STATE['updated'] = time.time() 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 00d7422..425e35b 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 }; + wlan0Pinned: false, wlan0Hopping: null }; const cols = reconLoadCols(); // ---- title cards (stat cards with optional mini charts) ---- @@ -1661,9 +1661,10 @@ 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.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.scanErr); + scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || state.wlan0Hopping === false || !!state.scanErr); } scanToggle.addEventListener('change', () => { if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } @@ -2526,6 +2527,7 @@ views.recon = (root) => { state.hopperOnline = r.data.hopper_online; state.historyReset = !!r.data.history_reset; state.wlan0Pinned = !!r.data.wlan0_pinned; + state.wlan0Hopping = r.data.wlan0_hopping; if (!pendingScan) scanToggle.checked = scanning; renderScanBar(); if (wasScanning !== scanning) restartPoll(); diff --git a/tests/test_env_check.py b/tests/test_env_check.py index 9ac8b97..6e9b4f0 100644 --- a/tests/test_env_check.py +++ b/tests/test_env_check.py @@ -30,6 +30,10 @@ class EnvCheckTest(unittest.TestCase): self.daemon_ok = True self.iface_up = {'wlan0mon': True, 'wlan1mon': True} self.uci_state = {} + self.interface_list = ( + 'Interface #ch Bands Type Hop Chan Pkts \n' + 'wlan1mon 25 5 max fast hop 21567\n' + 'wlan0mon 11 2 max fast hop 452\n') server.ENV_CHECK_STATE.update({'report': None, 'overall': None, 'updated': 0, 'pool_runtime': None}) self.old_iface_up = server._iface_up @@ -77,6 +81,8 @@ class EnvCheckTest(unittest.TestCase): return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.uci_state.items() if k.startswith(sec + '.')), '') if a[0] == '_pineap': + if len(a) > 1 and a[1] == 'INTERFACE': + return (0, self.interface_list, '') return (0, '', '') if a[0] in ('ip', '/etc/init.d/pineapd'): return (0, '', '') @@ -191,6 +197,26 @@ class EnvCheckTest(unittest.TestCase): report = server.env_check() self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass') + def test_wlan0_starved_warns(self): + self.safe_set() + self.interface_list = ( + 'Interface #ch Bands Type Hop Chan Pkts \n' + 'wlan1mon 25 5 max fast hop 21567\n' + 'wlan0mon 11 2 max 0 hop 452\n') + report = server.env_check() + self.assertEqual(self.steps(report, 'wlan0mon hopping is off')[0]['ok'], 'warn') + + def test_wlan0_hopping_unknown_warns(self): + self.safe_set() + self.interface_list = '' + report = server.env_check() + self.assertEqual(self.steps(report, 'could not read pineapd interface state')[0]['ok'], 'warn') + + def test_wlan0_hopping_pass(self): + self.safe_set() + report = server.env_check() + self.assertEqual(self.steps(report, 'wlan0mon hopping on')[0]['ok'], 'pass') + def test_cli_exits_zero_on_pass(self): self.safe_set() buf = io.StringIO()