fix: non-disruptive env check — drop wifi reload + misleading hop warning

- STA uplink disable now uses uci commit + ip link set wlan0 down instead of
  wifi reload, so the env check never bounces radios/APs/drops monitors
- Removed the wlan0mon 'hop is off' warning: field-verified hop=0 is normal
  (wlan1mon also reports 0 while scanning both bands); the STA/AP pinning
  checks are the real signals
- recon/status drops wlan0_hopping; UI keeps wlan0_sta + wlan0_pinned pills
This commit is contained in:
2026-08-19 10:55:26 -05:00
parent 5a72566381
commit 6e3968c19a
3 changed files with 11 additions and 77 deletions
@@ -1360,7 +1360,6 @@ def h_recon_status(ctx):
'hopper_online': _hopper_online(),
'wlan0_pinned': _wlan0_pinned(),
'wlan0_sta': _sta_uplink_enabled(),
'wlan0_hopping': _wlan0_hopping(),
'history_reset': _recon_history_reset()}
@@ -3727,43 +3726,14 @@ def _sta_uplink_enabled():
def _disable_sta_uplink():
"""Disable the dummy_radio0 STA without bouncing the radios. The UCI flag
keeps it off across reboots/wifi reloads; taking wlan0 down immediately
frees phy0's channel for wlan0mon. Never runs `wifi reload` here — that
tears down live APs and drops the monitors mid-assessment."""
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}}."""
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')
for iface in ('wlan0',):
rc, out, err = device_run(['ip', 'link', 'set', iface, 'down'], timeout=10)
def env_check():
@@ -3831,16 +3801,6 @@ 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()
@@ -1462,7 +1462,7 @@ views.recon = (root) => {
compare: [], history: {}, mapBand: null,
archive: null, archives: [], scanRemaining: null,
hopperOnline: null, historyReset: false, scanErr: null,
wlan0Pinned: false, wlan0Sta: false, wlan0Hopping: null };
wlan0Pinned: false, wlan0Sta: false };
const cols = reconLoadCols();
// ---- title cards (stat cards with optional mini charts) ----
@@ -1662,10 +1662,9 @@ views.recon = (root) => {
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.wlan0Sta || state.wlan0Hopping === false || !!state.scanErr);
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || state.wlan0Sta || !!state.scanErr);
}
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
@@ -2529,7 +2528,6 @@ views.recon = (root) => {
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();
if (wasScanning !== scanning) restartPoll();
+3 -27
View File
@@ -30,10 +30,6 @@ 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
@@ -81,8 +77,6 @@ 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, '', '')
@@ -197,26 +191,6 @@ 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_sta_uplink_disabled_when_enabled(self):
self.safe_set()
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
@@ -225,7 +199,9 @@ class EnvCheckTest(unittest.TestCase):
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)
self.assertIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs)
self.assertNotIn(['wifi', 'reload'], self.runs,
'STA fix must not bounce the radios')
def test_sta_uplink_pass_when_absent(self):
self.safe_set()