fix: harden UI actions and daemon calls for reliable control (v1.3.2)
Retry and serialize pineapd/hak5 calls, queue virtual-pager keys, and grey out buttons until the pager finishes. Deploy now installs python3-light after factory firmware. Bump version to 1.3.2. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+148
-22
@@ -163,33 +163,54 @@ class FakeSock:
|
||||
|
||||
|
||||
class ReconHopperTest(unittest.TestCase):
|
||||
def test_preflight_verifies_every_non_dfs_channel(self):
|
||||
def test_preflight_probes_one_channel_per_radio(self):
|
||||
calls = []
|
||||
with mock.patch.object(
|
||||
server, '_set_monitor_channel',
|
||||
side_effect=lambda interface, channel:
|
||||
calls.append((interface, channel)) or (True, '')):
|
||||
self.assertEqual(
|
||||
server._recon_hopper_preflight(),
|
||||
(True, 'monitor channel control ready'))
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
self.assertEqual(
|
||||
server._recon_hopper_preflight(),
|
||||
(True, 'monitor channel control ready'))
|
||||
expected = [
|
||||
(interface, channel)
|
||||
(interface, channels[0])
|
||||
for interface, channels in server.RECON_CHANNELS.items()
|
||||
for channel in channels
|
||||
]
|
||||
self.assertEqual(calls, expected)
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan0mon', 'wlan1mon'])
|
||||
|
||||
def test_preflight_stops_at_first_unusable_channel(self):
|
||||
def test_preflight_skips_busy_radio_and_keeps_the_other(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan1mon' and channel == 44:
|
||||
return False, 'wlan1mon channel 44: busy'
|
||||
if interface == 'wlan0mon':
|
||||
return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
|
||||
return True, ''
|
||||
|
||||
with mock.patch.object(server, '_set_monitor_channel', side_effect=set_channel):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
with mock.patch.object(server, '_sta_uplink_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=True):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertTrue(ok)
|
||||
self.assertIn('wlan0mon', server._recon_hop_state['skipped'])
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan1mon'])
|
||||
self.assertIn('2.4 GHz hopping skipped', detail)
|
||||
self.assertIn('Scanning 5 GHz only', detail)
|
||||
|
||||
def test_preflight_fails_when_no_monitor_is_usable(self):
|
||||
with mock.patch.object(
|
||||
server, '_set_monitor_channel', side_effect=set_channel):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
server, '_set_monitor_channel',
|
||||
return_value=(False, 'wlan0mon channel 1: No such device')):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=True):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('wlan1mon channel 44', detail)
|
||||
self.assertIn('unavailable', detail.lower())
|
||||
|
||||
def test_busy_error_is_classified(self):
|
||||
self.assertEqual(
|
||||
server._iw_error_kind('wlan0mon channel 1: command failed: Resource busy (-16)'),
|
||||
'busy')
|
||||
self.assertEqual(server._iw_error_kind('No such device'), 'missing')
|
||||
|
||||
def test_set_channel_surfaces_iw_failure(self):
|
||||
with mock.patch.object(
|
||||
@@ -200,6 +221,67 @@ class ReconHopperTest(unittest.TestCase):
|
||||
self.assertIn('wlan0mon channel 6', detail)
|
||||
self.assertIn('Device or resource busy', detail)
|
||||
|
||||
def test_dummy_sta_not_borrowable_when_client_mode_on(self):
|
||||
with mock.patch.object(server, '_wifi_client_mode_enabled', return_value=True):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_mgmt_enabled', return_value=False):
|
||||
self.assertFalse(server._dummy_sta_borrowable())
|
||||
|
||||
def test_dummy_sta_borrowable_when_only_dummy_is_up(self):
|
||||
with mock.patch.object(server, '_wifi_client_mode_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_mgmt_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_iface_associated', return_value=False):
|
||||
with mock.patch.object(server, '_sta_uplink_enabled', return_value=True):
|
||||
self.assertTrue(server._dummy_sta_borrowable())
|
||||
|
||||
def test_preflight_parks_dummy_sta_and_hops_24ghz(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan0mon' and not server._recon_hop_state.get('borrowed_wlan0'):
|
||||
return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
|
||||
return True, ''
|
||||
|
||||
def borrow():
|
||||
server._recon_hop_state['borrowed_wlan0'] = True
|
||||
return True
|
||||
|
||||
with mock.patch.object(server, '_set_monitor_channel', side_effect=set_channel):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
with mock.patch.object(server, '_dummy_sta_borrowable', return_value=True):
|
||||
with mock.patch.object(server, '_borrow_dummy_sta', side_effect=borrow):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(detail, 'monitor channel control ready')
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan0mon', 'wlan1mon'])
|
||||
self.assertTrue(server._recon_hop_state['borrowed_wlan0'])
|
||||
self.assertEqual(server._recon_hop_state['skipped'], {})
|
||||
|
||||
def test_preflight_does_not_park_when_ap_holds_phy0(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan0mon':
|
||||
return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
|
||||
return True, ''
|
||||
|
||||
with mock.patch.object(server, '_set_monitor_channel', side_effect=set_channel):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
with mock.patch.object(server, '_dummy_sta_borrowable', return_value=False):
|
||||
with mock.patch.object(server, '_borrow_dummy_sta') as borrow:
|
||||
with mock.patch.object(server, '_sta_uplink_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=True):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertTrue(ok)
|
||||
borrow.assert_not_called()
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan1mon'])
|
||||
self.assertIn('Open AP / Evil WPA', detail)
|
||||
|
||||
def test_reset_restores_parked_dummy_sta(self):
|
||||
server._recon_hop_state['borrowed_wlan0'] = True
|
||||
with mock.patch.object(
|
||||
server, 'device_run', return_value=(0, '', '')) as run:
|
||||
server._reset_recon_hop_state()
|
||||
run.assert_any_call(['ip', 'link', 'set', 'wlan0', 'up'], timeout=10)
|
||||
self.assertFalse(server._recon_hop_state['borrowed_wlan0'])
|
||||
|
||||
|
||||
class DaemonSockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -278,15 +360,36 @@ class DaemonSockTest(unittest.TestCase):
|
||||
def test_start_reports_hopper_preflight_failure(self):
|
||||
calls = []
|
||||
server._recon_hopper_preflight.return_value = (
|
||||
False, 'wlan1mon channel 36: Device or resource busy')
|
||||
False, 'Recon radios are unavailable. wlan0mon is missing.')
|
||||
server.daemon_sock_call = lambda *args, **kwargs: calls.append(args)
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 503)
|
||||
self.assertEqual(data['error'], 'recon radio preflight failed')
|
||||
self.assertIn('wlan1mon', data['detail'])
|
||||
self.assertEqual(data['error'], 'Could not prepare recon radios')
|
||||
self.assertIn('unavailable', data['detail'])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_start_returns_warning_when_a_radio_is_skipped(self):
|
||||
calls = []
|
||||
def fake_preflight():
|
||||
server._recon_hop_state.update({
|
||||
'warning': '2.4 GHz hopping skipped: Open AP is holding phy0. Scanning 5 GHz only.',
|
||||
'ifaces': ['wlan1mon'],
|
||||
'skipped': {'wlan0mon': '2.4 GHz hopping skipped: Open AP is holding phy0.'},
|
||||
'hint': 'Stop the 2.4 GHz AP to hop 2.4 GHz.',
|
||||
})
|
||||
return True, server._recon_hop_state['warning']
|
||||
server._recon_hopper_preflight.side_effect = fake_preflight
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data.get('ok'))
|
||||
self.assertIn('2.4 GHz hopping skipped', data.get('warning'))
|
||||
self.assertEqual(data.get('hopping'), ['wlan1mon'])
|
||||
self.assertEqual(calls[0][1], '/api/pineap/recon/new')
|
||||
server._start_recon_hopper.assert_called_once_with(30)
|
||||
|
||||
|
||||
class ReconScanStateTest(unittest.TestCase):
|
||||
"""The webui mirrors the duration of the Pager's native timed scan."""
|
||||
@@ -487,7 +590,7 @@ class ReconExamineTest(unittest.TestCase):
|
||||
ctx = type('C', (), {'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF'))
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF', '30'))
|
||||
|
||||
def test_examine_channel_calls_hak5(self):
|
||||
calls = []
|
||||
@@ -495,7 +598,24 @@ class ReconExamineTest(unittest.TestCase):
|
||||
ctx = type('C', (), {'args': (), 'body': {'channel': 6}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '6'))
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '6', '30'))
|
||||
|
||||
def test_examine_channel_5ghz_sends_duration(self):
|
||||
calls = []
|
||||
server.hak5 = lambda *args, **kw: calls.append(args) or ''
|
||||
ctx = type('C', (), {'args': (), 'body': {'channel': 140, 'seconds': 15}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '140', '15'))
|
||||
self.assertEqual(data.get('seconds'), 15)
|
||||
|
||||
def test_examine_compact_bssid_is_colonized(self):
|
||||
calls = []
|
||||
server.hak5 = lambda *args, **kw: calls.append(args) or ''
|
||||
ctx = type('C', (), {'args': (), 'body': {'bssid': 'aabbccddeeff'}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF', '30'))
|
||||
|
||||
def test_examine_requires_target(self):
|
||||
server.hak5 = lambda *args, **kw: ''
|
||||
@@ -1186,18 +1306,24 @@ class WigleTest(unittest.TestCase):
|
||||
return type('C', (), {'args': args, 'body': body or {}})()
|
||||
|
||||
def _write(self, name, content):
|
||||
with open(os.path.join(self.dir, name), 'w') as f:
|
||||
f.write(content)
|
||||
raw = content.encode('utf-8') if isinstance(content, str) else content
|
||||
path = os.path.join(self.dir, name)
|
||||
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644)
|
||||
try:
|
||||
os.write(fd, raw)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def test_file_rows_count_excludes_header(self):
|
||||
self._write('a.csv', 'header\nr1\nr2\n')
|
||||
self._write('b.csv', 'onlyheader\n')
|
||||
payload = b'header\nr1\nr2\n'
|
||||
self._write('a.csv', payload)
|
||||
self._write('b.csv', b'onlyheader\n')
|
||||
status, data = server.h_recon_wigle_files(self._ctx())
|
||||
self.assertEqual(status, 200)
|
||||
files = {f['name']: f for f in data['files']}
|
||||
self.assertEqual(files['a.csv']['rows'], 2)
|
||||
self.assertEqual(files['b.csv']['rows'], 0)
|
||||
self.assertEqual(files['a.csv']['size'], len('header\nr1\nr2\n'))
|
||||
self.assertEqual(files['a.csv']['size'], os.path.getsize(os.path.join(self.dir, 'a.csv')))
|
||||
|
||||
def test_file_rows_count_ignores_wigle_meta_and_header(self):
|
||||
meta = 'WigleWifi-1.6,appRelease=0.0.0,model=pineapplepager,release=0.0.0\n'
|
||||
|
||||
Reference in New Issue
Block a user