- deauth/kick: use full PINEAPPLE_DEAUTH_CLIENT hak5cmd app name (BUG 1)
- _allow_all_ssids: PINEAPPLE_NETWORK_FILTER_MODE deny so karma lets
clients associate after deploy (BUG 2)
- enterprise inner EAP (BUG 3), two root causes found by live experiment:
- hostapd never wildcard-matches a bare `*` identity for phase-2
lookups; eap_users now uses quoted empty prefix `""* ... [2]`,
which prefix-matches any inner identity
- pineape_auth_pass=1 forwards inner EAP to pineapd, which has no
standalone responder; deploy forces 0 and stop restores 1
Residual: plaintext MSCHAPv2 capture is firmware-blocked on 24.10.1
(MSG_DEBUG compiled out of the karma wpad; `-f` silently ignored);
documented in code comments and the validation report.
- ISSUE 4: point pineapd.@hostapd[0].mgmtiface at wlan1wpa during 5 GHz
WPA deploys so handshakes/loot populate; cleared on stop
- ISSUE 5: radio0 set_ap path polls 90 s across the wifi-reload window
and retries set_ap once if the iface still has not appeared
- ISSUE 6: skills docs use `llc && eth.type == 0x888e` (firmware tcpdump
matches 0 frames on `eapol`)
- ISSUE 7: capture state helper detects dead-pid / iface-down, cleans up,
status reports {running:false, stale:true} instead of zombie running
- tests: fix global os.path monkeypatch leaks between test modules that
broke test_mk8_events/test_reliability_api under discovery; add
regression coverage for all fixes above (463 tests green)
Live validation evidence and newly discovered firmware quirks
(DEVICE_FILTER_DELETE no-op, dropbear rate limiting) recorded in
docs/validation/2026-08-23-pineap-validation-report.md.
372 lines
16 KiB
Python
372 lines
16 KiB
Python
import os
|
|
import sys
|
|
import unittest
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
|
import server
|
|
|
|
|
|
def setUpModule():
|
|
__import__('importlib').reload(server)
|
|
|
|
|
|
class ChannelBandTest(unittest.TestCase):
|
|
def test_2g_channels(self):
|
|
for ch in (1, 6, 11, 14):
|
|
self.assertEqual(server.channel_band(ch), server.BAND_2G)
|
|
|
|
def test_5g_channels(self):
|
|
for ch in (36, 48, 100, 149, 165, 177):
|
|
self.assertEqual(server.channel_band(ch), server.BAND_5G)
|
|
|
|
def test_6g_channels(self):
|
|
for ch in (181, 189, 197, 205, 213, 225, 233):
|
|
self.assertEqual(server.channel_band(ch), server.BAND_6G)
|
|
|
|
def test_invalid(self):
|
|
for ch in (0, 15, 17, 21, 33, 35, 178, 234, None, 'x'):
|
|
self.assertIsNone(server.channel_band(ch))
|
|
|
|
|
|
class ChannelBandsConsistencyTest(unittest.TestCase):
|
|
def test_lists_match_channel_band(self):
|
|
for band, channels in server.CHANNEL_BANDS.items():
|
|
for ch in channels:
|
|
self.assertEqual(server.channel_band(ch), band, '%s should be %s' % (ch, band))
|
|
|
|
def test_no_out_of_list_channels(self):
|
|
for ch in list(range(0, 235)):
|
|
band = server.channel_band(ch)
|
|
if band is not None:
|
|
self.assertIn(ch, server.CHANNEL_BANDS[band], '%s should be listed for %s' % (ch, band))
|
|
|
|
def test_boundaries(self):
|
|
self.assertEqual(server.CHANNEL_BANDS[server.BAND_2G][-1], 14)
|
|
self.assertEqual(server.CHANNEL_BANDS[server.BAND_5G][-1], 177)
|
|
self.assertEqual(server.CHANNEL_BANDS[server.BAND_6G][0], 181)
|
|
self.assertEqual(server.CHANNEL_BANDS[server.BAND_6G][-1], 233)
|
|
|
|
|
|
class ChannelFreqTest(unittest.TestCase):
|
|
def test_freqs(self):
|
|
self.assertEqual(server.channel_freq(server.BAND_2G, 1), 2412)
|
|
self.assertEqual(server.channel_freq(server.BAND_2G, 11), 2462)
|
|
self.assertEqual(server.channel_freq(server.BAND_5G, 36), 5180)
|
|
self.assertEqual(server.channel_freq(server.BAND_5G, 165), 5825)
|
|
self.assertEqual(server.channel_freq(server.BAND_6G, 1), 5955)
|
|
self.assertEqual(server.channel_freq(server.BAND_6G, 233), 7115)
|
|
|
|
|
|
class BandAuxTest(unittest.TestCase):
|
|
def test_htmode(self):
|
|
self.assertEqual(server.band_htmode(server.BAND_2G), 'HT20')
|
|
self.assertEqual(server.band_htmode(server.BAND_5G), 'VHT80')
|
|
self.assertEqual(server.band_htmode(server.BAND_6G), 'HE80')
|
|
|
|
def test_radio(self):
|
|
self.assertEqual(server.band_radio(server.BAND_2G), 'radio0')
|
|
self.assertEqual(server.band_radio(server.BAND_5G), 'radio1')
|
|
self.assertEqual(server.band_radio(server.BAND_6G), 'radio1')
|
|
|
|
def test_dfs_marker(self):
|
|
for ch in (52, 64, 100, 144):
|
|
self.assertIn(ch, server.DFS_CHANNELS)
|
|
for ch in (36, 48, 149):
|
|
self.assertNotIn(ch, server.DFS_CHANNELS)
|
|
self.assertEqual(set(server.DFS_CHANNELS),
|
|
set(range(52, 65, 4)) | set(range(100, 145, 4)))
|
|
|
|
|
|
def ctx(body=None):
|
|
return type('C', (), {'body': body, 'args': (), 'query': {}})()
|
|
|
|
|
|
class GetApRadio1Test(unittest.TestCase):
|
|
def _uci(self, section):
|
|
table = {
|
|
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
|
|
'htmode': 'HT20', 'country': 'US'},
|
|
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
|
|
'htmode': 'VHT80', 'country': 'US'},
|
|
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'none',
|
|
'channel': '11'},
|
|
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
|
|
'channel': '1', 'key': 'testpass123'},
|
|
'wlan1open': {'device': 'radio1', 'mode': 'ap', 'ssid': 'CorpGuest',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'none',
|
|
'channel': '36'},
|
|
'wlan1wpa': {'device': 'radio1', 'mode': 'ap', 'ssid': 'Corp',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'sae',
|
|
'channel': '1', 'key': 'secret123'},
|
|
}
|
|
return dict(table.get(section, {}))
|
|
|
|
def setUp(self):
|
|
server._uci_wifi_iface = lambda name: self._uci(name)
|
|
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
|
|
404, {'error': 'not found'})
|
|
|
|
def test_open_reports_radio1_when_present(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['radio1_open']['ssid'], 'CorpGuest')
|
|
self.assertEqual(payload['radio1_open']['channel'], 36)
|
|
self.assertEqual(payload['radio1_open']['country'], 'US')
|
|
# radio0 cards keep reporting radio0 truth
|
|
self.assertEqual(payload['open']['ssid'], 'pager-open')
|
|
|
|
def test_wpa_reports_radio1_when_present(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['radio1_wpa']['ssid'], 'Corp')
|
|
self.assertEqual(payload['radio1_wpa']['enctype'], 'sae')
|
|
self.assertEqual(payload['radio1_wpa']['channel'], 1)
|
|
self.assertEqual(payload['wpa']['ssid'], 'Service')
|
|
|
|
def test_radio1_info(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['radios']['radio1']['band'], server.BAND_5G)
|
|
self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
|
|
self.assertEqual(payload['radios']['radio0']['band'], server.BAND_2G)
|
|
|
|
|
|
class GetApRadio1AbsentTest(unittest.TestCase):
|
|
"""Regression: no radio1 AP sections -> today's 2.4GHz behavior."""
|
|
|
|
def _uci(self, section):
|
|
table = {
|
|
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
|
|
'htmode': 'HT20', 'country': 'US'},
|
|
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
|
|
'htmode': 'VHT80', 'country': 'US'},
|
|
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'none',
|
|
'channel': '11'},
|
|
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
|
|
'channel': '1', 'key': 'testpass123'},
|
|
}
|
|
return dict(table.get(section, {}))
|
|
|
|
def setUp(self):
|
|
server._uci_wifi_iface = lambda name: self._uci(name)
|
|
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
|
|
404, {'error': 'not found'})
|
|
|
|
def test_open_uses_wlan0open(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['open']['ssid'], 'pager-open')
|
|
self.assertEqual(payload['open']['channel'], 11)
|
|
|
|
def test_wpa_uses_wlan0wpa(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['wpa']['ssid'], 'Service')
|
|
self.assertEqual(payload['wpa']['channel'], 1)
|
|
|
|
|
|
class SetApRadio1Test(unittest.TestCase):
|
|
def setUp(self):
|
|
self.uci = {}
|
|
self.runs = []
|
|
|
|
def fake_uci_get(section):
|
|
return self.uci.get(section)
|
|
|
|
def fake_run(args, timeout=20, input_data=None):
|
|
self.runs.append((list(args), input_data))
|
|
a = list(args)
|
|
if a[:2] == ['uci', 'set']:
|
|
k, _, v = a[2].partition('=')
|
|
self.uci[k] = v
|
|
if a[:2] == ['uci', 'get']:
|
|
return (0, self.uci.get(a[2], '') + '\n', '')
|
|
return (0, '', '')
|
|
|
|
server._uci_wifi_iface = fake_uci_get
|
|
server._uci_section = fake_uci_get
|
|
server.device_run = fake_run
|
|
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
|
|
200, {'success': True})
|
|
|
|
def test_5g_open_writes_radio1_sections(self):
|
|
server.h_pineap_wifi_set_ap(ctx({'open': {
|
|
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
|
|
'channel': 36, 'country': 'US'}}))
|
|
self.assertEqual(self.uci['wireless.radio1.band'], '5g')
|
|
self.assertEqual(self.uci['wireless.radio1.channel'], '36')
|
|
self.assertEqual(self.uci['wireless.radio1.htmode'], 'VHT80')
|
|
self.assertEqual(self.uci['wireless.wlan1open'], 'wifi-iface')
|
|
self.assertEqual(self.uci['wireless.wlan1open.device'], 'radio1')
|
|
self.assertEqual(self.uci['wireless.wlan1open.disabled'], '0')
|
|
self.assertEqual(self.uci['wireless.wlan1open.ssid'], 'CorpGuest')
|
|
self.assertEqual(self.uci['wireless.wlan1open.encryption'], 'none')
|
|
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '0')
|
|
self.assertIn(['wifi', 'reload'], [r[0] for r in self.runs])
|
|
self.assertIn(['/etc/init.d/pineapd', 'reload'], [r[0] for r in self.runs])
|
|
|
|
def test_6g_wpa_sae(self):
|
|
server.h_pineap_wifi_set_ap(ctx({'wpa': {
|
|
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'sae',
|
|
'hidden': False, 'enabled': True, 'channel': 181}}))
|
|
self.assertEqual(self.uci['wireless.radio1.band'], '6g')
|
|
self.assertEqual(self.uci['wireless.radio1.htmode'], 'HE80')
|
|
self.assertEqual(self.uci['wireless.wlan1wpa.encryption'], 'sae')
|
|
|
|
def test_6g_rejects_psk2(self):
|
|
status, payload = server.h_pineap_wifi_set_ap(ctx({'wpa': {
|
|
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'psk2',
|
|
'hidden': False, 'enabled': True, 'channel': 181}}))
|
|
self.assertEqual(status, 400)
|
|
|
|
def test_6g_open_rejected(self):
|
|
status, payload = server.h_pineap_wifi_set_ap(ctx({'open': {
|
|
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
|
|
'channel': 181, 'country': 'US'}}))
|
|
self.assertEqual(status, 400)
|
|
self.assertIn('6GHz', payload['error'])
|
|
|
|
def test_6g_disable_removes_radio1(self):
|
|
self.uci['pineapd.wlan1mon.hop'] = '0'
|
|
self.uci['wlan1wpa'] = {'device': 'radio1'}
|
|
status, payload = server.h_pineap_wifi_set_ap(ctx({'wpa': {
|
|
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'sae',
|
|
'hidden': False, 'enabled': False, 'channel': 181}}))
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(self.uci['wireless.radio1.channel'], 'auto')
|
|
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
|
|
|
|
def test_5g_open_rejects_bad_bssid(self):
|
|
status, payload = server.h_pineap_wifi_set_ap(ctx({'open': {
|
|
'ssid': 'CorpGuest', 'bssid': 'not-a-mac', 'hidden': False,
|
|
'enabled': True, 'channel': 36, 'country': 'US'}}))
|
|
self.assertEqual(status, 400)
|
|
|
|
def test_hop_read_failure_still_pauses(self):
|
|
def fake_run(args, timeout=20, input_data=None):
|
|
self.runs.append((list(args), input_data))
|
|
a = list(args)
|
|
if a[:2] == ['uci', 'get']:
|
|
return (1, '', '')
|
|
if a[:2] == ['uci', 'set']:
|
|
k, _, v = a[2].partition('=')
|
|
self.uci[k] = v
|
|
return (0, '', '')
|
|
|
|
server.device_run = fake_run
|
|
server.h_pineap_wifi_set_ap(ctx({'open': {
|
|
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
|
|
'channel': 36, 'country': 'US'}}))
|
|
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '0')
|
|
|
|
def test_2g_still_uses_daemon_path(self):
|
|
calls = []
|
|
|
|
def fake_sock(method, path, body=None, timeout=10):
|
|
if method == 'GET':
|
|
return 200, {'loghandshake': True}
|
|
calls.append((method, path, body))
|
|
return 200, {'success': True}
|
|
|
|
server.daemon_sock_call = fake_sock
|
|
server.h_pineap_wifi_set_ap(ctx({'open': {
|
|
'ssid': 'pager-open', 'hidden': False, 'enabled': True,
|
|
'channel': 11, 'country': 'US'}}))
|
|
put = [c for c in calls if c[0] == 'PUT']
|
|
self.assertEqual(len(put), 1)
|
|
self.assertEqual(put[0][1], '/api/settings/wifi/set_ap')
|
|
self.assertEqual(put[0][2]['configs'][0]['interface'], 'wlan0open')
|
|
|
|
def test_2g_removes_existing_radio1(self):
|
|
self.uci['pineapd.wlan1mon.hop'] = '0'
|
|
self.uci['wlan1open'] = {'device': 'radio1'}
|
|
server.h_pineap_wifi_set_ap(ctx({'open': {
|
|
'ssid': 'pager-open', 'hidden': False, 'enabled': True,
|
|
'channel': 11, 'country': 'US'}}))
|
|
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
|
|
self.assertEqual(self.uci.get('wireless.radio1.channel'), 'auto')
|
|
|
|
def test_mixed_24g_and_radio1_rejected(self):
|
|
status, payload = server.h_pineap_wifi_set_ap(ctx({
|
|
'open': {'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
|
|
'channel': 36, 'country': 'US'},
|
|
'wpa': {'ssid': 'Office', 'passphrase': 'secret123', 'enctype': 'psk2',
|
|
'hidden': False, 'enabled': True, 'channel': 6}}))
|
|
self.assertEqual(status, 400)
|
|
self.assertIn('2.4GHz', payload['error'])
|
|
|
|
|
|
class GetApRadioChannelFallbackTest(unittest.TestCase):
|
|
"""Regression: iface without a channel option inherits the radio channel."""
|
|
|
|
def _uci(self, section):
|
|
table = {
|
|
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
|
|
'htmode': 'HT20', 'country': 'US'},
|
|
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
|
|
'htmode': 'VHT80', 'country': 'US'},
|
|
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'none'},
|
|
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
|
|
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
|
|
'key': 'testpass123'},
|
|
}
|
|
return dict(table.get(section, {}))
|
|
|
|
def setUp(self):
|
|
server._uci_wifi_iface = lambda name: self._uci(name)
|
|
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
|
|
404, {'error': 'not found'})
|
|
|
|
def test_open_falls_back_to_radio_channel(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['open']['channel'], 11)
|
|
|
|
def test_wpa_falls_back_to_radio_channel(self):
|
|
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertEqual(status, 200)
|
|
self.assertEqual(payload['wpa']['channel'], 11)
|
|
|
|
|
|
class GetApReconcileTest(unittest.TestCase):
|
|
def setUp(self):
|
|
server._last_reconcile = 0.0
|
|
self.uci = {'wlan1wpa': {'device': 'radio1', 'mode': 'ap', 'ssid': 'Corp',
|
|
'disabled': '0', 'encryption': 'sae', 'channel': '36'}}
|
|
self.runs = []
|
|
server._uci_wifi_iface = lambda name: dict(self.uci.get(name, {}))
|
|
server._uci_section = lambda name: {}
|
|
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
|
|
404, {'error': 'not found'})
|
|
server.device_run = lambda args, timeout=20, input_data=None: (
|
|
self.runs.append(list(args)) or (0, '', ''))
|
|
self._real_exists = os.path.exists
|
|
|
|
def tearDown(self):
|
|
os.path.exists = self._real_exists
|
|
|
|
def test_missing_netdev_triggers_wifi_reload(self):
|
|
os.path.exists = lambda p: False
|
|
server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertIn(['wifi', 'reload'], self.runs)
|
|
|
|
def test_present_netdev_skips_reload(self):
|
|
os.path.exists = lambda p: True
|
|
server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertNotIn(['wifi', 'reload'], self.runs)
|
|
|
|
def test_disabled_section_skips_reload(self):
|
|
self.uci['wlan1wpa']['disabled'] = '1'
|
|
os.path.exists = lambda p: False
|
|
server.h_pineap_wifi_get_ap(ctx())
|
|
self.assertNotIn(['wifi', 'reload'], self.runs)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|