diff --git a/README.md b/README.md index 7add2b6..e57f9b6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ terminal. ## Requirements - WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` -- `python3` on the device (present on current firmware) +- `python3` on the device (factory 24.10.1 may not ship it; `scripts/deploy.sh` installs OpenWrt `python3-light` from offline ipks) - Python 3.11 on the development machine ## Install (sideload) @@ -53,10 +53,12 @@ Then on the Pager menu, run **Mark VIII**: Every payload run (and every service startup) first runs an **environment check** that prints on the payload screen / `/tmp/pagerwebui.log`: daemon -reachable, pineapd alive, sane-off PineAP UCI defaults applied (SSID pool -broadcast off, 6GHz hopper disabled, monitor bands), runtime pool sync, monitor -interfaces up, and recon DB readable. Anything fixable is fixed and re-verified; -startup aborts only if a core dependency fails. +reachable, pineapd alive, monitor interfaces up, and recon DB readable. On a +healthy pager it **does not rewrite** live PineAP or wireless UCI (SSID pool, +hopping, dummy_radio0 STA, Open AP). Crash-prone settings are reported as +warnings. Startup aborts only if a core dependency fails. The health monitor +will restart a dead pineapd and re-raise dropped monitors; it will not clear +the SSID list or disable pool broadcast. Browse `http://172.16.52.1:8080/` and log in with the device password. diff --git a/payload/user/remote_access/pager-webui/_hak5_manifest.json b/payload/user/remote_access/pager-webui/_hak5_manifest.json index 33454bb..19302e1 100644 --- a/payload/user/remote_access/pager-webui/_hak5_manifest.json +++ b/payload/user/remote_access/pager-webui/_hak5_manifest.json @@ -8,7 +8,7 @@ "title": "Mark VIII", "author": "c4ch3c4d3", "description": "Mark VII-style web management UI for the WiFi Pineapple Pager", - "version": "1.3.1", + "version": "1.3.2", "category": "remote_access", "tags": ["remote-access", "web-interface", "device-management", "pineap"], "firmware": "Pineapple Pager 24.10.1" diff --git a/payload/user/remote_access/pager-webui/pagerwebui.init b/payload/user/remote_access/pager-webui/pagerwebui.init index 5ff4cc8..1fea820 100755 --- a/payload/user/remote_access/pager-webui/pagerwebui.init +++ b/payload/user/remote_access/pager-webui/pagerwebui.init @@ -21,7 +21,7 @@ start_service() { procd_set_param respawn procd_set_param stdout 1 procd_set_param stderr 1 - procd_set_param term_timeout 10 + procd_set_param term_timeout 90 procd_close_instance } diff --git a/payload/user/remote_access/pager-webui/payload.sh b/payload/user/remote_access/pager-webui/payload.sh index 68f1699..d3fde73 100755 --- a/payload/user/remote_access/pager-webui/payload.sh +++ b/payload/user/remote_access/pager-webui/payload.sh @@ -2,7 +2,7 @@ # Title: Mark VIII # Description: Mark VII-style web management UI for the WiFi Pineapple Pager # Author: c4ch3c4d3 -# Version: 1.3.1 +# Version: 1.3.2 # Category: Remote-Access # Tags: remote-access, web-interface, device-management, pineap # Firmware: Pineapple Pager 24.10.1 @@ -28,7 +28,7 @@ get_pager_ip() { } LOG "cyan" "+---------------------------+" -LOG "cyan" "| Mark VIII v1.3.1 |" +LOG "cyan" "| Mark VIII v1.3.2 |" LOG "cyan" "+---------------------------+" if ! command -v python3 >/dev/null 2>&1; then @@ -70,8 +70,14 @@ wait_for_server_stop() { return 1 } +release_pager_truth() { + python3 "$SCRIPT_DIR/server.py" --release-pager >/tmp/pagerwebui-release.log 2>&1 || true +} + remove_boot_service() { "$INIT_SCRIPT" stop 2>/dev/null || true + wait_for_server_stop 90 || true + release_pager_truth "$INIT_SCRIPT" disable 2>/dev/null || true rm -f "$INIT_SCRIPT" } @@ -84,7 +90,7 @@ if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then if user_confirmed "$resp"; then LOG "yellow" "Stopping service..." remove_boot_service - if ! wait_for_server_stop 12; then + if ! wait_for_server_stop 90; then LOG "red" "Service is still listening on port $PORT" exit 1 fi @@ -147,8 +153,10 @@ LOG "cyan" "Starting foreground mode..." cleanup() { LOG "yellow" "Stopping Mark VIII..." [ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null + wait_for_server_stop 90 || true + release_pager_truth rm -f "$PID_FILE" - LOG "cyan" "Stopped." + LOG "cyan" "Stopped. Pager UI is source of truth." } trap cleanup EXIT INT TERM diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index b8c9525..c4d1d32 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -34,6 +34,30 @@ PINEAP_STATE_FILE = os.environ.get( 'PAGER_PINEAP_STATE_FILE', os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp', 'pagerwebui.pineap-state')) +PAGER_SNAPSHOT_FILE = os.environ.get( + 'PAGER_PAGER_SNAPSHOT', + os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp', + 'pagerwebui.pager-snapshot.json')) +# UCI keys Mark VIII may overlay. Daemon-mediated PineAP (SSID pool contents, +# karma, filters lists) stays with the Pager UI; these keys plus radio1 AP +# sections are restored on graceful shutdown. +PAGER_SNAPSHOT_UCI = ( + 'pineapd.wlan1mon.hop', + 'pineapd.wlan2mon.disable', + 'pineapd.wlan2mon.hop', + 'pineapd.wlan1mon.bands', + 'pineapd.wlan0mon.bands', + 'pineapd.@ssidpool[0].disable', + 'pineapd.@hostapd[0].mgmtiface', + 'pineapd.@ssid_filter[0].mode', + 'pineapd.@mac_filter[0].mode', + 'wireless.dummy_radio0.disabled', + 'wireless.radio1.channel', + 'wireless.radio1.band', + 'wireless.radio1.htmode', + 'wireless.radio1.country', +) +PAGER_SNAPSHOT_IFACES = ('wlan1open', 'wlan1wpa', 'wlan1ent') WWW_DIR = os.environ.get('PAGER_WWW_DIR', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'www')) HOST = os.environ.get('PAGER_HOST', '0.0.0.0') @@ -49,7 +73,10 @@ RECON_CHANNELS = { } _recon_scan_lock = threading.Lock() _recon_hopper_stop = threading.Event() -_recon_hop_state = {'active': False, 'error': None} +_recon_hop_state = { + 'active': False, 'error': None, 'warning': None, 'hint': None, + 'ifaces': [], 'skipped': {}, 'borrowed_wlan0': False, +} _recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}} _recon_status_cache = { 'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None} @@ -64,6 +91,12 @@ _gps_cache = {'updated': 0, 'data': None} _gps_lock = threading.Lock() _payload_runs = {} _payload_runs_lock = threading.Lock() +_daemon_sock_lock = threading.Lock() +_pineapd_cmd_lock = threading.Lock() +DAEMON_SOCK_RETRIES = 2 +DAEMON_SOCK_RETRY_SLEEP = 0.05 +HAK5_RETRIES = 2 +HAK5_RETRY_SLEEP = 0.05 PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs') PAYLOAD_ROOTS = tuple(os.path.realpath(path) for path in os.environ.get('PAGER_PAYLOAD_ROOTS', @@ -78,8 +111,14 @@ def device_run(args, timeout=20, input_data=None): return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace') except FileNotFoundError: return 127, '', 'not found' - except subprocess.TimeoutExpired: - return 124, '', 'timeout' + except subprocess.TimeoutExpired as exc: + out = exc.stdout if exc.stdout is not None else b'' + err = exc.stderr if exc.stderr is not None else b'' + if not isinstance(out, str): + out = out.decode('utf-8', 'replace') + if not isinstance(err, str): + err = err.decode('utf-8', 'replace') + return 124, out, err or 'timeout' def _daemon_addr(): @@ -90,7 +129,25 @@ def _daemon_addr(): return base, 80 +def _retry_status0(fn): + """Retry a (status, payload) call when the peer never answered (status 0).""" + last = (0, None) + attempts = max(1, DAEMON_SOCK_RETRIES + 1) + for attempt in range(attempts): + last = fn() + if last[0] != 0: + return last + if attempt + 1 < attempts and DAEMON_SOCK_RETRY_SLEEP: + time.sleep(DAEMON_SOCK_RETRY_SLEEP * (attempt + 1)) + return last + + def daemon_call(method, path, body=None, token=None, timeout=15): + return _retry_status0( + lambda: _daemon_call_once(method, path, body=body, token=token, timeout=timeout)) + + +def _daemon_call_once(method, path, body=None, token=None, timeout=15): host, port = _daemon_addr() data = json.dumps(body).encode() if body is not None else None lines = ['%s %s HTTP/1.1' % (method, path), 'Host: %s:%d' % (host, port), @@ -154,6 +211,14 @@ def daemon_call(method, path, body=None, token=None, timeout=15): def daemon_sock_call(method, path, body=None, timeout=10): """Raw HTTP/1.1 request to the daemon's local unix-socket API. Returns (status, json|None).""" + def once(): + with _daemon_sock_lock: + return _daemon_sock_once(method, path, body=body, timeout=timeout) + return _retry_status0(once) + + +def _daemon_sock_once(method, path, body=None, timeout=10): + """One unix-socket HTTP attempt. Returns (status, json|None).""" data = json.dumps(body).encode() if body is not None else None lines = ['%s %s HTTP/1.1' % (method, path), 'Host: localhost', 'Connection: close'] if data is not None: @@ -238,6 +303,15 @@ class Download: handler.connection.sendall(self.data) +def _json_default(obj): + """Make sqlite BLOBs JSON-safe (hex) so enterprise captures never 500.""" + if isinstance(obj, memoryview): + obj = obj.tobytes() + if isinstance(obj, (bytes, bytearray)): + return bytes(obj).hex() + raise TypeError('%r is not JSON serializable' % (obj,)) + + def _unquote_plus(s): s = s.replace('+', ' ') return re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s) @@ -527,7 +601,7 @@ class PagerHandler: "object-src 'none'; base-uri 'none'; frame-ancestors 'none'") def _json(self, obj, status=200, close=False): - body = json.dumps(obj).encode() + body = json.dumps(obj, default=_json_default).encode() self.send_response(status) self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', str(len(body))) @@ -678,6 +752,7 @@ class PagerHandler: except OSError: pass return + self.connection.settimeout(5) daemon_sock.setblocking(False) import select buf = b'' @@ -699,11 +774,17 @@ class PagerHandler: if closed: return else: - opcode, payload = self._ws_read_frame() + try: + opcode, payload = self._ws_read_frame() + except (OSError, socket.timeout): + continue if opcode is None or opcode == 0x8: return if opcode in (0x1, 0x2, 0x9): - daemon_sock.sendall(ws_encode(payload, opcode=opcode, mask=True)) + try: + daemon_sock.sendall(ws_encode(payload, opcode=opcode, mask=True)) + except OSError: + return finally: try: daemon_sock.close() @@ -824,8 +905,17 @@ def battery_data(power_supply='/sys/class/power_supply'): def wifi_ifaces(): - rc, out, err = device_run(['iwinfo']) + """List wifi interfaces from sysfs first so a hung `iwinfo` cannot stall clients.""" names = [] + try: + for name in sorted(os.listdir('/sys/class/net')): + if name.startswith('wlan') or name.startswith('radio'): + names.append(name) + if names: + return names + except OSError: + pass + rc, out, err = device_run(['iwinfo'], timeout=8) for line in out.splitlines(): m = re.match(r'^(\S+)\s+', line) if m and (m.group(1).startswith('wlan') or m.group(1).startswith('radio')): @@ -861,7 +951,10 @@ def normalize_mac(mac): def h_clients(ctx): - clients = assoc_clients() + try: + clients = assoc_clients() + except Exception as exc: + return 200, {'clients': [], 'count': 0, 'error': str(exc)} return 200, {'clients': clients, 'count': len(clients)} @@ -872,11 +965,11 @@ def h_client_kick(ctx): # Persistent kick: deny-filter the client so pineapd deauths every probe # and connect, then deauth it once immediately with the full # (bssid, target, channel) form hak5cmd requires. - for argv in ([HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'deny'], - [HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac]): - rc, out, err = device_run(argv, timeout=20) - if rc != 0: - return 502, {'error': 'kick filter failed', 'detail': (err or out)[-300:]} + try: + hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny') + hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac) + except RuntimeError as exc: + return 502, {'error': 'kick filter failed', 'detail': str(exc)} ok, detail = _deauth_client_via_iface(mac) return 200, {'ok': True, 'deauth': ok, 'detail': detail or None} @@ -1266,8 +1359,10 @@ def _h_recon_start_locked(ctx): hop_ok, hop_detail = _recon_hopper_preflight() if not hop_ok: return 503, { - 'error': 'recon radio preflight failed', + 'error': 'Could not prepare recon radios', 'detail': hop_detail, + 'hint': _recon_hop_state.get('hint') or hop_detail, + 'skipped': dict(_recon_hop_state.get('skipped') or {}), } body = {'scan_time': scan_time} # log/recon/start restarts the recon logger and can rotate the existing @@ -1279,14 +1374,22 @@ def _h_recon_start_locked(ctx): reason = detail.get('error') or detail.get('detail') if not reason and status == 0: reason = 'daemon unreachable' + _reset_recon_hop_state() return 502, {'error': 'native recon scan failed', 'detail': ('recon/new: %s' % reason) if reason else None, + 'hint': 'PineAP daemon did not start the scan. Check Health / pineapd.', 'daemon': detail or None} _recon_scan_state['active'] = True _recon_scan_state['started'] = time.time() _recon_scan_state['duration'] = scan_time _start_recon_hopper(scan_time) - return 200, {'ok': True} + result = {'ok': True, 'hopping': list(_recon_hop_state.get('ifaces') or [])} + warning = _recon_hop_state.get('warning') + if warning: + result['warning'] = warning + result['skipped'] = dict(_recon_hop_state.get('skipped') or {}) + result['hint'] = _recon_hop_state.get('hint') + return 200, result def h_recon_stop(ctx): @@ -1320,6 +1423,10 @@ def _recon_watchdog_tick(): st = _recon_scan_state if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']: st['active'] = False + try: + _restore_dummy_sta() + except Exception: + pass def _set_monitor_channel(interface, channel): @@ -1331,28 +1438,227 @@ def _set_monitor_channel(interface, channel): return False, '%s channel %s: %s' % (interface, channel, detail[-160:]) -def _recon_hopper_preflight(): - """Prove both monitor PHYs can change channel before creating a scan. +def _iw_error_kind(detail): + text = (detail or '').lower() + if 'busy' in text or '(-16)' in text: + return 'busy' + if 'no such device' in text or 'no such file' in text: + return 'missing' + if 'network is down' in text or 'not running' in text: + return 'down' + if 'not supported' in text or 'operation not permitted' in text: + return 'unsupported' + if 'invalid argument' in text: + return 'bad_channel' + return 'other' - pineapd can report ``Hop fast`` while both radios remain fixed. Direct - channel changes are the only reliable contract on this Pager firmware. + +def _phy1_pinned(): + try: + if _radio1_ap_active(): + return True + except Exception: + pass + try: + return bool(_ent_running()) + except Exception: + return False + + +def _wifi_client_mode_enabled(): + """Pager UI Wireless Client Mode (wlan0cli), not the dummy STA.""" + cfg = _uci_wifi_iface('wlan0cli') or {} + return bool(cfg) and cfg.get('disabled') != '1' + + +def _wlan0_mgmt_enabled(): + cfg = _uci_wifi_iface('wlan0mgmt') or {} + return bool(cfg) and cfg.get('disabled') != '1' + + +def _iface_admin_up(name): + rc, out, err = device_run(['ip', 'link', 'show', 'dev', name], timeout=5) + if rc != 0: + return False + start = (out or '').find('<') + end = (out or '').find('>', start) + if start < 0 or end < 0: + return False + return 'UP' in [flag for flag in out[start + 1:end].split(',') if flag] + + +def _iface_associated(name): + rc, out, err = device_run(['iw', 'dev', name, 'link'], timeout=5) + return 'connected to' in (out or '').lower() + + +def _dummy_sta_borrowable(): + """True when dummy wlan0 is the only phy0 pin and is safe to park. + + Never parks real Client Mode, a 2.4 GHz AP, or an associated STA. + Does not write UCI. """ + if _wifi_client_mode_enabled() or _wlan0_pinned() or _wlan0_mgmt_enabled(): + return False + if _iface_associated('wlan0') or _iface_associated('wlan0cli'): + return False + return _sta_uplink_enabled() or _iface_admin_up('wlan0') + + +def _borrow_dummy_sta(): + """Admin-down dummy wlan0 for the scan. Restored when the scan ends.""" + if _recon_hop_state.get('borrowed_wlan0'): + return True + rc, out, err = device_run(['ip', 'link', 'set', 'wlan0', 'down'], timeout=10) + if rc != 0: + return False + _recon_hop_state['borrowed_wlan0'] = True + return True + + +def _restore_dummy_sta(): + if not _recon_hop_state.get('borrowed_wlan0'): + return + try: + device_run(['ip', 'link', 'set', 'wlan0', 'up'], timeout=10) + finally: + _recon_hop_state['borrowed_wlan0'] = False + + +def _keep_dummy_sta_parked(): + if not _recon_hop_state.get('borrowed_wlan0'): + return + if _iface_admin_up('wlan0'): + device_run(['ip', 'link', 'set', 'wlan0', 'down'], timeout=10) + + +def _iface_pin_reason(interface): + """Human reason + hint when a monitor cannot change channel.""" + if interface == 'wlan0mon': + if _wifi_client_mode_enabled(): + return ('2.4 GHz hopping skipped: Wireless Client Mode is holding phy0.', + 'Turn off Client Mode in Settings to hop 2.4 GHz.') + if _wlan0_pinned(): + return ('2.4 GHz hopping skipped: Open AP / Evil WPA is holding phy0.', + 'Stop the 2.4 GHz AP to hop 2.4 GHz.') + if _wlan0_mgmt_enabled(): + return ('2.4 GHz hopping skipped: the management AP is holding phy0.', + 'Stop the 2.4 GHz management AP to hop 2.4 GHz.') + if _sta_uplink_enabled() or _iface_admin_up('wlan0'): + return ('2.4 GHz hopping skipped: the firmware dummy STA (wlan0) is holding phy0.', + 'Recon parks this automatically when Client Mode and 2.4 GHz APs are off.') + return ('2.4 GHz hopping skipped: wlan0mon is busy.', + 'Stop other 2.4 GHz tools (captures, hostapd) and retry.') + if interface == 'wlan1mon': + if _phy1_pinned(): + return ('5 GHz hopping skipped: a 5 GHz AP is holding phy1.', + 'Stop the 5 GHz Open / WPA / Enterprise AP to hop 5 GHz.') + return ('5 GHz hopping skipped: wlan1mon is busy.', + 'Stop other 5 GHz tools and retry.') + return ('%s is unavailable.' % interface, 'Check Health and retry.') + + +def _reset_recon_hop_state(): + _restore_dummy_sta() + _recon_hop_state.update({ + 'active': False, 'error': None, 'warning': None, 'hint': None, + 'ifaces': [], 'skipped': {}, 'borrowed_wlan0': False, + }) + + +def _recon_hopper_preflight(): + """Prepare monitors for a scan without blocking recon on one busy radio. + + Channel control is probed once per radio. A busy phy (Resource busy -16) + is skipped so the other radio can still hop. Native recon still starts + as long as at least one monitor interface is up; hopping is best-effort. + """ + _reset_recon_hop_state() + try: + _raise_monitors() + except Exception: + pass + hoppable = [] + skipped = {} + hints = [] for interface, channels in RECON_CHANNELS.items(): - for channel in channels: - ok, detail = _set_monitor_channel(interface, channel) - if not ok: - return False, detail + if _monitor_down(interface): + try: + device_run(['ip', 'link', 'set', interface, 'up'], timeout=10) + except Exception: + pass + probe = channels[0] + ok, detail = _set_monitor_channel(interface, probe) + if (not ok and interface == 'wlan0mon' + and _iw_error_kind(detail) == 'busy' + and _dummy_sta_borrowable()): + if _borrow_dummy_sta(): + ok, detail = _set_monitor_channel(interface, probe) + if not ok: + ok, detail = _set_monitor_channel(interface, probe) + if not ok: + _restore_dummy_sta() + if not ok: + ok, detail = _set_monitor_channel(interface, probe) + if ok: + hoppable.append(interface) + continue + kind = _iw_error_kind(detail) + if kind == 'missing': + reason = '%s is missing.' % interface + hint = 'Bring monitors up from Health, or restart pineapd.' + elif kind == 'down': + reason = '%s is down.' % interface + hint = 'Mark VIII tried to bring it up; retry or check Health.' + else: + reason, hint = _iface_pin_reason(interface) + skipped[interface] = reason + if hint: + hints.append(hint) + _recon_hop_state['ifaces'] = hoppable + _recon_hop_state['skipped'] = skipped + up = [name for name in RECON_CHANNELS if not _monitor_down(name)] + if not hoppable and not up: + msg = 'Recon radios are unavailable. ' + ' '.join( + skipped.get(name, '') for name in RECON_CHANNELS).strip() + _recon_hop_state['hint'] = ' '.join(hints) or 'Check Health and try again.' + return False, msg or 'Recon radios are unavailable' + if skipped: + bands = [] + if 'wlan0mon' in hoppable: + bands.append('2.4 GHz') + if 'wlan1mon' in hoppable: + bands.append('5 GHz') + prefix = ' '.join(skipped[name] for name in RECON_CHANNELS if name in skipped) + if bands: + warning = '%s Scanning %s only.' % (prefix, ' and '.join(bands)) + else: + warning = '%s Recon will still run on the currently pinned channel(s).' % prefix + _recon_hop_state['warning'] = warning + _recon_hop_state['hint'] = ' '.join(hints) + return True, warning return True, 'monitor channel control ready' def _recon_hopper_loop(duration, stop_event): deadline = time.monotonic() + duration - offsets = dict((interface, 1) for interface in RECON_CHANNELS) - _recon_hop_state.update({'active': True, 'error': None}) + hoppable = [name for name in (_recon_hop_state.get('ifaces') or []) + if name in RECON_CHANNELS] + if not hoppable: + _recon_hop_state['active'] = False + return + offsets = dict((interface, 1) for interface in hoppable) + _recon_hop_state['active'] = True + _recon_hop_state['error'] = None try: while not stop_event.is_set() and time.monotonic() < deadline: + try: + _keep_dummy_sta_parked() + except Exception: + pass cycle_error = None - for interface, channels in RECON_CHANNELS.items(): + for interface in hoppable: + channels = RECON_CHANNELS[interface] index = offsets[interface] % len(channels) ok, detail = _set_monitor_channel(interface, channels[index]) if not ok: @@ -1366,11 +1672,23 @@ def _recon_hopper_loop(duration, stop_event): finally: if stop_event is _recon_hopper_stop: _recon_hop_state['active'] = False + scanning, _ = _recon_scan_snapshot() + if not scanning: + try: + _restore_dummy_sta() + except Exception: + pass def _start_recon_hopper(duration): global _recon_hopper_stop _recon_hopper_stop.set() + hoppable = [name for name in (_recon_hop_state.get('ifaces') or []) + if name in RECON_CHANNELS] + if not hoppable: + _recon_hop_state['active'] = False + _restore_dummy_sta() + return None stop_event = threading.Event() _recon_hopper_stop = stop_event thread = threading.Thread( @@ -1438,8 +1756,12 @@ def h_recon_status(ctx): 'scanning': scanning, 'scan_remaining': remaining, 'stale': stale, 'hopper_online': _hopper_online(), 'hopper_error': _recon_hop_state.get('error'), + 'hopper_warning': _recon_hop_state.get('warning'), + 'hopper_hint': _recon_hop_state.get('hint'), + 'hopper_ifaces': list(_recon_hop_state.get('ifaces') or []), 'wlan0_pinned': _wlan0_pinned(), 'wlan0_sta': _sta_uplink_enabled(), + 'dummy_sta_parked': bool(_recon_hop_state.get('borrowed_wlan0')), 'history_reset': _recon_history_reset()} @@ -1527,17 +1849,44 @@ def h_recon_events(ctx): return 200, recon_events_data() +def _examine_seconds(body): + try: + seconds = int(body.get('seconds')) if body.get('seconds') is not None else 30 + except (TypeError, ValueError): + seconds = 30 + return max(8, min(seconds, 600)) + + def h_recon_examine(ctx): body = getattr(ctx, 'body', None) or {} - bssid = (body.get('bssid') or '').strip() + bssid = fmt_mac((body.get('bssid') or '').strip()) + if bssid in ('', '--'): + bssid = '' channel = body.get('channel') - if bssid: - hak5('PINEAPPLE_EXAMINE_BSSID', bssid) - elif channel is not None: - hak5('PINEAPPLE_EXAMINE_CHANNEL', str(int(channel))) - else: - return 400, {'error': 'examine requires bssid or channel'} - return 200, {'ok': True} + seconds = _examine_seconds(body) + try: + if bssid: + if not re.match(r'^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$', bssid): + return 400, {'error': 'invalid BSSID'} + hak5('PINEAPPLE_EXAMINE_BSSID', bssid.upper(), str(seconds)) + elif channel is not None: + ch = int(channel) + if ch < 1 or ch > 233: + return 400, {'error': 'invalid channel'} + hak5('PINEAPPLE_EXAMINE_CHANNEL', str(ch), str(seconds)) + else: + return 400, {'error': 'examine requires bssid or channel'} + except RuntimeError as exc: + detail = str(exc) + scanning, remaining = _recon_scan_snapshot() + if scanning: + return 409, { + 'error': 'Examine needs an idle radio; wait for the current scan to finish', + 'detail': detail, + 'scan_remaining': remaining, + } + return 502, {'error': 'examine failed', 'detail': detail} + return 200, {'ok': True, 'seconds': seconds} def h_recon_scans(ctx): @@ -2377,20 +2726,83 @@ def h_loot_archive(ctx): return (200 if status == 200 else 502), (data if isinstance(data, dict) else {'ok': status == 200}) -def assoc_clients(ifaces=None): +def _is_monitor_iface(name): + n = (name or '').lower() + return n.endswith('mon') or 'mon' in n + + +def _parse_assoclist(name, out): clients = [] - for name in (wifi_ifaces() if ifaces is None else ifaces): - rc, out, err = device_run(['iwinfo', name, 'assoclist']) - for line in out.splitlines(): - m = re.match(r'\s*([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s+', line) - if not m: - continue + for line in (out or '').splitlines(): + m = re.match(r'\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+', line) + if not m: + continue + rssi = None + rm = re.search(r'Signal:\s*(-?\d+)', line) + if rm: + rssi = int(rm.group(1)) + clients.append({'mac': m.group(1).upper(), 'iface': name, 'rssi': rssi, + 'source': 'iwinfo'}) + return clients + + +def _parse_station_dump(name, out): + clients = [] + mac = None + rssi = None + for line in (out or '').splitlines(): + m = re.match(r'Station\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s', line, re.I) + if m: + if mac: + clients.append({'mac': mac, 'iface': name, 'rssi': rssi, 'source': 'iw'}) mac = m.group(1).upper() rssi = None - rm = re.search(r'Signal:\s*(-?\d+)', line) - if rm: - rssi = int(rm.group(1)) - clients.append({'mac': mac, 'iface': name, 'rssi': rssi}) + continue + sm = re.search(r'signal:\s*(-?\d+)', line) + if sm and mac: + rssi = int(sm.group(1)) + if mac: + clients.append({'mac': mac, 'iface': name, 'rssi': rssi, 'source': 'iw'}) + return clients + + +def assoc_clients(ifaces=None): + """Associated STAs on AP interfaces. Skips monitor ifaces (they hang iwinfo).""" + clients = [] + seen = set() + names = list(wifi_ifaces() if ifaces is None else ifaces) + try: + if os.path.exists('/sys/class/net/%s' % ENT_IFACE) and ENT_IFACE not in names: + names.append(ENT_IFACE) + except OSError: + pass + for name in names: + if not name or _is_monitor_iface(name): + continue + rows = [] + rc, out, err = device_run(['iw', 'dev', name, 'station', 'dump'], timeout=3) + if rc == 0: + rows = _parse_station_dump(name, out) + else: + rc, out, err = device_run(['iwinfo', name, 'assoclist'], timeout=3) + rows = _parse_assoclist(name, out) + for row in rows: + key = (row.get('mac'), name) + if key in seen: + continue + seen.add(key) + clients.append(row) + try: + ssid = (_ent_state_loaded() or {}).get('ssid') or '' + for mac in _ent_stations(): + key = (mac, ENT_IFACE) + if key in seen: + continue + seen.add(key) + clients.append({'mac': mac, 'iface': ENT_IFACE, 'rssi': None, + 'ssid': ssid, 'source': 'hostapd'}) + except Exception: + pass return clients @@ -2568,10 +2980,11 @@ HOSTAPD_DEFAULTS = { def h_pineap_set_config(ctx): body = ctx.body or {} - _, cur = daemon_sock_call('GET', '/api/pineap/get_config') + status, cur = daemon_sock_call('GET', '/api/pineap/get_config') + if status != 200 or not isinstance(cur, dict): + return 502, {'error': 'could not read current PineAP config', 'detail': cur} base = dict(PINEAP_CONFIG_DEFAULTS) - if isinstance(cur, dict) and 'reconpath' in cur: - base.update(cur) + base.update(cur) base.update(body) status, data = _daemon_proxy('PUT', 'set_config', base) if status == 200 and 'autossidpool' in body: @@ -2585,11 +2998,15 @@ def h_pineap_hostapd_get(ctx): def h_pineap_hostapd_set(ctx): body = ctx.body or {} - _, cur = daemon_sock_call('GET', '/api/pineap/hostapd/get_config') + status, cur = daemon_sock_call('GET', '/api/pineap/hostapd/get_config') + if status != 200 or not isinstance(cur, dict): + return 502, {'error': 'could not read current hostapd config', 'detail': cur} base = dict(HOSTAPD_DEFAULTS) - if isinstance(cur, dict) and 'mgmt_ifaces' in cur: - base.update(cur) + base.update(cur) base.update({k: v for k, v in body.items() if k in HOSTAPD_DEFAULTS}) + if _ent_running() and 'pineape_auth_pass' in body: + _ent_ctrl('pineape_auth_enable' if body.get('pineape_auth_pass') + else 'pineape_auth_disable') return _daemon_proxy('PUT', 'hostapd/set_config', base) @@ -2768,22 +3185,24 @@ def h_pineap_wifi_get_ap(ctx): _last_reconcile = time.time() device_run(['wifi', 'reload']) break + ent = _ent_summary(detail=False) return 200, { 'open': _ap_iface_dict(open_cfg, radio0_cfg, pool), 'wpa': _ap_iface_dict(wpa_cfg, radio0_cfg), 'radio1_open': _ap_iface_dict(r1_open_cfg, radio1_cfg), 'radio1_wpa': _ap_iface_dict(r1_wpa_cfg, radio1_cfg), 'enterprise': { - 'enabled': _ent_summary()['enabled'], - 'ssid': _ent_summary()['ssid'], - 'enctype': _ent_summary()['enctype'], + 'enabled': ent['enabled'], + 'ssid': ent['ssid'], + 'enctype': ent['enctype'], 'passphrase': '', 'hidden': False, - 'channel': _ent_summary()['channel'], - 'live': _ent_summary()['live'], + 'channel': ent['channel'], + 'live': ent['live'], }, 'pool': {'disabled': pool.get('disable') == '1', - 'collecting': bool(pinecfg.get('autossidpool'))}, + 'collecting': bool(pinecfg.get('autossidpool')), + 'broadcast_blocked': True}, 'radios': { 'radio0': _radio_dict('radio0'), 'radio1': _radio_dict('radio1'), @@ -2877,10 +3296,21 @@ def _pause_hop(): def _resume_hop(): - if _read_hop() == '0': - device_run(['uci', 'set', 'pineapd.wlan1mon.hop=1']) - device_run(['uci', 'commit', 'pineapd']) - device_run(['/etc/init.d/pineapd', 'reload']) + """Undo a Mark VIII hop pause without fighting a live Pager hop enable.""" + wanted = '1' + snap = load_pager_snapshot() + if snap: + hop = (snap.get('uci') or {}).get('pineapd.wlan1mon.hop') + if hop is not None: + wanted = hop + current = _read_hop() + if current == wanted: + return + if current == '1' and wanted == '0': + return + device_run(['uci', 'set', 'pineapd.wlan1mon.hop=%s' % wanted]) + device_run(['uci', 'commit', 'pineapd']) + device_run(['/etc/init.d/pineapd', 'reload']) def _remove_radio1_ap(): @@ -3002,12 +3432,10 @@ def h_pineap_wifi_set_ap(ctx): def h_pineap_advertise(ctx): enable = bool((ctx.body or {}).get('enable')) if enable: - # The SSID-pool broadcast segfaults pineapd on this firmware; the - # pool stays disabled or the crash-loop returns. - pool = _uci_section('pineapd.@ssidpool[0]') - if pool.get('disable') == '1': - return 400, {'error': 'SSID pool broadcast is disabled on this firmware ' - '(pineapd SIGSEGV crash-loop) and cannot be re-enabled'} + # The SSID-pool broadcast segfaults pineapd on this firmware. Mark VIII + # never turns it on, even if the Pager UI left the UCI flag unset. + return 400, {'error': 'SSID pool broadcast is disabled on this firmware ' + '(pineapd SIGSEGV crash-loop) and cannot be re-enabled'} status, data = _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable', {'enable': enable}) if status == 200: update_pineap_state(mode='advanced', advertise=enable) @@ -3045,6 +3473,24 @@ ENT_CONF = '/root/loot/enterprise.conf' ENT_PIDFILE = '/var/run/hostapd-mk8.pid' ENT_EAP_USERS = '/root/loot/eap_users' ENT_STATE = '/root/loot/mk8_enterprise.json' +ENT_DIR = '/root/loot/enterprise' +ENT_CA_CERT = os.path.join(ENT_DIR, 'ca.pem') +ENT_SERVER_CERT = os.path.join(ENT_DIR, 'server.pem') +ENT_SERVER_KEY = os.path.join(ENT_DIR, 'server.key') +ENT_LOG = os.path.join(ENT_DIR, 'hostapd.log') +ENT_CAPTURES = os.path.join(ENT_DIR, 'captures.json') +ENT_BUNDLED_CERTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'certs') +_ent_log_cache = {'path': None, 'mtime': None, 'items': []} +ENT_DH_FILE = os.path.join(ENT_DIR, 'dh.pem') +STOCK_HOSTAPD_CTRL = '/var/run/hostapd' +# Hak5 karma hostapd (v2.12-devel) has no parser for these keys. Emitting +# them aborts startup ("unknown configuration item"). dh_file is also +# absent on this mbedtls build (only dh_file2 exists); PEAP uses ECDHE. +ENT_HOSTAPD_UNSUPPORTED = frozenset(( + 'eap_server_identity', + 'eap_server_erp', + 'dh_file', +)) def _band_of_channel(channel): @@ -3159,7 +3605,7 @@ def _deploy_wpa_open(kind, fields): 'verified': verified} -def _disable_enterprise_ap(): +def _disable_enterprise_ap(resume_hop=True): """Tear down the standalone PineAPE enterprise AP (wlan1ent on phy1).""" try: with open(ENT_PIDFILE) as f: @@ -3169,6 +3615,7 @@ def _disable_enterprise_ap(): except (OSError, ValueError): pass device_run(['iw', 'dev', ENT_IFACE, 'del'], timeout=10) + _unlink_ent_ctrl() for p in (ENT_PIDFILE, ENT_STATE): try: os.unlink(p) @@ -3179,7 +3626,7 @@ def _disable_enterprise_ap(): device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface']) device_run(['uci', 'commit', 'pineapd']) device_run(['/etc/init.d/pineapd', 'reload']) - if not _radio1_ap_active(): + if resume_hop and not _radio1_ap_active(): _resume_hop() @@ -3192,7 +3639,7 @@ def _ent_running(): return False -def _ent_ctrl(cmd, timeout=10): +def _ent_ctrl(cmd, timeout=4): rc, out, err = device_run(['hostapd_cli', '-p', ENT_CTRL_DIR, '-i', ENT_IFACE, cmd], timeout=timeout) return (rc, out or '') @@ -3206,13 +3653,403 @@ def _ent_state_loaded(): return {} +def _eap_secret(value): + """Sanitize the optional EAP user password written to hostapd's user file.""" + text = re.sub(r'[\r\n\t"]', '', (value or '').strip())[:63] + return text or 'dummy' + + +def _eap_users_text(secret, method='any'): + """hostapd eap_user_file: PEAP/TTLS outer + inner EAP (WPE-style wildcard). + + A phase-1-only `* MSCHAPV2` file cannot complete PEAP/TTLS, so clients + never send an inner identity or MSCHAPv2 response. + """ + secret = _eap_secret(secret) + method = (method or 'any').strip().lower() + if method == 'gtc': + inner = 'GTC,TTLS-PAP,MD5' + elif method == 'mschapv2': + inner = 'MSCHAPV2,TTLS-MSCHAPV2,TTLS-MSCHAP' + else: + inner = 'TTLS-PAP,TTLS-CHAP,TTLS-MSCHAP,MSCHAPV2,MD5,GTC,TTLS-MSCHAPV2' + return ( + '*\tPEAP,TTLS\n' + '*\t%s\t"%s"\t[2]\n' % (inner, secret) + ) + + +def _mkdir(path): + try: + os.makedirs(path) + except OSError: + if not os.path.isdir(path): + raise + + +def _copy_file(src, dst): + with open(src, 'rb') as f: + data = f.read() + parent = os.path.dirname(dst) + if parent: + _mkdir(parent) + with open(dst, 'wb') as f: + f.write(data) + return len(data) > 0 + + +def _ensure_ent_certs(): + """Install a server cert so PEAP/TTLS can start a TLS tunnel. + + Without ca_cert/server_cert/private_key, clients never reach inner + MSCHAPv2/GTC and nothing is captured. + """ + needed = (ENT_CA_CERT, ENT_SERVER_CERT, ENT_SERVER_KEY) + have_certs = all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in needed) + _mkdir(ENT_DIR) + bundled = ( + ('ca.pem', ENT_CA_CERT), + ('server.pem', ENT_SERVER_CERT), + ('server.key', ENT_SERVER_KEY), + ('dh.pem', ENT_DH_FILE), + ) + if not have_certs or not (os.path.isfile(ENT_DH_FILE) and os.path.getsize(ENT_DH_FILE) > 0): + if all(os.path.isfile(os.path.join(ENT_BUNDLED_CERTS, src)) + for src, _dst in bundled[:3]): + try: + for src, dst in bundled: + src_path = os.path.join(ENT_BUNDLED_CERTS, src) + if os.path.isfile(src_path): + _copy_file(src_path, dst) + have_certs = all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in needed) + except OSError: + pass + if have_certs: + return True + rc, _out, _err = device_run([ + 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-days', '3650', + '-nodes', '-keyout', ENT_SERVER_KEY, '-out', ENT_SERVER_CERT, + '-subj', '/C=US/ST=Lab/O=Information Technology/CN=wifi.lab.local', + ], timeout=60) + if rc == 0 and os.path.isfile(ENT_SERVER_CERT): + try: + _copy_file(ENT_SERVER_CERT, ENT_CA_CERT) + except OSError: + return False + return all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in needed) + return False + + +def _hex_clean(value): + return re.sub(r'[^0-9A-Fa-f]', '', value or '').lower() + + +def _parse_ent_log(text): + """Pull EAP identities, MSCHAPv2 challenge/response, and GTC passwords.""" + items = [] + if not text: + return items + + def add_mschap(user, chal, resp, source='log'): + user = (user or '').strip().strip('\'"') + chal = _hex_clean(chal) + resp = _hex_clean(resp) + if not user or not chal or not resp: + return + items.append({ + 'kind': 'mschapv2', 'username': user, 'challenge': chal, 'response': resp, + 'hashcat': _mschap_hashcat_5500(user, chal, resp), + 'john': _mschap_john(user, chal, resp), + 'source': source, + }) + + for m in re.finditer( + r"identity[:\s=]+['\"]([^'\"]+)['\"]", text, re.I): + ident = m.group(1).strip() + if ident: + items.append({'kind': 'eap-identity', 'username': ident, 'source': 'log'}) + for m in re.finditer( + r"EAP-Identity[:\s]+['\"]([^'\"]+)['\"]", text, re.I): + ident = m.group(1).strip() + if ident: + items.append({'kind': 'eap-identity', 'username': ident, 'source': 'log'}) + for m in re.finditer( + r'CTRL-EVENT-EAP-IDENTITY\s+(\S+)', text, re.I): + ident = m.group(1).strip().strip('\'"') + if ident: + items.append({'kind': 'eap-identity', 'username': ident, 'source': 'log'}) + for m in re.finditer( + r'(?:EAP-)?GTC[:\s].*?username[:\s=]+(\S+).*?password[:\s=]+(\S+)', + text, re.I | re.S): + items.append({ + 'kind': 'gtc', 'username': m.group(1).strip().strip('\'"'), + 'password': m.group(2).strip().strip('\'"'), 'source': 'log', + }) + for m in re.finditer( + r'mschapv2:.*?username:\s*(\S+)\s+challenge:\s*([0-9A-Fa-f:]+)\s+response:\s*([0-9A-Fa-f:]+)', + text, re.I | re.S): + add_mschap(m.group(1), m.group(2), m.group(3)) + for m in re.finditer( + r'hashcat NETNTLM:\s*(\S+?)::::([0-9A-Fa-f]+):([0-9A-Fa-f]+)', + text, re.I): + add_mschap(m.group(1), m.group(3), m.group(2)) + for m in re.finditer( + r'(?:jtr|john) NETNTLM:\s*(\S+?):\$NETNTLM\$([0-9A-Fa-f]+)\$([0-9A-Fa-f]+)', + text, re.I): + add_mschap(m.group(1), m.group(2), m.group(3)) + for m in re.finditer( + r'(\S+?):\$NETNTLM\$([0-9A-Fa-f]+)\$([0-9A-Fa-f]+)', text, re.I): + add_mschap(m.group(1), m.group(2), m.group(3)) + return items + + +def _capture_key(item): + return ( + item.get('kind') or '', + item.get('username') or '', + item.get('challenge') or '', + item.get('response') or '', + item.get('password') or '', + ) + + +def _merge_captures(*groups): + out = [] + seen = set() + for group in groups: + for item in group or []: + key = _capture_key(item) + if key in seen: + continue + seen.add(key) + out.append(item) + return out + + +def _unique_keep_order(items): + seen = set() + out = [] + for item in items: + if not item or item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _load_ent_captures(): + try: + with open(ENT_CAPTURES) as f: + data = json.load(f) + if isinstance(data, list): + return data + if isinstance(data, dict) and isinstance(data.get('captures'), list): + return data['captures'] + except (OSError, ValueError): + pass + return [] + + +def _save_ent_captures(items): + try: + parent = os.path.dirname(ENT_CAPTURES) + if parent: + _mkdir(parent) + with open(ENT_CAPTURES, 'w') as f: + json.dump({'captures': items}, f) + except OSError: + pass + + +def _harvest_ent_log(): + # File only. Full `logread` on this hot path stalls the HTTP workers + # (PineAP log, filters, clients) past the UI's 20s GET timeout. + persisted = _load_ent_captures() + parsed = [] + mtime = None + try: + mtime = os.path.getmtime(ENT_LOG) + except OSError: + pass + if (mtime is not None and _ent_log_cache['path'] == ENT_LOG + and _ent_log_cache['mtime'] == mtime): + parsed = list(_ent_log_cache['items']) + else: + try: + with open(ENT_LOG) as f: + parsed = _parse_ent_log(f.read()) + except OSError: + parsed = [] + _ent_log_cache['path'] = ENT_LOG + _ent_log_cache['mtime'] = mtime + _ent_log_cache['items'] = list(parsed) + merged = _merge_captures(persisted, parsed) + if parsed and merged != persisted: + _save_ent_captures(merged) + return merged + + +def _link_ent_ctrl(): + """Expose wlan1ent on the stock hostapd ctrl dir so pineapd can see it.""" + try: + _mkdir(STOCK_HOSTAPD_CTRL) + except OSError: + return False + src = os.path.join(ENT_CTRL_DIR, ENT_IFACE) + dst = os.path.join(STOCK_HOSTAPD_CTRL, ENT_IFACE) + if not os.path.exists(src): + # Only wait on the live Pager, where wlan1ent exists. Unit tests and + # Linux CI have /proc but not the iface — skip the 8s poll there. + if not os.path.exists('/sys/class/net/%s' % ENT_IFACE): + return False + deadline = time.time() + 8 + while time.time() < deadline and not os.path.exists(src): + time.sleep(0.25) + if not os.path.exists(src): + return False + try: + if os.path.islink(dst) or os.path.exists(dst): + os.unlink(dst) + os.symlink(src, dst) + return True + except OSError: + return False + + +def _unlink_ent_ctrl(): + path = os.path.join(STOCK_HOSTAPD_CTRL, ENT_IFACE) + try: + if os.path.islink(path) or os.path.exists(path): + os.unlink(path) + except OSError: + pass + + +def _ent_conf_text(ssid, channel, hidden, pmf, skip_keys=None): + """Build a hostapd conf that this Pager firmware will actually parse.""" + skip = set(ENT_HOSTAPD_UNSUPPORTED) + if skip_keys: + skip.update(skip_keys) + ssid = re.sub(r'[\r\n]', '', ssid or '') + pairs = [ + ('interface', ENT_IFACE), + ('driver', 'nl80211'), + ('ssid', ssid), + ('hw_mode', 'a'), + ('channel', str(int(channel))), + ('country_code', 'US'), + ('ieee80211d', '1'), + ('ieee80211n', '1'), + ('ht_capab', '[SHORT-GI-20][SHORT-GI-40]'), + ('beacon_int', '100'), + ('auth_algs', '1'), + ('ieee8021x', '1'), + ('eap_server', '1'), + ('eap_user_file', ENT_EAP_USERS), + ('ca_cert', ENT_CA_CERT), + ('server_cert', ENT_SERVER_CERT), + ('private_key', ENT_SERVER_KEY), + ('wpa', '2'), + ('wpa_key_mgmt', 'WPA-EAP'), + ('wpa_pairwise', 'CCMP'), + ('rsn_pairwise', 'CCMP'), + ('ieee80211w', str(int(pmf))), + ('wpa_disable_eapol_key_retries', '0'), + ('ignore_broadcast_ssid', '1' if hidden else '0'), + ('logger_stdout', '-1'), + ('logger_stdout_level', '0'), + ('logger_syslog', '-1'), + ('logger_syslog_level', '0'), + ('ctrl_interface', ENT_CTRL_DIR), + ] + return ''.join('%s=%s\n' % (k, v) for k, v in pairs if k not in skip) + + +def _hostapd_unknown_items(text): + return re.findall(r"unknown configuration item '([^']+)'", text or '') + + +def _drop_hostapd_keys(conf, keys): + drop = set(keys or ()) + if not drop: + return conf, False + kept = [] + changed = False + for line in (conf or '').splitlines(): + raw = line.strip() + if raw and not raw.startswith('#') and '=' in raw: + key = raw.split('=', 1)[0].strip() + if key in drop: + changed = True + continue + kept.append(line) + return ('\n'.join(kept) + ('\n' if kept else '')), changed + + +def _hostapd_failure_text(out, err): + parts = [err or '', out or ''] + try: + with open(ENT_LOG) as f: + parts.append(f.read()[-4000:]) + except OSError: + pass + return '\n'.join(p for p in parts if p).strip() + + +def _start_ent_hostapd(): + """Start hostapd, dropping keys this firmware does not understand.""" + last = (1, '', '') + for _ in range(5): + rc, out, err = device_run( + ['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, '-f', ENT_LOG, ENT_CONF], + timeout=25) + combined = '%s\n%s' % (err or '', out or '') + if rc != 0 and re.search(r'invalid option|unrecognized option|unknown option', combined, re.I): + rc, out, err = device_run( + ['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], timeout=25) + combined = '%s\n%s' % (err or '', out or '') + last = (rc, out, err) + if rc == 0: + return rc, out, err + unknown = _hostapd_unknown_items(_hostapd_failure_text(out, err)) + if not unknown: + return rc, out, err + try: + with open(ENT_CONF) as f: + conf = f.read() + except OSError: + return rc, out, err + new_conf, changed = _drop_hostapd_keys(conf, unknown) + if not changed: + return rc, out, err + try: + with open(ENT_CONF, 'w') as f: + f.write(new_conf) + except OSError: + return rc, out, err + return last + + +def _ent_stations(): + if not _ent_running(): + return [] + rc, out = _ent_ctrl('list_sta') + if rc != 0 or not out: + return [] + return [line.strip().upper() for line in out.splitlines() + if re.match(r'^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$', line.strip())] + + def _deploy_enterprise(fields): ssid = (fields.get('ssid') or '').strip() if not ssid: raise ValueError('SSID is required') - enctype = fields.get('enctype') or 'wpa2' + enctype = fields.get('enctype') or fields.get('encryption') or 'wpa2' if enctype not in ('wpa2', 'wpa3'): raise ValueError('enterprise encryption must be wpa2 or wpa3') + method = (fields.get('auth_method') or fields.get('eap_method') or 'any').strip().lower() + if method not in ('any', 'mschapv2', 'gtc'): + method = 'any' ch = fields.get('channel') if ch is None: ch = _best_channel_for(ssid) @@ -3242,29 +4079,19 @@ def _deploy_enterprise(fields): device_run(['iw', 'dev', ENT_IFACE, 'set', 'type', 'ap']) device_run(['ip', 'link', 'set', ENT_IFACE, 'up']) try: + _mkdir(ENT_DIR) + if not _ensure_ent_certs(): + raise RuntimeError( + 'could not install EAP TLS certificates (PEAP/TTLS cannot capture without them)') + secret = _eap_secret(fields.get('passphrase') or fields.get('password') or '') with open(ENT_EAP_USERS, 'w') as f: - f.write('"*"\tMSCHAPV2\t"dummy"\n') + f.write(_eap_users_text(secret, method)) + hidden = bool(fields.get('hidden')) + # Optional PMF (ieee80211w=1) makes some PEAP clients refuse to start + # EAP. WPA2 capture APs stay more compatible with PMF off; WPA3 requires it. + pmf = 2 if enctype == 'wpa3' else 0 with open(ENT_CONF, 'w') as f: - f.write( - 'interface=%s\n' - 'driver=nl80211\n' - 'ssid=%s\n' - 'hw_mode=a\n' - 'channel=%d\n' - 'country_code=US\n' - 'ieee80211d=1\n' - 'ieee80211n=1\n' - 'ht_capab=[SHORT-GI-20][SHORT-GI-40]\n' - 'beacon_int=100\n' - 'auth_algs=1\n' - 'ieee8021x=1\n' - 'eap_server=1\n' - 'eap_user_file=%s\n' - 'wpa=2\n' - 'wpa_key_mgmt=WPA-EAP\n' - 'wpa_pairwise=CCMP\n' - 'wpa_disable_eapol_key_retries=0\n' - 'ctrl_interface=%s\n' % (ENT_IFACE, ssid, int(ch), ENT_EAP_USERS, ENT_CTRL_DIR)) + f.write(_ent_conf_text(ssid, ch, hidden, pmf)) except OSError as exc: raise RuntimeError('could not write enterprise config: %s' % exc) # Tell the karma build which iface is the management (enterprise) AP and @@ -3273,24 +4100,25 @@ def _deploy_enterprise(fields): device_run(['uci', 'set', 'pineapd.@ssid_filter[0].mode=deny']) device_run(['uci', 'set', 'pineapd.@mac_filter[0].mode=deny']) device_run(['uci', 'commit', 'pineapd']) + _allow_all_ssids() _pause_hop() # The daemon's wireless reconciliation races standalone iface creation; # retry through a few quiet windows before giving up. verified = False last_err = '' for attempt in range(3): - rc, out, err = device_run(['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], - timeout=25) - deadline = time.time() + 12 - while time.time() < deadline: - rc2, out2 = _ent_ctrl('status') - if 'state=ENABLED' in (out2 or ''): - verified = True - break - time.sleep(2) + rc, out, err = _start_ent_hostapd() + if rc == 0: + deadline = time.time() + 12 + while time.time() < deadline: + rc2, out2 = _ent_ctrl('status') + if 'state=ENABLED' in (out2 or ''): + verified = True + break + time.sleep(2) if verified: break - last_err = (err or out or '')[-500:] + last_err = _hostapd_failure_text(out, err)[-800:] _disable_enterprise_ap() if attempt < 2: time.sleep(5) @@ -3301,27 +4129,36 @@ def _deploy_enterprise(fields): device_run(['ip', 'link', 'set', ENT_IFACE, 'up']) if not verified: raise RuntimeError('hostapd failed to start for enterprise AP: %s' % last_err) - _ent_ctrl('pineap_enable') - _ent_ctrl('pineape_enable') - _ent_ctrl('pineape_auth_enable') + ctrl = { + 'pineap_enable': _ent_ctrl('pineap_enable')[0], + 'pineape_enable': _ent_ctrl('pineape_enable')[0], + 'pineape_auth_enable': _ent_ctrl('pineape_auth_enable')[0], + } + linked = _link_ent_ctrl() + device_run(['/etc/init.d/pineapd', 'reload'], timeout=20) try: with open(ENT_STATE, 'w') as f: - json.dump({'ssid': ssid, 'enctype': enctype, 'hidden': bool(fields.get('hidden')), - 'channel': int(ch), 'started': int(time.time())}, f) + json.dump({ + 'ssid': ssid, 'enctype': enctype, 'hidden': bool(fields.get('hidden')), + 'passphrase': secret, 'channel': int(ch), 'auth_method': method, + 'started': int(time.time()), 'ctrl': ctrl, 'ctrl_linked': linked, + 'certs': True, + }, f) except OSError: pass return {'kind': 'enterprise', 'ssid': ssid, 'iface': ENT_IFACE, - 'band': BAND_5G, 'channel': int(ch), 'verified': verified} + 'band': BAND_5G, 'channel': int(ch), 'verified': verified, + 'auth_method': method, 'ctrl_linked': linked, 'ctrl': ctrl} -def _ent_summary(): +def _ent_summary(detail=True): st = _ent_state_loaded() running = _ent_running() live = False if running: - rc, out = _ent_ctrl('status') + rc, out = _ent_ctrl('status', timeout=3) live = 'state=ENABLED' in (out or '') - return { + summary = { 'enabled': running, 'live': live, 'ssid': st.get('ssid') or '', @@ -3330,7 +4167,17 @@ def _ent_summary(): 'channel': st.get('channel', 36), 'iface': ENT_IFACE, 'started': st.get('started'), + 'hidden': bool(st.get('hidden')), + 'stations': [], + 'auth_method': st.get('auth_method') or 'any', + 'certs': bool(st.get('certs')), + 'ctrl_linked': bool(st.get('ctrl_linked')), + 'captures': 0, } + if detail: + summary['stations'] = _ent_stations() if running else [] + summary['captures'] = len(_load_ent_captures()) + return summary def _enterprise_boot_recover(): @@ -3382,17 +4229,37 @@ def _uci_ap_summary(name, radio_name): def _count_table(table): - rc, out, err = device_run(['sqlite3', 'file:/root/recon/recon.db?mode=ro', - 'SELECT COUNT(*) FROM %s' % table], timeout=20) - try: - return int(out.strip()) - except (TypeError, ValueError): + if not re.match(r'^[A-Za-z0-9_]+$', table or ''): return 0 + try: + rows = _db_rows(RECON_DB, 'SELECT COUNT(*) AS n FROM %s' % table, timeout=4) + if rows: + return int(rows[0].get('n') or 0) + except (RuntimeError, TypeError, ValueError, IndexError): + return 0 + return 0 def h_attacks_status(ctx): handshakes = _count_table('hostap_handshake') - creds = _count_table('hostap_basic') + _count_table('hostap_chalresp') + identities = _count_table('hostap_basic') + mschapv2 = _count_table('hostap_chalresp') + creds = identities + mschapv2 + summary = _ent_summary() + _, hostapd = daemon_sock_call('GET', '/api/pineap/hostapd/get_config', timeout=4) + pineape_on = not bool((hostapd or {}).get('pineape_disabled', True)) + enterprise = { + 'ap': summary, + 'pineape': {'enabled': pineape_on}, + 'creds': creds, + 'identities': identities, + 'mschapv2': mschapv2, + 'stations': summary.get('stations') or [], + 'auth_method': summary.get('auth_method') or 'any', + 'certs': bool(summary.get('certs')), + 'captures': summary.get('captures') or 0, + 'ctrl_linked': bool(summary.get('ctrl_linked')), + } return 200, { 'wpa': { 'radio0': _uci_ap_summary('wlan0wpa', 'radio0'), @@ -3402,13 +4269,7 @@ def h_attacks_status(ctx): 'radio0': _uci_ap_summary('wlan0open', 'radio0'), 'radio1': _uci_ap_summary('wlan1open', 'radio1'), }, - 'enterprise': { - 'ap': _ent_summary(), - 'pineape': {'enabled': not bool( - (daemon_sock_call('GET', '/api/pineap/hostapd/get_config')[1] or {}) - .get('pineape_disabled', True))}, - 'creds': creds, - }, + 'enterprise': enterprise, 'handshakes': handshakes, 'hop': {'wlan1mon': _read_hop()}, } @@ -3423,7 +4284,7 @@ def _radio1_ap_active(): def h_attacks_stop(ctx): - kind = (ctx.body or {}).get('kind') or '' + kind = ((ctx.body or {}).get('kind') or '') stopped = [] if kind in ('wpa', 'open'): if kind == 'wpa': @@ -3563,14 +4424,15 @@ def h_attacks_deauth(ctx): def _pineap(*args, timeout=30): - rc, out, err = device_run(['_pineap'] + list(args), timeout=timeout) + with _pineapd_cmd_lock: + rc, out, err = device_run(['_pineap'] + list(args), timeout=timeout) return rc, out, err # -------------------------------------------------------------------------- -# Health monitor: keep pineapd alive and the monitor radios up. The stock -# SSID-pool broadcast segfaults pineapd on this firmware; when a crash-loop -# is detected the pool broadcast is disabled and pineapd restarted. +# Health monitor: keep pineapd alive and the monitor radios up. On this +# firmware the stock UI owns PineAP UCI; Mark VIII must not disable the +# SSID pool or rewrite hopping just because pineapd restarted. # -------------------------------------------------------------------------- HEALTH_POLL_SECONDS = 15 @@ -3586,8 +4448,8 @@ _health = { def _sigsegv_count(): - rc, out, err = device_run(['logread'], timeout=15) - return out.count('SIGSEGV') + rc, out, err = device_run(['logread', '-l', '200'], timeout=4) + return (out or '').count('SIGSEGV') PINEAPD_SAFE_UCI = { @@ -3624,11 +4486,11 @@ def _pending_uci(wanted): return pending -def _stabilize_uci(clear_pool=True): - """Sane-off UCI pass shared by the health monitor and the startup env - check. Clear the refilled pool only while pineapd is down or undergoing a - controlled restart; committing that list while it is live makes the stock - watchdog SIGTERM pineapd.""" +def _stabilize_uci(clear_pool=False): + """Optional sane-off UCI pass. Never used on a healthy pineapd — rewriting + live PineAP settings (pool broadcast, hopping, SSID list) is what bricks + the stock UI and forces a factory reset. The SSID list is preserved + unless a caller opts in with clear_pool=True.""" actions = _apply_uci_wanted(PINEAPD_SAFE_UCI) if clear_pool: rc, out, err = device_run(['uci', 'get', 'pineapd.@ssidpool[0].ssid']) @@ -3641,17 +4503,9 @@ def _stabilize_uci(clear_pool=True): def _stabilize_pineapd(): - """Idempotent crash-source pass. Field-verified SIGSEGV/terminate sources - on this firmware: - 1. SSID-pool broadcast (segfault, ~15s cadence) - 2. wlan2mon: a 6GHz monitor this hardware never creates; hopping the - missing iface segfaults pineapd - 3. wlan1mon fast-hopping (stalls the command socket; the stock daemon's - watchdog then SIGTERMs pineapd every ~30s) - 4. a large refilled pool (collect refills it; crashes observed even - with broadcast disabled) - The pool list itself is cleared. Returns what changed.""" - actions = _stabilize_uci() + """Manual crash-source pass. Not invoked by env_check or the health + monitor: those must not rewrite a live PineAP configuration.""" + actions = _stabilize_uci(clear_pool=False) if actions: return 'stabilized: ' + ', '.join(actions) return 'pineapd restart' @@ -3688,15 +4542,13 @@ def health_check(): now = time.time() if now - h['last_fix'] < HEALTH_FIX_COOLDOWN: return dict(h) - pool = _uci_section('pineapd.@ssidpool[0]') - if pool.get('disable') != '1': - device_run(['uci', 'set', 'pineapd.@ssidpool[0].disable=1']) - device_run(['uci', 'commit', 'pineapd']) - h['last_action'] = 'SSID pool broadcast disabled (pineapd crash-loop fix)' - elif _monitor_down('wlan1mon') or _monitor_down('wlan0mon'): + # Restart pineapd and repair monitors. Do not rewrite UCI: disabling + # the SSID pool or clearing collected SSIDs is user-visible data loss + # and is the class of change that has required factory resets. + if _monitor_down('wlan1mon') or _monitor_down('wlan0mon'): _bring_monitors_up(h) else: - h['last_action'] = _stabilize_pineapd() + h['last_action'] = 'pineapd restart' device_run(['/etc/init.d/pineapd', 'restart'], timeout=30) h['sigsegv_last'] = _sigsegv_count() h['last_fix'] = now @@ -3813,12 +4665,10 @@ def _daemon_alive(): def _sync_pool_runtime(): - """Report the crash-safe pool state without writing pineapd's socket. + """Report pool-broadcast UCI without writing pineapd's socket or UCI. - Runtime-sensitive UCI changes cause a controlled pineapd restart in - ``env_check``. When UCI is already safe, writing either directly or - through the stock daemon races its own command traffic and makes its - watchdog SIGTERM pineapd. + Mark VIII must not flip broadcast or rewrite the SSID list on a live + pager; those writes race the stock daemon and destroy collected SSIDs. """ pool = _uci_section('pineapd.@ssidpool[0]') if pool.get('disable') == '1': @@ -3858,9 +4708,10 @@ def _disable_sta_uplink(): def env_check(): """Full environment pass, run at service startup and via - ``server.py --env-check``. Auto-fixes everything fixable, re-verifies, and - returns a list of step reports (ok: pass|fixed|warn|fail). Core - dependencies (daemon, pineapd, monitors, recon DB) can fail.""" + ``server.py --env-check``. Verifies core dependencies and repairs + monitors. Does **not** rewrite live PineAP or wireless UCI — doing so + (SSID pool, hopping, dummy_radio0) is what has forced factory resets. + Core dependencies (daemon, pineapd, monitors, recon DB) can fail.""" report = [] if not _daemon_alive(): @@ -3870,28 +4721,20 @@ def env_check(): pineap_was_alive = _pineapd_alive() pending = _pending_uci(PINEAPD_SAFE_UCI) - runtime_changed = any(action in PINEAPD_RUNTIME_UCI - for action in pending) - if pineap_was_alive and runtime_changed: - device_run(['/etc/init.d/pineapd', 'stop'], timeout=30) - actions = _stabilize_uci( - clear_pool=not pineap_was_alive or runtime_changed) - if not pineap_was_alive or runtime_changed: + if not pineap_was_alive: device_run(['/etc/init.d/pineapd', 'restart'], timeout=30) if _pineapd_alive(): - reason = ('runtime safety settings changed' if runtime_changed - else 'pineapd was down') - _env_step(report, 'fixed', '%s; pineapd restarted' % reason, - ', '.join(actions) or 'restart') + _env_step(report, 'fixed', 'pineapd was down; pineapd restarted') else: - _env_step(report, 'fail', 'pineapd did not come back after restart', - ', '.join(actions) or 'restart') + _env_step(report, 'fail', 'pineapd did not come back after restart') else: _env_step(report, 'pass', 'pineapd running') - if actions: - _env_step(report, 'fixed', 'pineapd sane-off UCI defaults applied', - ', '.join(actions)) + if pending: + _env_step( + report, 'warn', + 'live PineAP UCI left unchanged (sane-off defaults not applied on a running pineapd)', + ', '.join(pending)) else: _env_step(report, 'pass', 'pineapd sane-off UCI defaults already set') @@ -3903,9 +4746,9 @@ def env_check(): _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)') + _env_step(report, 'warn', + 'dummy_radio0 STA uplink is enabled and pins phy0; ' + 'recon parks wlan0 during scans (UCI left unchanged)') else: _env_step(report, 'pass', 'no STA uplink pinning phy0') @@ -4086,10 +4929,7 @@ def _mcp_tools(): return payload def enterprise_creds(args): - tables = {} - for t in ('basic', 'challenge'): - tables[t] = _sql_table('hostap_%s' % ('chalresp' if t == 'challenge' else t), 100) - return tables + return _enterprise_radius_payload() def recon_aps(args): return {'aps': _sql_table('ssid', args.get('limit', 50))} @@ -4183,7 +5023,7 @@ def _mcp_tools(): {}, export_hc), _mcp_tool('loot.handshakes', 'List captured WPA handshakes (files + parsed entries).', {}, handshakes), - _mcp_tool('loot.enterprise_creds', 'PineAPE captured enterprise credentials (basic identity data + MSCHAPv2 challenge/response).', + _mcp_tool('loot.enterprise_creds', 'PineAPE captured EAP/RADIUS-equivalent credentials: identities, MSCHAPv2 challenge/response, hashcat -m 5500 and john netntlm lines.', {}, enterprise_creds), _mcp_tool('recon.aps', 'Recent recon APs from the recon database.', {'limit': {'type': 'number'}}, recon_aps), _mcp_tool('recon.isearch', 'Find APs matching an SSID in the recon database.', {'ssid': {'type': 'string'}}, recon_isearch), @@ -4207,8 +5047,9 @@ def _mcp_resource_list(): {'uri': 'recon://aps', 'name': 'Recon access points', 'mimeType': 'application/json'}, {'uri': 'recon://devices', 'name': 'Recon client devices', 'mimeType': 'application/json'}, {'uri': 'recon://handshakes', 'name': 'Captured handshakes', 'mimeType': 'application/json'}, - {'uri': 'recon://enterprise/basic', 'name': 'Enterprise basic credentials', 'mimeType': 'application/json'}, - {'uri': 'recon://enterprise/challenge', 'name': 'Enterprise challenge/response', 'mimeType': 'application/json'}, + {'uri': 'recon://enterprise/basic', 'name': 'Enterprise EAP identities', 'mimeType': 'application/json'}, + {'uri': 'recon://enterprise/challenge', 'name': 'Enterprise MSCHAPv2 challenge/response', 'mimeType': 'application/json'}, + {'uri': 'recon://enterprise/radius', 'name': 'Unified EAP/RADIUS captures (hashcat + john)', 'mimeType': 'application/json'}, ] for name in ('pineapple-control', 'wifi-deauth', 'aircrack-suite'): res.append({'uri': 'skills://%s' % name, 'name': 'Skill: %s' % name, @@ -4237,10 +5078,13 @@ def _mcp_resource_read(uri): 'text': json.dumps(payload)}]} if uri == 'recon://enterprise/basic': return {'contents': [{'uri': uri, 'mimeType': 'application/json', - 'text': json.dumps(_sql_table('hostap_basic', 100))}]} + 'text': json.dumps(_enterprise_basic_rows(), default=_json_default)}]} if uri == 'recon://enterprise/challenge': return {'contents': [{'uri': uri, 'mimeType': 'application/json', - 'text': json.dumps(_sql_table('hostap_chalresp', 100))}]} + 'text': json.dumps(_enterprise_chalresp_rows(), default=_json_default)}]} + if uri == 'recon://enterprise/radius': + return {'contents': [{'uri': uri, 'mimeType': 'application/json', + 'text': json.dumps(_enterprise_radius_payload(), default=_json_default)}]} if uri.startswith('skills://'): name = uri[len('skills://'):] path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'skills', '%s.md' % name) @@ -4268,7 +5112,7 @@ def _mcp_prompt_get(name): if name == 'evil-enterprise-attack': return {'description': 'Evil Enterprise (PineAPE) attack workflow', 'messages': [{'role': 'user', 'content': {'type': 'text', - 'text': 'Plan: 1) recon.isearch the target SSID (authorized only). 2) attack.deploy kind=enterprise ssid= enctype=wpa2 channel=36. 3) attack.status until live. 4) Monitor loot.enterprise_creds for captured identities and MSCHAPv2 challenge/response pairs; crack offline with hashcat -m 5500 (or -m 5800 for netntlmv2-style) once captured. 5) attack.stop when done.'}}]} + 'text': 'Plan: 1) recon.isearch the target SSID (authorized only). 2) attack.deploy kind=enterprise ssid= enctype=wpa2 channel=36. 3) attack.status until live. 4) Monitor loot.enterprise_creds (or recon://enterprise/radius) for EAP identities and MSCHAPv2 challenge/response — the RADIUS inner-auth equivalent captured by PineAPE. Export hashcat -m 5500 / john --format=netntlm lines and crack offline. 5) attack.stop when done.'}}]} if name == 'recon-survey': return {'description': 'Recon survey', 'messages': [{'role': 'user', 'content': {'type': 'text', @@ -4305,7 +5149,7 @@ def _mcp_dispatch(msg): 'resources': {'listChanged': False, 'subscribe': False}, 'prompts': {'listChanged': False}, }, - 'serverInfo': {'name': 'mark-viii', 'version': '1.2'}}} + 'serverInfo': {'name': 'mark-viii', 'version': '1.3.2'}}} if method == 'notifications/initialized': return 202, None if method == 'ping': @@ -4324,7 +5168,7 @@ def _mcp_dispatch(msg): return respond_error(-32603, 'tool error: %s' % exc) if isinstance(result, dict) and 'error' in result: return respond_error(-32602, result['error']) - return respond({'content': [{'type': 'text', 'text': json.dumps(result, indent=2)}]}) + return respond({'content': [{'type': 'text', 'text': json.dumps(result, indent=2, default=_json_default)}]}) return respond_error(-32602, 'unknown tool: %s' % name) if method == 'resources/list': return respond({'resources': _mcp_resource_list()}) @@ -4378,13 +5222,26 @@ def h_harness_token(ctx): return 200, {'token': '', 'serverid': ''} -def hak5(*args, timeout=30): - rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout) - return out +def hak5(*args, timeout=30, check=True): + last = (1, '', 'hak5cmd failed') + attempts = max(1, HAK5_RETRIES + 1) + with _pineapd_cmd_lock: + for attempt in range(attempts): + rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout) + blended = '%s\n%s' % (out or '', err or '') + # hak5cmd sometimes exits 0 while printing ERROR: (5 GHz channel). + if rc == 0 and 'ERROR:' not in blended: + return out + last = (rc if rc else 1, out, err or blended) + if attempt + 1 < attempts and HAK5_RETRY_SLEEP: + time.sleep(HAK5_RETRY_SLEEP * (attempt + 1)) + if check: + raise RuntimeError((last[2] or last[1] or 'hak5cmd failed').strip()[-300:]) + return last[1] def _json_or(text): - text = text.strip() + text = (text or '').strip() if text.startswith('{') or text.startswith('['): try: return json.loads(text) @@ -4394,6 +5251,7 @@ def _json_or(text): def _parse_pool_list(text): + text = text or '' obj = _json_or(text) if isinstance(obj, dict) and 'ssids' in obj: return [str(s) for s in obj['ssids']] @@ -4412,24 +5270,30 @@ def _parse_pool_list(text): def h_ssids_get(ctx): - return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + try: + return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + except RuntimeError as exc: + return 502, {'error': 'ssid pool unavailable', 'detail': str(exc), 'ssids': []} def h_ssids_post(ctx): body = ctx.body or {} action = body.get('action') - if action == 'add': - ssid = (body.get('ssid') or '').strip() - if not ssid: - return 400, {'error': 'ssid required'} - hak5('PINEAPPLE_SSID_POOL_ADD', ssid) - elif action == 'remove': - hak5('PINEAPPLE_SSID_POOL_DELETE', (body.get('ssid') or '').strip()) - elif action == 'clear': - hak5('PINEAPPLE_SSID_POOL_CLEAR') - else: - return 400, {'error': 'unknown action'} - return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + try: + if action == 'add': + ssid = (body.get('ssid') or '').strip() + if not ssid: + return 400, {'error': 'ssid required'} + hak5('PINEAPPLE_SSID_POOL_ADD', ssid) + elif action == 'remove': + hak5('PINEAPPLE_SSID_POOL_DELETE', (body.get('ssid') or '').strip()) + elif action == 'clear': + hak5('PINEAPPLE_SSID_POOL_CLEAR') + else: + return 400, {'error': 'unknown action'} + return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + except RuntimeError as exc: + return 502, {'error': 'ssid pool update failed', 'detail': str(exc)} FILTER_DAEMON = { @@ -4438,6 +5302,76 @@ FILTER_DAEMON = { } +def _parse_filter_mode(text): + text = (text or '').strip().lower() + obj = _json_or(text) + if isinstance(obj, dict): + mode = str(obj.get('mode') or '').lower() + if mode in ('allow', 'deny'): + return mode + if re.search(r'\ballow\b', text): + return 'allow' + if re.search(r'\bdeny\b', text): + return 'deny' + return None + + +def _parse_filter_list(text): + obj = _json_or(text) + if isinstance(obj, dict): + for key in ('ssids', 'macs', 'entries', 'list', 'denied', 'allowed'): + if isinstance(obj.get(key), list): + return [str(item) for item in obj[key]] + if isinstance(obj, list): + return [str(item) for item in obj] + return _parse_pool_list(text or '') + + +def _first_present(data, keys): + for key in keys: + if key in data and data[key] is not None: + return data[key] + return [] + + +def _filter_payload_from_daemon(kind, data): + mode = data.get('mode') or 'allow' + if kind == 'client': + keys = ('denied_macs', 'denied_macs') if mode == 'deny' else ('allowed_macs', 'allowed_macs') + else: + keys = ('denied_ssids', 'denied_ssids') if mode == 'deny' else ('allowed_ssids', 'allowed_ssids') + values = [str(e) for e in (_first_present(data, keys) or [])] + if kind == 'ssid': + decoded = [] + for value in values: + try: + raw = base64.b64decode(value, validate=True) + decoded.append(raw.decode('utf-8')) + except (ValueError, TypeError): + decoded.append(value) + values = decoded + return {'mode': mode, 'entries': values, 'source': 'daemon'} + + +def _filter_from_hak5(kind): + prefix = FILTER_DAEMON[kind][2] + with _pineapd_cmd_lock: + rc, out, err = device_run([HAK5CMD, '%s_MODE' % prefix], timeout=5) + mode = _parse_filter_mode(out) or _parse_filter_mode(err) + entries = [] + listed = False + for argv in ([HAK5CMD, '%s_LIST' % prefix, mode or 'deny'], + [HAK5CMD, '%s_LIST' % prefix]): + rc2, out2, err2 = device_run(argv, timeout=5) + if rc2 == 0: + listed = True + entries = _parse_filter_list(out2 or '') + break + if mode is None and not listed: + return None + return {'mode': mode or 'deny', 'entries': entries, 'source': 'hak5cmd'} + + def load_pineap_state(): try: with open(PINEAP_STATE_FILE) as f: @@ -4524,26 +5458,18 @@ def h_pineap_mode_post(ctx): def h_filter_get(ctx, kind): get_path, set_path, hak5_prefix = FILTER_DAEMON[kind] - status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path) - if status != 200 or not isinstance(data, dict): - return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data} - mode = data.get('mode') or 'allow' - if kind == 'client': - entries = data.get('denied_macs') if mode == 'deny' else data.get('allowed_macs') - else: - entries = data.get('denied_ssids') if mode == 'deny' else data.get('allowed_ssids') - values = [str(e) for e in (entries or [])] - if kind == 'ssid': - decoded = [] - for value in values: - try: - raw = base64.b64decode(value, validate=True) - decoded.append(raw.decode('utf-8')) - except (ValueError, TypeError): - # Older daemon builds may return literal SSIDs instead. - decoded.append(value) - values = decoded - return 200, {'mode': mode, 'entries': values} + status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path, timeout=4) + if status == 200 and isinstance(data, dict): + return 200, _filter_payload_from_daemon(kind, data) + fallback = _filter_from_hak5(kind) + if fallback: + return 200, fallback + return 200, { + 'mode': 'deny', + 'entries': [], + 'source': 'unavailable', + 'error': 'filter service did not answer', + } def h_filter_post(ctx, kind): @@ -4558,9 +5484,10 @@ def h_filter_post(ctx, kind): return 400, {'error': 'mode must be allow or deny'} def run_filter(command, *args): - rc, out, err = device_run([HAK5CMD, command] + list(args), timeout=30) - if rc != 0: - return 502, {'error': 'filter command failed', 'detail': err or out} + try: + hak5(command, *args) + except RuntimeError as exc: + return 502, {'error': 'filter command failed', 'detail': str(exc)} return None if action == 'set_mode': @@ -4590,26 +5517,297 @@ def h_filter_post(ctx, kind): return h_filter_get(ctx, kind) -ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_challenge'} +ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_chalresp'} + +RADIUS_CAPTURE_NOTE = ( + 'The Pager terminates EAP locally (PineAPE). 802.1X identities and the inner ' + 'MSCHAPv2 challenge/response — the credentials a RADIUS server would see — ' + 'are stored in recon.db. The device is not a UDP/1812 RADIUS proxy.' +) + + +def _blob_to_hex(value): + if value is None or value == '': + return '' + if isinstance(value, memoryview): + value = value.tobytes() + if isinstance(value, bytearray): + value = bytes(value) + if isinstance(value, bytes): + return value.hex() + text = str(value).strip() + if not text: + return '' + if text.startswith("X'") and text.endswith("'") and len(text) > 3: + return text[2:-1].lower() + if '\\x' in text: + raw = bytearray() + i = 0 + n = len(text) + while i < n: + if (i + 3 < n and text[i] == '\\' and text[i + 1] == 'x' + and all(c in '0123456789abcdefABCDEF' for c in text[i + 2:i + 4])): + raw.append(int(text[i + 2:i + 4], 16)) + i += 4 + else: + raw.append(ord(text[i]) & 0xff) + i += 1 + return bytes(raw).hex() + cleaned = text.replace(':', '').replace(' ', '') + if re.match(r'^[0-9A-Fa-f]+$', cleaned) and len(cleaned) % 2 == 0: + return cleaned.lower() + try: + return text.encode('latin-1').hex() + except Exception: + return text.encode('utf-8', 'replace').hex() + + +def _mschap_hashcat_5500(username, challenge_hex, response_hex): + user = (username or '').strip() or '*' + chal = (challenge_hex or '').lower() + resp = (response_hex or '').lower() + if not chal or not resp: + return '' + return '%s::::%s:%s' % (user, resp, chal) + + +def _mschap_john(username, challenge_hex, response_hex): + user = (username or '').strip() or '*' + chal = (challenge_hex or '').lower() + resp = (response_hex or '').lower() + if not chal or not resp: + return '' + return '%s:$NETNTLM$%s$%s' % (user, chal, resp) + + +def _format_basic_row(row): + row = dict(row or {}) + for key, value in list(row.items()): + if isinstance(value, (bytes, bytearray, memoryview)): + row[key] = _blob_to_hex(value) + identity = row.get('identity') or row.get('username') or '' + row['identity'] = identity + if not row.get('username'): + row['username'] = identity + row['kind'] = 'eap-identity' + return row + + +def _format_chalresp_row(row): + row = dict(row or {}) + chal = _blob_to_hex(row.get('challenge')) + resp = _blob_to_hex(row.get('response')) + user = row.get('username') or row.get('identity') or '' + row['username'] = user + row['challenge'] = chal + row['response'] = resp + row['hashcat'] = _mschap_hashcat_5500(user, chal, resp) + row['john'] = _mschap_john(user, chal, resp) + row['asleap'] = ( + 'asleap -C %s -R %s -W wordlist.txt' % (chal, resp) if chal and resp else '') + row['kind'] = 'mschapv2' + row['hashcat_mode'] = 5500 + if isinstance(row.get('type'), (bytes, bytearray, memoryview)): + row['type'] = _blob_to_hex(row.get('type')) + return row + + +def _enterprise_basic_rows(): + try: + rows = _db_rows(RECON_DB, 'SELECT * FROM hostap_basic ORDER BY time DESC') + except RuntimeError: + rows = [] + return [_format_basic_row(r) for r in (rows or [])] + + +def _enterprise_chalresp_rows(): + sql_hex = ( + 'SELECT id, scan, time, type, username, ' + 'lower(hex(challenge)) AS challenge, ' + 'lower(hex(response)) AS response, verified ' + 'FROM hostap_chalresp ORDER BY time DESC') + try: + rows = _db_rows(RECON_DB, sql_hex) + except RuntimeError: + try: + rows = _db_rows(RECON_DB, 'SELECT * FROM hostap_chalresp ORDER BY time DESC') + except RuntimeError: + rows = [] + return [_format_chalresp_row(r) for r in (rows or [])] + + +def _enterprise_clients(): + try: + rows = _db_rows(RECON_DB, 'SELECT * FROM hostap_client ORDER BY connected_time DESC') + except RuntimeError: + return [] + out = [] + for raw in rows or []: + item = dict(raw) + if 'ssid' in item: + item['ssid'] = decode_ssid(item.get('ssid')) + if item.get('mac'): + item['mac'] = fmt_mac(item.get('mac')) + out.append(item) + return out + + +def _enterprise_radius_payload(): + basic = _enterprise_basic_rows() + chal = _enterprise_chalresp_rows() + log_items = _harvest_ent_log() + captures = [] + for row in basic: + captures.append({ + 'kind': 'eap-identity', + 'time': row.get('time'), + 'username': row.get('identity') or row.get('username') or '', + 'password': row.get('password') or '', + 'type': row.get('type') or '', + 'verified': row.get('verified'), + 'source': 'recon.db', + }) + for row in chal: + captures.append({ + 'kind': 'mschapv2', + 'time': row.get('time'), + 'username': row.get('username') or '', + 'challenge': row.get('challenge') or '', + 'response': row.get('response') or '', + 'hashcat': row.get('hashcat') or '', + 'john': row.get('john') or '', + 'asleap': row.get('asleap') or '', + 'type': row.get('type') or '', + 'verified': row.get('verified'), + 'source': 'recon.db', + }) + for item in log_items: + kind = item.get('kind') or '' + captures.append({ + 'kind': kind, + 'time': item.get('time'), + 'username': item.get('username') or '', + 'password': item.get('password') or '', + 'challenge': item.get('challenge') or '', + 'response': item.get('response') or '', + 'hashcat': item.get('hashcat') or '', + 'john': item.get('john') or '', + 'source': item.get('source') or 'log', + }) + captures.sort(key=lambda item: item.get('time') or 0, reverse=True) + uniq_hc = _unique_keep_order( + [row.get('hashcat') for row in chal] + [item.get('hashcat') for item in log_items]) + uniq_j = _unique_keep_order( + [row.get('john') for row in chal] + [item.get('john') for item in log_items]) + db_clients = _enterprise_clients() + live = [] + seen_mac = set() + ssid = (_ent_state_loaded() or {}).get('ssid') or '' + stations = _ent_stations() + for mac in stations: + seen_mac.add(mac) + live.append({'mac': mac, 'ssid': ssid, 'source': 'hostapd', + 'connected_time': None, 'disconnected_time': None}) + for row in db_clients: + mac = (row.get('mac') or '').upper() + if mac in seen_mac: + continue + seen_mac.add(mac) + live.append(row) + return { + 'note': RADIUS_CAPTURE_NOTE, + 'identities': basic, + 'mschapv2': chal, + 'captures': captures, + 'clients': live, + 'stations': stations, + 'log_captures': log_items, + 'hashcat': { + 'mode': 5500, + 'lines': uniq_hc, + 'command': 'hashcat -m 5500 hashes.5500 wordlist.txt', + }, + 'john': { + 'lines': uniq_j, + 'command': 'john --format=netntlm hashes.john', + }, + } def h_enterprise_data(ctx): - table = ENTERPRISE_TABLES.get((ctx.args or [''])[0]) + key = (ctx.args or [''])[0] + table = ENTERPRISE_TABLES.get(key) if not table: return 400, {'error': 'unknown table'} - rows = _db_rows(RECON_DB, 'SELECT * FROM %s ORDER BY time' % table) - return 200, {'table': table, 'rows': rows or []} + if key == 'challenge': + rows = _enterprise_chalresp_rows() + else: + rows = _enterprise_basic_rows() + return 200, {'table': table, 'rows': rows} + + +def h_enterprise_radius(ctx): + return 200, _enterprise_radius_payload() + + +def h_enterprise_export(ctx): + kind = (ctx.args or ['hashcat'])[0] + payload = _enterprise_radius_payload() + stamp = int(time.time()) + if kind == 'hashcat': + text = '\n'.join(payload['hashcat']['lines']) + if text: + text += '\n' + return 200, Download(text.encode('utf-8'), 'text/plain', + 'pineape-mschapv2-%d.5500' % stamp) + if kind == 'john': + text = '\n'.join(payload['john']['lines']) + if text: + text += '\n' + return 200, Download(text.encode('utf-8'), 'text/plain', + 'pineape-mschapv2-%d.john' % stamp) + if kind == 'json': + body = json.dumps(payload, indent=2, default=_json_default).encode('utf-8') + return 200, Download(body, 'application/json', + 'pineape-radius-%d.json' % stamp) + return 400, {'error': 'unknown export'} def h_enterprise_clear(ctx): - table = ENTERPRISE_TABLES.get((ctx.body or {}).get('table', '')) - if not table: - return 400, {'error': 'unknown table'} + key = ((ctx.body or {}).get('table') or '').strip() + if key == 'all': + names = ['hostap_basic', 'hostap_chalresp'] + else: + table = ENTERPRISE_TABLES.get(key) + if not table: + return 400, {'error': 'unknown table'} + names = [table] try: - _db_write(RECON_DB, 'DELETE FROM %s' % table) + for table in names: + _db_write(RECON_DB, 'DELETE FROM %s' % table) + if key in ('all', 'challenge', 'basic'): + _save_ent_captures([]) + try: + open(ENT_LOG, 'w').close() + except OSError: + pass except RuntimeError as e: return 502, {'error': str(e)} - return 200, {'ok': True} + return 200, {'ok': True, 'cleared': names} + + +def h_enterprise_log(ctx): + lines = [] + try: + with open(ENT_LOG) as f: + lines = f.read().splitlines()[-120:] + except OSError: + pass + return 200, { + 'lines': lines, + 'captures': _harvest_ent_log(), + 'stations': _ent_stations(), + } def _proxy_json(method, path, body=None): @@ -4655,10 +5853,65 @@ def _payload_key(value): return value if re.match(r'^[A-Za-z0-9._~-]+$', value) else '' +def _payload_installed_disk(): + """List payloads from disk when the stock portal API is unavailable.""" + rows = [] + for root in PAYLOAD_ROOTS: + user = os.path.join(root, 'user') + if not os.path.isdir(user): + continue + try: + categories = os.listdir(user) + except OSError: + continue + for category in categories: + catdir = os.path.join(user, category) + if not os.path.isdir(catdir): + continue + try: + names = os.listdir(catdir) + except OSError: + continue + for name in names: + path = os.path.join(catdir, name) + launch = os.path.join(path, 'payload.sh') + if not os.path.isfile(launch): + continue + manifest = {} + for fname in ('_hak5_manifest.json', 'manifest.json'): + mpath = os.path.join(path, fname) + if os.path.isfile(mpath): + try: + with open(mpath) as handle: + loaded = json.load(handle) + if isinstance(loaded, dict): + manifest = loaded + except (OSError, ValueError): + pass + break + key = manifest.get('key') or ('user~%s~%s' % (category, name)) + rows.append({ + 'uuid': manifest.get('uuid', ''), + 'key': key, + 'path': path, + 'category': manifest.get('category') or category, + 'title': manifest.get('title') or name, + 'author': manifest.get('author', ''), + 'description': manifest.get('description', ''), + 'version': manifest.get('version', ''), + 'launchpoint': manifest.get('launchpoint') or 'payload.sh', + 'interpreter': manifest.get('interpreter', ''), + 'disabled': bool(manifest.get('disabled')), + 'missingmanifest': not bool(manifest), + 'update': None, + }) + return 200, {'payloads': rows, 'source': 'disk'} + + def _payload_installed(): status, data = _payload_daemon('POST', '/api/payloads/portal/updates', {}) if status != 200: - return status, data + return _payload_installed_disk() rows = [] for record in data if isinstance(data, list) else data.get('payloads', []): if not isinstance(record, dict): @@ -4738,7 +5991,12 @@ def h_payloads_installed(ctx): def h_payloads_refresh(ctx): - return _payload_daemon('POST', '/api/payloads/portal/refresh', {}) + status, data = _payload_daemon('POST', '/api/payloads/portal/refresh', {}) + if status == 200: + return status, data + _, disk = _payload_installed_disk() + disk['warning'] = 'Pager portal refresh failed; using on-disk payload list' + return 200, disk def h_payloads_install(ctx): @@ -4825,7 +6083,7 @@ def h_payloads_stop(ctx): def _tail(text, lines): - return text.splitlines()[-lines:] if lines else [] + return (text or '').splitlines()[-lines:] if lines else [] def _line_count(ctx, default, maximum=2000): @@ -4837,23 +6095,57 @@ def _line_count(ctx, default, maximum=2000): return max(0, min(maximum, value)) +def _tail_file(path, n): + try: + size = os.path.getsize(path) + with open(path, 'r', errors='replace') as f: + if size > 262144: + f.seek(max(0, size - 262144)) + f.readline() + return _tail(f.read(), n) + except OSError: + return None + + +def _logread_lines(n, pattern=None, timeout=6): + n = max(0, min(int(n), 2000)) + if n == 0: + return [] + fetch = n if not pattern else min(2000, max(n * 4, 400)) + argv = ['logread', '-l', str(fetch)] + if pattern: + argv.extend(['-e', pattern]) + rc, out, err = device_run(argv, timeout=timeout) + combined = '%s %s' % (err or '', out or '') + if rc != 0 or re.search(r'invalid|unrecognized|unknown option|usage:', combined, re.I): + rc, out, err = device_run(['logread', '-l', str(fetch)], timeout=timeout) + if rc != 0: + rc, out, err = device_run(['logread'], timeout=timeout) + lines = (out or '').splitlines() + if pattern: + rx = re.compile(pattern, re.I) + lines = [line for line in lines if rx.search(line)] + return lines[-n:] + + def h_logging_system(ctx): - lines = _line_count(ctx, 200) - rc, out, err = device_run(['logread']) - return 200, {'lines': _tail(out, lines)} + n = _line_count(ctx, 200) + return 200, {'lines': _logread_lines(n)} PINEAP_LOG = '/var/log/pineapd.log' def h_logging_pineap(ctx): - lines = _line_count(ctx, 200) - if os.path.isfile(PINEAP_LOG): - with open(PINEAP_LOG, 'r', errors='replace') as f: - return 200, {'lines': _tail(f.read(), lines)} - rc, out, err = device_run(['logread']) - relevant = [l for l in out.splitlines() if 'pineap' in l.lower()] - return 200, {'lines': relevant[-lines:]} + n = _line_count(ctx, 200) + try: + if os.path.isfile(PINEAP_LOG): + tailed = _tail_file(PINEAP_LOG, n) + if tailed is not None: + return 200, {'lines': tailed} + except Exception: + pass + return 200, {'lines': _logread_lines(n, pattern=r'pineap|hostapd|eap')} def h_settings_hostname(ctx): @@ -4935,9 +6227,12 @@ def h_settings_service(ctx): return 200, {'running': running, 'background': rc2 == 0} -def _uci_get(option, default=''): - rc, out, err = device_run(['uci', 'get', option]) - return out.strip() if rc == 0 else default +def _uci_get(option, default=None): + rc, out, err = device_run(['uci', '-q', 'get', option]) + if rc != 0: + return default + val = (out or '').strip() + return val if val else default def _request_is_post(ctx): @@ -5375,6 +6670,9 @@ ROUTER.add('GET', r'/api/pineap/filters/client', lambda ctx: h_filter_get(ctx, ' ROUTER.add('POST', r'/api/pineap/filters/client', lambda ctx: h_filter_post(ctx, 'client')) ROUTER.add('GET', r'/api/pineap/filters/ssid', lambda ctx: h_filter_get(ctx, 'ssid')) ROUTER.add('POST', r'/api/pineap/filters/ssid', lambda ctx: h_filter_post(ctx, 'ssid')) +ROUTER.add('GET', r'/api/pineap/enterprise/radius', h_enterprise_radius) +ROUTER.add('GET', r'/api/pineap/enterprise/log', h_enterprise_log) +ROUTER.add('GET', r'/api/pineap/enterprise/export/(hashcat|john|json)', h_enterprise_export) ROUTER.add('GET', r'/api/pineap/enterprise/(basic|challenge)', h_enterprise_data) ROUTER.add('POST', r'/api/pineap/enterprise/clear', h_enterprise_clear) ROUTER.add('GET', r'/api/pineap/clients', h_clients) @@ -5521,10 +6819,171 @@ def _request_shutdown(signum=None, frame=None): _recon_hopper_stop.set() +def _uci_iface_present(name): + rc, out, err = device_run(['uci', '-q', 'get', 'wireless.%s' % name]) + return rc == 0 + + +def _stop_overlay_runtime(): + """Stop Mark VIII-only processes. Does not rewrite Pager UCI.""" + try: + _recon_hopper_stop.set() + except Exception: + pass + _recon_scan_state['active'] = False + try: + _restore_dummy_sta() + except Exception: + pass + for iface in ('wlan0mon', 'wlan1mon'): + pidfile = '/tmp/mk8_capture_%s.pid' % iface + try: + with open(pidfile) as handle: + pid = int(handle.read().strip()) + device_run(['kill', str(pid)], timeout=5) + os.unlink(pidfile) + except (OSError, ValueError): + pass + try: + _disable_enterprise_ap(resume_hop=False) + except Exception: + pass + + +def _restore_wifi_iface(name, wanted): + device_run(['uci', 'delete', 'wireless.%s' % name]) + if not wanted: + return + device_run(['uci', 'set', 'wireless.%s=wifi-iface' % name]) + for key, value in wanted.items(): + device_run(['uci', 'set', 'wireless.%s.%s=%s' % (name, key, value)]) + + +def capture_pager_snapshot(force=False): + """Record Pager-owned overlay UCI so shutdown can restore it. + + A crash/respawn must keep the original snapshot. Recapture only when + forced (fresh start after a clean restore, or an explicit release). + """ + if not force: + existing = load_pager_snapshot() + if existing: + return existing + snap = {'taken': time.time(), 'uci': {}, 'ifaces': {}} + for name in PAGER_SNAPSHOT_IFACES: + snap['ifaces'][name] = _uci_wifi_iface(name) if _uci_iface_present(name) else None + for key in PAGER_SNAPSHOT_UCI: + snap['uci'][key] = _uci_get(key) + directory = os.path.dirname(PAGER_SNAPSHOT_FILE) or '.' + try: + os.makedirs(directory, exist_ok=True) + except OSError: + pass + tmp = PAGER_SNAPSHOT_FILE + '.tmp' + with open(tmp, 'w') as handle: + json.dump(snap, handle) + os.replace(tmp, PAGER_SNAPSHOT_FILE) + return snap + + +def load_pager_snapshot(): + try: + with open(PAGER_SNAPSHOT_FILE) as handle: + data = json.load(handle) + return data if isinstance(data, dict) else None + except (OSError, ValueError): + return None + + +def restore_pager_truth(resnapshot=False): + """Make the Pager UI the source of truth for overlay radio/PineAP keys. + + Daemon-written PineAP (SSID pool contents, karma, filter entries) is left + alone. Mark VIII-only overlays (radio1 APs, hop pause, enterprise iface, + dummy_radio0) revert to the snapshot taken when Mark VIII started. + """ + _stop_overlay_runtime() + snap = load_pager_snapshot() + if not snap: + return {'ok': True, 'restored': False, 'reason': 'no snapshot'} + wireless_dirty = False + pineapd_dirty = False + for name in PAGER_SNAPSHOT_IFACES: + wanted = (snap.get('ifaces') or {}).get(name) + present = _uci_iface_present(name) + current = _uci_wifi_iface(name) if present else None + if current == wanted: + continue + _restore_wifi_iface(name, wanted) + wireless_dirty = True + for key, wanted in (snap.get('uci') or {}).items(): + current = _uci_get(key) + if current == wanted: + continue + if wanted is None: + device_run(['uci', 'delete', key]) + else: + device_run(['uci', 'set', '%s=%s' % (key, wanted)]) + if key.startswith('wireless.'): + wireless_dirty = True + elif key.startswith('pineapd.'): + pineapd_dirty = True + if wireless_dirty: + device_run(['uci', 'commit', 'wireless']) + device_run(['wifi', 'reload'], timeout=45) + if pineapd_dirty: + device_run(['uci', 'commit', 'pineapd']) + device_run(['/etc/init.d/pineapd', 'reload'], timeout=30) + try: + os.unlink(PINEAP_STATE_FILE) + except OSError: + pass + if resnapshot: + capture_pager_snapshot(force=True) + else: + try: + os.unlink(PAGER_SNAPSHOT_FILE) + except OSError: + pass + return { + 'ok': True, + 'restored': True, + 'wireless': wireless_dirty, + 'pineapd': pineapd_dirty, + 'resnapshot': bool(resnapshot), + } + + +def h_mode_get(ctx): + snap = load_pager_snapshot() + return 200, { + 'markviii': True, + 'pager_port': 1471, + 'snapshot': bool(snap), + 'taken': (snap or {}).get('taken'), + 'overlay': { + 'radio1_ap': _radio1_ap_active(), + 'enterprise': _ent_running(), + 'recon_hopper': bool(_recon_hop_state.get('active')), + }, + 'note': 'Stopping Mark VIII restores Pager-owned radio/PineAP overlays; ' + 'the Pager UI at :1471 is then the source of truth.', + } + + +def h_mode_release(ctx): + return 200, restore_pager_truth(resnapshot=True) + + +ROUTER.add('GET', r'/api/mode', h_mode_get) +ROUTER.add('POST', r'/api/mode/release', h_mode_release) + + def serve(): LIVE_STOP.clear() HEALTH_STOP.clear() _recon_hopper_stop.set() + capture_pager_snapshot() startup_env_check() threading.Thread(target=live_loop, daemon=True).start() threading.Thread(target=_recon_watchdog_loop, daemon=True).start() @@ -5545,8 +7004,12 @@ def serve(): threading.Thread( target=_handle_conn, args=(conn, addr), daemon=True).start() finally: - sock.close() _request_shutdown() + try: + restore_pager_truth(resnapshot=False) + except Exception: + pass + sock.close() def env_check_cli(): @@ -5572,6 +7035,10 @@ def _print_env_report(report, flush=False): if __name__ == '__main__': if '--env-check' in sys.argv: sys.exit(env_check_cli()) + if '--release-pager' in sys.argv: + result = restore_pager_truth(resnapshot=False) + print(json.dumps(result)) + sys.exit(0 if result.get('ok') else 1) signal.signal(signal.SIGTERM, _request_shutdown) signal.signal(signal.SIGINT, _request_shutdown) serve() diff --git a/payload/user/remote_access/pager-webui/skills/pineapple-control.md b/payload/user/remote_access/pager-webui/skills/pineapple-control.md index 3deb76b..fd711f0 100644 --- a/payload/user/remote_access/pager-webui/skills/pineapple-control.md +++ b/payload/user/remote_access/pager-webui/skills/pineapple-control.md @@ -57,8 +57,11 @@ hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineape_auth_enable ``` Captured credentials flow to pineapd's socket and land in -`hostap_basic`/`hostap_chalresp` in recon.db. Tear down: kill the pidfile -pid, `iw dev wlan1ent del`, resume hop. +`hostap_basic` / `hostap_chalresp` in recon.db. Mark VIII exposes them as +EAP identities + MSCHAPv2 (RADIUS inner-auth equivalent) at +`/api/pineap/enterprise/radius`, with hashcat `-m 5500` and john `netntlm` +export. The Pager is an EAP terminator (PineAPE), not a UDP/1812 RADIUS +proxy. Tear down: kill the pidfile pid, `iw dev wlan1ent del`, resume hop. ## Access diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 14084e4..49f7363 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -62,6 +62,10 @@ body { align-items: center; justify-content: center; } .toolbar-icon-btn:hover, .toolbar-icon-btn:focus-visible { background: rgba(255,255,255,.12); outline: none; } +.toolbar-icon-btn:disabled, .toolbar-icon-btn.busy, +.menu-link:disabled, .menu-link.busy { + opacity: .45; cursor: wait; pointer-events: none; +} .toolbar-icon-btn svg { width: 24px; height: 24px; display: block; } #terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] { background: #1976d2; color: #fff; @@ -200,12 +204,26 @@ html.dark .health-chip.bad { background: #4a2020; color: #ffb4a9; } box-shadow: 0 1px 3px rgba(0,0,0,.3); } .btn:hover { background: var(--primary-dark); } -.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; } .btn.ghost { background: transparent; color: var(--primary); box-shadow: none; border: 1px solid var(--primary); } .btn.danger { background: var(--danger); } +.btn:disabled, .btn.busy { + background: #9e9e9e; color: #fafafa; opacity: 1; + cursor: wait; pointer-events: none; box-shadow: none; +} +.btn.ghost:disabled, .btn.ghost.busy { + background: var(--surface-alt); color: var(--muted); + border-color: var(--border); +} +.btn.danger:disabled, .btn.danger.busy { background: #9e9e9e; color: #fafafa; } +html.dark .btn:disabled, html.dark .btn.busy { + background: #616161; color: #eeeeee; +} +html.dark .btn.ghost:disabled, html.dark .btn.ghost.busy { + background: var(--surface-alt); color: var(--muted); +} input, select { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 2px; padding: 8px 10px; width: 100%; @@ -293,6 +311,9 @@ pre.logs { .switch input:checked + .track::after { left: 22px; } .switch input:indeterminate + .track { background: #9e9e9e; } .switch input:indeterminate + .track::after { left: 12px; } +.switch:has(input:disabled), .switch:has(input.busy) { + opacity: .55; cursor: wait; pointer-events: none; +} .sel { padding: 6px 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; background: var(--card, #fff); color: var(--text, #212121); } .pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; } .muted { color: var(--muted, #686868); } @@ -342,7 +363,7 @@ html.dark .muted { color: #bdbdbd; } .recon-ps-actions .icon-btn svg { width: 18px; height: 18px; } .icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } .icon-btn:hover { background: var(--surface-alt); color: var(--text); } -.icon-btn:disabled { opacity: .38; cursor: default; } +.icon-btn:disabled, .icon-btn.busy { opacity: .38; cursor: wait; pointer-events: none; } .icon-btn svg { width: 22px; height: 22px; } .recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .recon-scan-bar .sel { width: auto; } @@ -406,7 +427,7 @@ html.dark .recon-dbm-bar { background: #333; } .recon-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; } .recon-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .recon-pill:hover { color: var(--text); border-color: var(--primary); } -.recon-pill:disabled { opacity: .5; cursor: default; } +.recon-pill:disabled, .recon-pill.busy { opacity: .5; cursor: wait; pointer-events: none; } .recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; } html.dark .recon-pill.on { background: #1b3a23; color: #81c784; } @@ -475,7 +496,7 @@ html.dark .modal { background: #303030; } .seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); } .seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); } .seg-btn.active { background: var(--primary, #1976d2); color: #fff; } -.seg-btn.busy { opacity: .5; pointer-events: none; } +.seg-btn:disabled, .seg-btn.busy { opacity: .5; cursor: wait; pointer-events: none; } /* ---- PineAP Mark VII layout ---- */ .pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; } diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 3d1ea33..62c598c 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -6,7 +6,7 @@ WiFi Pineapple - + @@ -262,13 +262,13 @@ - + - - - - + + + + diff --git a/payload/user/remote_access/pager-webui/www/js/api.js b/payload/user/remote_access/pager-webui/www/js/api.js index c3c2abb..0e6addd 100644 --- a/payload/user/remote_access/pager-webui/www/js/api.js +++ b/payload/user/remote_access/pager-webui/www/js/api.js @@ -3,32 +3,32 @@ const PagerAPI = (() => { let apiBase = ''; let on401 = null; - // Reads are polled and must never hang a page's refresh loop; writes have - // server-side timeouts up to 45s (radio deploys) so they get no client - // abort. const GET_TIMEOUT_MS = 20000; - async function request(method, path, body) { + const WRITE_TIMEOUT_MS = 45000; + async function request(method, path, body, attempt) { + attempt = attempt || 0; const opts = { method, headers: {}, credentials: 'include' }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } + const timeoutMs = method === 'GET' ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS; const ctl = new AbortController(); - const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null; - if (timer) opts.signal = ctl.signal; + const timer = setTimeout(() => ctl.abort(), timeoutMs); + opts.signal = ctl.signal; let res; try { res = await fetch(apiBase + path, opts); } catch (e) { - if (timer && e && e.name === 'AbortError') { - const error = new Error('Request timed out'); - error.status = 0; - throw error; + clearTimeout(timer); + if (method === 'GET' && attempt < 1) { + return request(method, path, body, attempt + 1); } - throw e; - } finally { - if (timer) clearTimeout(timer); + const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error'); + error.status = 0; + throw error; } + clearTimeout(timer); if (res.status === 401) { if (on401) on401(); throw new Error('unauthorized'); diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js index a358b8c..4686072 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -241,7 +241,7 @@ const App = (() => { }); } - function handleMenuAction(action) { + function handleMenuAction(action, button) { closeToolbarMenus(); if (action === 'help') { location.hash = '#/settings/help'; @@ -252,14 +252,18 @@ const App = (() => { if (typeof views.openClientModeModal === 'function') views.openClientModeModal(); else checkInternet(true); } else if (action === 'logout') { + if (button) { button.disabled = true; button.classList.add('busy'); } PagerAPI.post('/api/logout') .then(() => showLogin()) - .catch((error) => toast(error.message || 'Logout failed', 'error')); + .catch((error) => toast(error.message || 'Logout failed', 'error')) + .finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } }); } else if (action === 'reboot') { if (!window.confirm('Reboot Mark VIII now?')) return; + if (button) { button.disabled = true; button.classList.add('busy'); } PagerAPI.post('/api/settings/reboot') .then(() => toast('Reboot requested. Mark VIII will disconnect shortly.')) - .catch((error) => toast(error.message || 'Reboot failed', 'error')); + .catch((error) => toast(error.message || 'Reboot failed', 'error')) + .finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } }); } } @@ -332,7 +336,7 @@ const App = (() => { renderNotifications(); }); Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => { - item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'))); + item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'), item)); }); document.addEventListener('click', closeToolbarMenus); @@ -342,13 +346,14 @@ const App = (() => { const pw = document.getElementById('login-password').value; document.getElementById('login-error').textContent = ''; btn.disabled = true; + btn.classList.add('busy'); PagerAPI.login('root', pw) .then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); }) .catch((err) => { document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized') ? 'Login failed.' : 'Invalid credentials.'; }) - .finally(() => { btn.disabled = false; }); + .finally(() => { btn.disabled = false; btn.classList.remove('busy'); }); }); document.getElementById('terminal-btn').addEventListener('click', () => { diff --git a/payload/user/remote_access/pager-webui/www/js/pager.js b/payload/user/remote_access/pager-webui/www/js/pager.js index 1a775cf..f8c772c 100644 --- a/payload/user/remote_access/pager-webui/www/js/pager.js +++ b/payload/user/remote_access/pager-webui/www/js/pager.js @@ -5,6 +5,7 @@ const Pager = (() => { const SCREEN_HEIGHT = 222; const FB_STRIDE = SCREEN_WIDTH * 4; const PAGER_WIDTH = 745; + const MAX_QUEUED_KEYS = 24; const KEY_MAP = { 'LEFT.png': 'ArrowLeft', @@ -23,6 +24,10 @@ const Pager = (() => { let screenerr = null; let keyws = null; let screenws = null; + let wantOpen = false; + let retryTimer = null; + let retryMs = 400; + const pendingKeys = []; function ensure() { if (table) return; @@ -36,10 +41,17 @@ const Pager = (() => { const src = img.getAttribute('src').split('/').pop(); const key = KEY_MAP[src]; if (!key) return; - img.addEventListener('click', () => press(img, key)); + img.setAttribute('alt', key.replace('Arrow', '')); + img.addEventListener('pointerdown', (event) => { + event.preventDefault(); + press(img, key); + }); }); const retry = document.getElementById('screen_retry'); - if (retry) retry.addEventListener('click', () => connect()); + if (retry) retry.addEventListener('click', () => { + retryMs = 400; + connect(); + }); } function press(el, key) { @@ -48,8 +60,26 @@ const Pager = (() => { sendKey(key); } + function queueKey(k) { + pendingKeys.push(k); + while (pendingKeys.length > MAX_QUEUED_KEYS) pendingKeys.shift(); + } + + function flushKeys() { + while (pendingKeys.length && keyws && keyws.readyState === WebSocket.OPEN) { + try { keyws.send(pendingKeys.shift()); } + catch (e) { break; } + } + } + function sendKey(k) { - if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k); + if (keyws && keyws.readyState === WebSocket.OPEN) { + try { keyws.send(k); return true; } + catch (e) {} + } + queueKey(k); + if (wantOpen) connectKeys(); + return false; } function renderRGBAFrame(bytes) { @@ -76,33 +106,81 @@ const Pager = (() => { pager.src = canvas.toDataURL('image/png'); } - function connect() { - disconnect(); + function showError(show) { + if (screenerr) screenerr.hidden = !show; + } + + function scheduleReconnect() { + if (!wantOpen) return; + clearTimeout(retryTimer); + retryTimer = setTimeout(() => { + if (!wantOpen) return; + connectScreen(); + connectKeys(); + }, retryMs); + retryMs = Math.min(5000, Math.max(400, retryMs * 2)); + } + + function connectScreen() { + if (!wantOpen) return; + if (screenws && (screenws.readyState === WebSocket.OPEN || screenws.readyState === WebSocket.CONNECTING)) return; try { const sock = new WebSocket(App.pagerScreenWs); sock.binaryType = 'arraybuffer'; screenws = sock; - sock.onopen = () => { screenerr.hidden = true; }; + sock.onopen = () => { + retryMs = 400; + if (keyws && keyws.readyState === WebSocket.OPEN) showError(false); + }; sock.onmessage = (ev) => { + showError(false); if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data)); else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b))); }; - sock.onerror = () => { screenerr.hidden = false; }; - sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; }; + sock.onerror = () => { showError(true); }; + sock.onclose = () => { + if (screenws === sock) screenws = null; + showError(true); + scheduleReconnect(); + }; } catch (e) { - screenerr.hidden = false; - } - try { - const sock = new WebSocket(App.pagerKeysWs); - keyws = sock; - sock.onclose = () => { if (keyws === sock) keyws = null; }; - sock.onerror = () => { try { sock.close(); } catch (e2) {} }; - } catch (e) { - keyws = null; + showError(true); + scheduleReconnect(); } } + function connectKeys() { + if (!wantOpen) return; + if (keyws && (keyws.readyState === WebSocket.OPEN || keyws.readyState === WebSocket.CONNECTING)) return; + try { + const sock = new WebSocket(App.pagerKeysWs); + keyws = sock; + sock.onopen = () => { + retryMs = 400; + flushKeys(); + if (screenws && screenws.readyState === WebSocket.OPEN) showError(false); + }; + sock.onclose = () => { + if (keyws === sock) keyws = null; + scheduleReconnect(); + }; + sock.onerror = () => { try { sock.close(); } catch (e2) {} }; + } catch (e) { + keyws = null; + scheduleReconnect(); + } + } + + function connect() { + connectScreen(); + connectKeys(); + } + function disconnect() { + wantOpen = false; + clearTimeout(retryTimer); + retryTimer = null; + pendingKeys.length = 0; if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; } if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; } } @@ -121,6 +199,8 @@ const Pager = (() => { panel.classList.remove('hidden'); document.getElementById('pager-btn').classList.add('active'); applyScale(); + wantOpen = true; + retryMs = 400; connect(); try { pager.focus(); } catch (e) {} } else { diff --git a/payload/user/remote_access/pager-webui/www/js/terminal.js b/payload/user/remote_access/pager-webui/www/js/terminal.js index 9cf9cba..ab30b22 100644 --- a/payload/user/remote_access/pager-webui/www/js/terminal.js +++ b/payload/user/remote_access/pager-webui/www/js/terminal.js @@ -5,6 +5,9 @@ const Term = (() => { let fitAddon = null; let ws = null; let panel = null; + let wantOpen = false; + let retryTimer = null; + let retryMs = 400; function ensure() { if (term) return; @@ -14,7 +17,20 @@ const Term = (() => { term.loadAddon(fitAddon); term.open(document.getElementById('terminal')); try { fitAddon.fit(); } catch (e) {} - term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); }); + term.onData((d) => { + if (ws && ws.readyState === WebSocket.OPEN) { + try { ws.send(d); } catch (e) {} + } + }); + } + + function scheduleReconnect() { + if (!wantOpen) return; + clearTimeout(retryTimer); + retryTimer = setTimeout(() => { + if (wantOpen) connect(); + }, retryMs); + retryMs = Math.min(5000, Math.max(400, retryMs * 2)); } function toggle() { @@ -23,6 +39,8 @@ const Term = (() => { panel.classList.remove('hidden'); document.getElementById('terminal-btn').classList.add('active'); try { fitAddon.fit(); } catch (e) {} + wantOpen = true; + retryMs = 400; connect(); } else { panel.classList.add('hidden'); @@ -32,25 +50,39 @@ const Term = (() => { } function connect() { - if (ws) return; + if (!wantOpen) return; + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; if (term) term.reset(); let sock; try { sock = new WebSocket(App.terminalWs); } catch (e) { term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']'); + scheduleReconnect(); return; } ws = sock; + sock.onopen = () => { retryMs = 400; }; sock.onmessage = (ev) => { if (typeof ev.data === 'string') term.write(ev.data); else ev.data.text().then((t) => term.write(t)); }; - sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); }; + sock.onclose = () => { + if (ws === sock) ws = null; + if (term && wantOpen) { + term.writeln('\r\n[connection closed — reconnecting]'); + scheduleReconnect(); + } else if (term) { + term.writeln('\r\n[connection closed]'); + } + }; sock.onerror = () => { try { sock.close(); } catch (e) {} }; } function disconnect() { + wantOpen = false; + clearTimeout(retryTimer); + retryTimer = null; if (ws) { try { ws.close(); } catch (e) {} ws = null; } } 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 f6bbfc4..983c5c0 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -98,13 +98,75 @@ const downloadText = (filename, text) => { setTimeout(() => URL.revokeObjectURL(url), 0); }; +const copyText = (text, okMsg) => { + const value = String(text == null ? '' : text); + if (!value) return Promise.resolve(); + const done = () => App.toast(okMsg || 'Copied'); + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(value).then(done).catch(() => downloadText('copy.txt', value)); + } + downloadText('copy.txt', value); + return Promise.resolve(); +}; + const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' }); -const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label }); +function markBusy(button, busy, busyText) { + if (!button) return; + const labeled = button.classList.contains('btn'); + if (busy) { + if (labeled && !button.dataset.label) button.dataset.label = button.textContent; + button.disabled = true; + button.classList.add('busy'); + button.setAttribute('aria-busy', 'true'); + if (busyText && labeled) button.textContent = busyText; + } else { + button.disabled = false; + button.classList.remove('busy'); + button.removeAttribute('aria-busy'); + if (labeled && button.dataset.label) button.textContent = button.dataset.label; + } +} + +function runAction(button, work, busyText) { + if (button && (button.disabled || button.classList.contains('busy'))) { + return Promise.resolve(); + } + markBusy(button, true, busyText); + return Promise.resolve() + .then(work) + .catch((e) => { + App.toast((e && e.message) || 'Action failed', 'error'); + }) + .finally(() => markBusy(button, false)); +} + +function armBusy(button, onclk) { + if (!onclk) return; + button.addEventListener('click', (event) => { + if (button.disabled || button.classList.contains('busy')) { + event.preventDefault(); + return; + } + const result = onclk(event); + if (!result || typeof result.then !== 'function') return; + if (!button.classList.contains('busy')) markBusy(button, true); + Promise.resolve(result).catch(() => {}).finally(() => { + if (button.classList.contains('busy')) markBusy(button, false); + }); + }); +} + +const btn = (label, onclk, cls) => { + const b = h('button', { class: 'btn ' + (cls || ''), text: label }); + armBusy(b, onclk); + return b; +}; const iconBtn = (name, title, onclk) => { - const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk }); + const b = h('button', { class: 'icon-btn', title: title || '' }); b.innerHTML = PineappleIcons[name] || ''; + armBusy(b, onclk); return b; }; @@ -215,9 +277,9 @@ views.dashboard = (root) => { const unsubscribe = Live.onTick(update); PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients })) .catch(() => { - cards.clients.textContent = 'Unavailable'; - cards.disk.textContent = 'Unavailable'; - cards.uptime.textContent = 'Unavailable'; + cards.clients.textContent = '0'; + cards.disk.textContent = '—'; + cards.uptime.textContent = '—'; }); PagerAPI.get('/api/pineap/handshakes').then((r) => { cards.handshakes.textContent = (r.data.files || []).length; @@ -237,10 +299,16 @@ views.dashboard = (root) => { clBody.innerHTML = ''; clBody.appendChild(table( [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, - { label: '', render: () => '' }], - r.data.clients, - (c) => ({ style: 'cursor:pointer', - onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } }))); + { label: '', render: (c) => { + const deauthBtn = btn('Deauth', () => { + if (!confirm('Deauthenticate ' + c.mac + '?')) return; + return PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }) + .then(() => App.toast('Deauthenticated')) + .then(loadClients); + }, 'danger'); + return deauthBtn; + } }], + r.data.clients)); const cols = ['MAC', 'Interface', 'RSSI']; clBody.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; }); }).catch(() => { clBody.textContent = 'Unable to load connected clients.'; }); @@ -368,8 +436,10 @@ views.pineap = (root) => { function bind(cb, on, remember) { cb.addEventListener('change', () => { + if (cb.disabled) return; const requested = cb.checked; cb.indeterminate = false; + cb.disabled = true; on(requested).then(() => { if (remember) remember(requested); load(); @@ -377,7 +447,7 @@ views.pineap = (root) => { cb.checked = !requested; load(); App.toast((e && e.message) || 'Failed', 'error'); - }); + }).finally(() => { cb.disabled = false; }); }); } function rememberAdvanced(key, value) { @@ -427,6 +497,7 @@ views.pineap = (root) => { modePending = true; saveModeBtn.disabled = true; saveModeBtn.classList.add('busy'); + saveModeBtn.setAttribute('aria-busy', 'true'); PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => { const state = r.data || {}; PINEAP_SESSION.mode = state.mode || selectedMode; @@ -441,6 +512,7 @@ views.pineap = (root) => { .finally(() => { modePending = false; saveModeBtn.classList.remove('busy'); + saveModeBtn.removeAttribute('aria-busy'); saveModeBtn.disabled = !modeDirty; }); } @@ -469,11 +541,12 @@ views.pineap = (root) => { : Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null; setKnownCheckbox(quick.collect, collect); const pool = a.pool || {}; - const poolDisabled = pool.disabled === true; + const poolDisabled = pool.disabled === true || pool.broadcast_blocked === true; poolNotice.classList.toggle('hidden', !poolDisabled); quick.advertise.disabled = poolDisabled; quick.advertise.title = poolDisabled ? 'Disabled by firmware crash fix' : ''; - if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false; + if (pool.broadcast_blocked) PINEAP_SESSION.advertise = false; + else if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false; setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); cards.karma.value.textContent = PINEAP_SESSION.karma == null ? 'Unknown' : (PINEAP_SESSION.karma ? 'On' : 'Off'); const open = a.open || {}; @@ -485,8 +558,8 @@ views.pineap = (root) => { stats.ssids.textContent = Array.isArray((ss.data || {}).ssids) ? ss.data.ssids.length : 'Unavailable'; }).catch(() => { stats.ssids.textContent = 'Unavailable'; }), PagerAPI.get('/api/pineap/clients').then((cl) => { - stats.clients.textContent = typeof (cl.data || {}).count === 'number' ? cl.data.count : 'Unavailable'; - }).catch(() => { stats.clients.textContent = 'Unavailable'; }), + stats.clients.textContent = typeof (cl.data || {}).count === 'number' ? cl.data.count : '0'; + }).catch(() => { stats.clients.textContent = '0'; }), PagerAPI.get('/api/pineap/handshakes').then((hs) => { stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable'; }).catch(() => { stats.handshakes.textContent = 'Unavailable'; }) @@ -576,49 +649,19 @@ const OPEN_COUNTRIES = [ views.pineap_evilwpa = attackLauncher('wpa', { title: 'Evil WPA', - subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture', passphrase: true, encodings: EVIL_ENC, handshakes: true, export: true, deauth: true, - tabHash: '#/pineap/evilwpa', - playbook: { - steps: ['Deploy the evil twin', - 'Wait for a client to associate', - 'Deauth the target client to force the 4-way', - 'Export .hc22000 and crack with hashcat'], - currentStep: (s, w) => { - if (!w || !w.enabled) return 'Deploy the evil twin'; - if (!s || !(s.handshakes > 0)) return 'Wait for a client to associate'; - return 'Export .hc22000 and crack with hashcat'; - }, - hint: (s, w) => { - if (!w || !w.enabled) return '1. Set the target SSID and passphrase, pick a channel (Auto finds it from recon), Deploy. 2. When the target client is near, use Deauth Targeting below. 3. Captured handshakes appear above — Export and run the hashcat command.'; - if (s && s.handshakes > 0) return 'Handshake captured! Export .hc22000 and run hashcat -m 22000.'; - return 'AP is live on ' + (w.ssid || 'the target') + '. Watch the handshakes list — use Deauth Targeting to nudge the client. If a client refuses to join the twin, its reconnect to the real AP is still captured passively.'; - } - } + tabHash: '#/pineap/evilwpa' }); views.pineap_open = attackLauncher('open', { title: 'OpenAP', - subtitle: 'Open network evil twin', bssid: true, country: true, - tabHash: '#/pineap/open', - playbook: { - steps: ['Deploy the open AP', - 'Wait for clients to associate', - 'Watch connected clients under PineAP → Clients'], - currentStep: (s, w) => { - if (!w || !w.enabled) return 'Deploy the open AP'; - return 'Wait for clients to associate'; - }, - hint: (s, w) => !w || !w.enabled - ? 'Set the SSID (optionally spoof a BSSID), pick a channel (Auto finds it from recon), Deploy.' - : 'Open AP is live on ' + (w.ssid || 'the target') + ' — clients that join appear in the Clients list.' - } + tabHash: '#/pineap/open' }); views.pineap_impersonation = (root) => { @@ -634,43 +677,76 @@ views.pineap_impersonation = (root) => { const colCb = h('input', { type: 'checkbox', id: 'imp-collect' }); const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' }, h('div', { class: 'pineap-card-title' }, 'SSID Pool')); + const addBtn = btn('Add', () => { + const v = input.value.trim(); if (!v) return; + return runAction(addBtn, () => PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { + input.value = ''; render(r.data.ssids); App.toast('Added'); + }), 'Adding…'); + }); + const clearBtn = btn('Clear', () => { + if (!confirm('Clear the entire SSID pool?')) return; + return runAction(clearBtn, () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => { + render(r.data.ssids); App.toast('Pool cleared'); + }), 'Clearing…'); + }, 'danger'); poolBox.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'SSID', input)), - h('div', {}, btn('Add', () => { - const v = input.value.trim(); if (!v) return; - PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); App.toast('Added'); }); - })), - h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger')))); + h('div', {}, addBtn), + h('div', {}, clearBtn))); poolBox.appendChild(h('label', { class: 'switch' }, advCb, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool')); poolBox.appendChild(h('label', { class: 'switch' }, colCb, h('span', { class: 'track' }), 'Capture SSIDs to Pool')); poolBox.appendChild(list); box.appendChild(poolBox); setKnownCheckbox(advCb, PINEAP_SESSION.advertise); advCb.addEventListener('change', () => { + if (advCb.disabled) return; const requested = advCb.checked; advCb.indeterminate = false; + advCb.disabled = true; PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => { PINEAP_SESSION.advertise = requested; load(); - }).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); }); + }).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); }) + .finally(() => { advCb.disabled = false; }); + }); + colCb.addEventListener('change', () => { + if (colCb.disabled) return; + colCb.disabled = true; + PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load) + .catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); }) + .finally(() => { colCb.disabled = false; }); }); - colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); })); function render(ssids) { poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0; list.innerHTML = ''; list.appendChild(table( - [{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }], - (ssids || []).map((s) => ({ ssid: s })), - (r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } }))); + [{ label: 'SSID', key: 'ssid' }, + { label: '', render: (r) => btn('Remove', () => { + if (!confirm('Remove ' + r.ssid + '?')) return; + return PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }) + .then((x) => render(x.data.ssids)); + }, 'danger') }], + (ssids || []).map((s) => ({ ssid: s })))); list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; }); if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No SSIDs in pool.' })); } function load() { - PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {}); + PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch((e) => { + list.innerHTML = ''; + list.appendChild(h('div', { class: 'empty', text: 'SSID pool unavailable: ' + ((e && e.message) || 'error') })); + }); PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const p = (r.data || {}).pool || {}; - if (p.disabled != null) PINEAP_SESSION.advertise = p.disabled === false; + if (p.broadcast_blocked) { + PINEAP_SESSION.advertise = false; + advCb.disabled = true; + advCb.title = 'Disabled by firmware crash fix'; + } else { + advCb.disabled = false; + advCb.title = ''; + if (p.disabled != null) PINEAP_SESSION.advertise = p.disabled === false; + } setKnownCheckbox(advCb, PINEAP_SESSION.advertise); setKnownCheckbox(colCb, Object.prototype.hasOwnProperty.call(p, 'collecting') ? !!p.collecting : null); }).catch(() => {}); @@ -700,22 +776,40 @@ views.pineap_clients = (root) => { body.innerHTML = ''; body.appendChild(table( [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, - { label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }], - state.clients, - (r) => ({ style: 'cursor:pointer', - onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } }))); - const cols = ['MAC', 'Interface', 'RSSI']; - body.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; }); + { label: 'RSSI', key: 'rssi' }, { label: 'Source', key: 'source' }, + { label: '', render: (r) => btn('Kick', () => { + if (!confirm('Kick ' + r.mac + '?')) return; + return PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }) + .then(() => App.toast('Kicked')) + .then(load); + }, 'danger') }], + state.clients)); + const cols = ['MAC', 'Interface', 'RSSI', 'Source']; + body.querySelectorAll('.tbl th').forEach((th, i) => { if (i === cols.length) th.textContent = 'Kick'; }); } let pending = false; function load() { - if (pending) return; + if (pending) return Promise.resolve(); pending = true; - PagerAPI.get('/api/pineap/clients').then((r) => { - state.clients = r.data.clients || []; - count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : '—'; + return PagerAPI.get('/api/pineap/clients').then((r) => { + state.clients = (r.data && r.data.clients) || []; + count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : String(state.clients.length); render(); - }).catch(() => { count.textContent = 'Unavailable'; body.textContent = 'Unable to load connected clients.'; }) + if (r.data && r.data.error) { + body.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px', + text: r.data.error })); + } + if (!state.clients.length) { + body.appendChild(h('div', { class: 'empty', + text: 'No associated clients on AP interfaces. Evil Enterprise stations appear here and on the Evil Enterprise page (wlan1ent is not shown in the stock Pager UI).' })); + } + }).catch((e) => { + count.textContent = '0'; + state.clients = []; + render(); + body.appendChild(h('div', { class: 'empty', + text: 'Client listing failed (' + (e.message || 'error') + '). Try Evil Enterprise for wlan1ent stations.' })); + }) .finally(() => { pending = false; }); } load(); @@ -729,7 +823,7 @@ views.pineap_filtering = (root) => { const path = '/api/pineap/filters/' + kind; const noun = kind === 'client' ? 'client MACs' : 'SSIDs'; const singular = kind === 'client' ? 'client MAC' : 'SSID'; - const state = { mode: 'deny', entries: [], pending: false }; + const state = { mode: 'deny', entries: [], pending: false, error: '' }; const allowDefault = h('button', { class: 'seg-btn', text: 'Allow by default' }); const denyDefault = h('button', { class: 'seg-btn', text: 'Deny by default' }); const modeBar = h('div', { class: 'seg filter-mode-seg' }, allowDefault, denyDefault); @@ -755,13 +849,17 @@ views.pineap_filtering = (root) => { function setPending(value) { state.pending = value; [allowDefault, denyDefault, addButton, clearButton, allowAllButton, valueIn] - .forEach((el) => { el.disabled = value; }); + .forEach((el) => { + el.disabled = value; + if (el.classList) el.classList.toggle('busy', value); + }); } function applyResponse(r) { const data = (r && r.data) || {}; state.mode = data.mode === 'allow' ? 'allow' : 'deny'; state.entries = Array.isArray(data.entries) ? data.entries : []; + state.error = data.error || ''; render(); } @@ -770,17 +868,17 @@ views.pineap_filtering = (root) => { } function mutate(payload, success) { - if (state.pending) return; + if (state.pending) return Promise.resolve(); setPending(true); - PagerAPI.post(path, Object.assign({ mode: state.mode }, payload)) + return PagerAPI.post(path, Object.assign({ mode: state.mode }, payload)) .then((r) => { applyResponse(r); if (success) App.toast(success); }) .catch(() => fail('Failed to update ' + title.toLowerCase())) .finally(() => setPending(false)); } function setMode(mode) { - if (state.mode === mode || state.pending) return; - mutate({ action: 'set_mode', mode }, mode === 'deny' + if (state.mode === mode || state.pending) return Promise.resolve(); + return mutate({ action: 'set_mode', mode }, mode === 'deny' ? title + ': allowing by default' : title + ': denying by default'); } @@ -788,26 +886,23 @@ views.pineap_filtering = (root) => { function addEntry() { const value = valueIn.value.trim(); if (!value) { fail(singular + ' required'); return; } - mutate({ action: 'add', value }, singular + ' added'); valueIn.value = ''; + return mutate({ action: 'add', value }, singular + ' added'); } function removeEntry(value) { - if (confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) { - mutate({ action: 'delete', value }, singular + ' removed'); - } + if (!confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) return; + return mutate({ action: 'delete', value }, singular + ' removed'); } function clearCurrent() { - if (confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) { - mutate({ action: 'clear' }, 'Current list cleared'); - } + if (!confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) return; + return mutate({ action: 'clear' }, 'Current list cleared'); } function allowAll() { - if (confirm('Allow all ' + noun + '? This clears the deny list and changes the default behavior to allow.')) { - mutate({ action: 'allow_all' }, 'All ' + noun + ' allowed'); - } + if (!confirm('Allow all ' + noun + '? This clears the deny list and changes the default behavior to allow.')) return; + return mutate({ action: 'allow_all' }, 'All ' + noun + ' allowed'); } function render() { @@ -817,15 +912,17 @@ views.pineap_filtering = (root) => { explanation.textContent = isDenyList ? 'New ' + noun + ' are allowed. Only entries in the deny list are blocked.' : 'New ' + noun + ' are blocked. Only entries in the allow list are permitted.'; + if (state.error) { + explanation.textContent += ' Live list is temporarily unavailable.'; + } valueLabel.querySelector('.pineap-filter-input-label').textContent = 'Add to ' + (isDenyList ? 'deny' : 'allow') + ' list'; valueIn.placeholder = kind === 'client' ? 'AA:BB:CC:DD:EE:FF' : 'Network name'; list.innerHTML = ''; list.appendChild(table( [{ label: (isDenyList ? 'Denied ' : 'Allowed ') + singular, key: 'value' }, - { label: '', render: () => 'Remove' }], - state.entries.map((e) => ({ value: e })), - (row) => ({ onclick: () => removeEntry(row.value) }))); + { label: '', render: (row) => btn('Remove', () => removeEntry(row.value), 'danger') }], + state.entries.map((e) => ({ value: e })))); if (!state.entries.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'The ' + (isDenyList ? 'deny' : 'allow') + ' list is empty.' @@ -835,7 +932,9 @@ views.pineap_filtering = (root) => { allowDefault.addEventListener('click', () => setMode('deny')); denyDefault.addEventListener('click', () => setMode('allow')); valueIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') addEntry(); }); - PagerAPI.get(path).then(applyResponse).catch(() => fail('Unable to load ' + title.toLowerCase())); + PagerAPI.get(path).then(applyResponse).catch((e) => { + applyResponse({ data: { mode: 'deny', entries: [], error: (e && e.message) || 'unavailable' } }); + }); } filterCard('Client Filter', 'client'); filterCard('SSID Filter', 'ssid'); @@ -957,8 +1056,7 @@ function attackLauncher(kind, opts) { return (root) => { const box = pineapShell(root, opts.tabHash || '#/pineap/evilwpa'); const form = h('div', { class: 'pineap-title-card' }); - form.appendChild(h('div', { class: 'pineap-card-title' }, - opts.title + (opts.subtitle ? ' — ' + opts.subtitle : ''))); + form.appendChild(h('div', { class: 'pineap-card-title' }, opts.title)); box.appendChild(form); const f = h('div', {}); form.appendChild(f); @@ -1026,24 +1124,28 @@ function attackLauncher(kind, opts) { } f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, - h('div', {}, btn('Deploy Attack', () => { - const body = { - kind, ssid: ssidIn.value.trim(), - hidden: hiddenCb.checked, - channel: chanSel.value ? parseInt(chanSel.value, 10) : null - }; - if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; } - if (bssidIn) body.bssid = bssidIn.value.trim(); - if (coSel) body.country = coSel.value; - PagerAPI.post('/api/attacks/deploy', body) - .then((r) => { verifiedToast(r.data || {}); load(); }) - .catch((e) => { App.toast(e.message || 'Deploy failed', 'error'); }); - })), - h('div', {}, btn('Stop Attack', () => { - PagerAPI.post('/api/attacks/stop', { kind }) - .then(() => { App.toast('Attack stopped'); load(); }) - .catch(() => App.toast('Stop failed', 'error')); - }, 'danger')), + h('div', {}, (function () { + const deployBtn = btn('Deploy Attack', () => { + const body = { + kind, ssid: ssidIn.value.trim(), + hidden: hiddenCb.checked, + channel: chanSel.value ? parseInt(chanSel.value, 10) : null + }; + if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; } + if (bssidIn) body.bssid = bssidIn.value.trim(); + if (coSel) body.country = coSel.value; + runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body) + .then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…'); + }); + return deployBtn; + })()), + h('div', {}, (function () { + const stopBtn = btn('Stop Attack', () => { + runAction(stopBtn, () => PagerAPI.post('/api/attacks/stop', { kind }) + .then(() => { App.toast('Attack stopped'); load(); }), 'Stopping…'); + }, 'danger'); + return stopBtn; + })()), h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Deploy reconfigures the radio — you may be disconnected briefly.'))); @@ -1063,11 +1165,14 @@ function attackLauncher(kind, opts) { const run = !!(st && st.running); capBody.appendChild(h('div', { class: 'row' }, h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }), - h('div', {}, btn(run ? 'Stop Capture' : 'Start Capture', () => { - PagerAPI.post('/api/attacks/capture', { - iface: capIface, action: run ? 'stop' : 'start' - }).then((r) => capRow(r.data || {})).catch(() => App.toast('Capture failed', 'error')); - })))); + h('div', {}, (function () { + const capBtn = btn(run ? 'Stop Capture' : 'Start Capture', () => { + runAction(capBtn, () => PagerAPI.post('/api/attacks/capture', { + iface: capIface, action: run ? 'stop' : 'start' + }).then((r) => capRow(r.data || {})), run ? 'Stopping…' : 'Starting…'); + }); + return capBtn; + })()))); capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px', text: run && st.path ? st.path : '' })); } @@ -1077,18 +1182,21 @@ function attackLauncher(kind, opts) { exp.appendChild(h('div', { class: 'pineap-card-title' }, 'Hashcat Export')); const expBody = h('div', { style: 'font-size:13px;line-height:1.8' }); exp.appendChild(expBody); - expBody.appendChild(h('div', {}, btn('Export .hc22000', () => { - PagerAPI.get('/api/attacks/export/hc22000').then((r) => { - const d = r.data || {}; - if (d.file) { - expBody.appendChild(h('div', { class: 'row', style: 'margin-top:8px' }, - h('a', { class: 'btn ghost', href: '/api/attacks/export/hc22000/' + d.name, - style: 'text-decoration:none', download: d.name }, 'Download ' + d.name), - h('code', { style: 'font-size:12px', text: d.hashcat || '' }))); - App.toast('Export ready'); - } else App.toast('No captures to export', 'error'); - }).catch(() => App.toast('Export failed', 'error')); - }))); + expBody.appendChild(h('div', {}, (function () { + const expBtn = btn('Export .hc22000', () => { + runAction(expBtn, () => PagerAPI.get('/api/attacks/export/hc22000').then((r) => { + const d = r.data || {}; + if (d.file) { + expBody.appendChild(h('div', { class: 'row', style: 'margin-top:8px' }, + h('a', { class: 'btn ghost', href: '/api/attacks/export/hc22000/' + d.name, + style: 'text-decoration:none', download: d.name }, 'Download ' + d.name), + h('code', { style: 'font-size:12px', text: d.hashcat || '' }))); + App.toast('Export ready'); + } else App.toast('No captures to export', 'error'); + }), 'Exporting…'); + }); + return expBtn; + })())); box.appendChild(exp); } @@ -1099,37 +1207,11 @@ function attackLauncher(kind, opts) { box.appendChild(deauthCard); } - if (opts.playbook) { - const pb = h('div', { class: 'pineap-title-card' }); - pb.appendChild(h('div', { class: 'pineap-card-title' }, 'Playbook')); - const pbBody = h('div', { style: 'font-size:13px;line-height:1.8' }); - pb.appendChild(pbBody); - box.appendChild(pb); - const steps = opts.playbook.steps; - const stepEls = {}; - const stepList = h('ol', { style: 'margin:0;padding-left:20px' }); - steps.forEach((s) => { - const li = h('li', { style: 'margin-top:4px' }, h('span', { text: s })); - stepEls[s] = li; - stepList.appendChild(li); - }); - pbBody.appendChild(stepList); - const pbHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:6px' }); - pbBody.appendChild(pbHint); - const pbTick = (status, which) => { - const activeStep = opts.playbook.currentStep(status, which); - steps.forEach((s) => { - stepEls[s].style.color = s === activeStep ? 'var(--primary)' : ''; - stepEls[s].style.fontWeight = s === activeStep ? '600' : ''; - }); - pbHint.textContent = opts.playbook.hint(status, which) || ''; - }; - pbTick(null, null); - box.__pbTick = pbTick; - } - + let loadPending = false; function load() { - PagerAPI.get('/api/attacks/status').then((r) => { + if (loadPending) return; + loadPending = true; + const statusReq = PagerAPI.get('/api/attacks/status').then((r) => { const s = r.data || {}; const ap = s[kind] || {}; const which = (ap.radio0 && ap.radio0.enabled) ? ap.radio0 @@ -1149,7 +1231,6 @@ function attackLauncher(kind, opts) { ssidRef.current = which && which.ssid ? which.ssid : ''; if (ssidRef.tick) ssidRef.tick(); } - if (box.__pbTick) box.__pbTick(s, which); if (opts.handshakes) { hsBox.innerHTML = ''; hsBox.appendChild(h('div', { class: 'pineap-card-title', text: 'Handshakes Captured (' + (s.handshakes || 0) + ')' })); @@ -1167,7 +1248,8 @@ function attackLauncher(kind, opts) { box.appendChild(hsBox); } }).catch(() => {}); - PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {}); + const capReq = PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {}); + Promise.allSettled([statusReq, capReq]).finally(() => { loadPending = false; }); } load(); const iv = setInterval(load, 5000); @@ -1192,8 +1274,7 @@ views.harness = (root) => { infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok, h('div', {}, btn('Copy Token', () => { - navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied')) - .catch(() => App.toast('Copy failed', 'error')); + return navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied')); })))); infoBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', text: 'Agents call POST /mcp with JSON-RPC 2.0 (MCP Streamable HTTP). The token is the current session token.' })); @@ -1241,9 +1322,9 @@ function deauthPanel(ssidRef) { const clTable = h('div', {}); let lastLookup = ''; function lookup(q) { - if (!q || q === lastLookup) return; + if (!q || q === lastLookup) return Promise.resolve(); lastLookup = q; - PagerAPI.get('/api/attacks/clients?ssid=' + encodeURIComponent(q)).then((r) => { + return PagerAPI.get('/api/attacks/clients?ssid=' + encodeURIComponent(q)).then((r) => { const d = r.data || {}; apSel.innerHTML = ''; (d.aps || []).forEach((a) => apSel.appendChild(h('option', { @@ -1264,19 +1345,27 @@ function deauthPanel(ssidRef) { cl.map((c) => { const mac = c.mac || c.client_mac || ''; return { mac: mac || '—', freq: c.freq || '', signal: c.signal || '', - packets: c.packets || '', _deauth: btn('Deauth', () => { - const parts = apSel.value.split('|'); - if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; } - PagerAPI.post('/api/attacks/deauth', { - bssid: parts[0], client: mac, channel: parseInt(parts[1], 10) - }).then(() => App.toast('Deauth frames sent')) - .catch((e) => App.toast(e.message || 'Deauth failed', 'error')); - }, 'danger') }; + packets: c.packets || '', _deauth: (function () { + const deauthBtn = btn('Deauth', () => { + const parts = apSel.value.split('|'); + if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; } + runAction(deauthBtn, () => PagerAPI.post('/api/attacks/deauth', { + bssid: parts[0], client: mac, channel: parseInt(parts[1], 10) + }).then(() => App.toast('Deauth frames sent')), 'Sending…'); + }, 'danger'); + return deauthBtn; + })() }; }))); }).catch(() => App.toast('Lookup failed', 'error')); } body.appendChild(h('div', { class: 'row' }, ssidBox, - h('div', {}, btn('Find', () => lookup(ssidBox.value.trim()))))); + h('div', {}, (function () { + const findBtn = btn('Find', () => { + lastLookup = ''; + runAction(findBtn, () => lookup(ssidBox.value.trim()), 'Finding…'); + }); + return findBtn; + })()))); body.appendChild(apSel); body.appendChild(clTable); body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px', @@ -1297,8 +1386,7 @@ function deauthPanel(ssidRef) { views.pineap_enterprise = (root) => { const box = pineapShell(root, '#/pineap/enterprise'); const form = h('div', { class: 'pineap-title-card' }); - form.appendChild(h('div', { class: 'pineap-card-title' }, - 'Evil Enterprise — WPA2/3-Enterprise with PineAPE credential harvest')); + form.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Enterprise')); box.appendChild(form); const f = h('div', {}); form.appendChild(f); @@ -1306,62 +1394,138 @@ views.pineap_enterprise = (root) => { const encSel = h('select', { id: 'ent-enc' }); [['wpa2', 'WPA2 Enterprise'], ['wpa3', 'WPA3 Enterprise']] .forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); + const methodSel = h('select', { id: 'ent-method' }); + [['any', 'Any (MSCHAPv2 + GTC)'], ['mschapv2', 'MSCHAPv2 hashes'], ['gtc', 'GTC (plaintext)']] + .forEach(([v, l]) => methodSel.appendChild(h('option', { value: v, text: l }))); const pskIn = h('input', { id: 'ent-pass', type: 'password', autocomplete: 'new-password' }); const hiddenCb = h('input', { type: 'checkbox', id: 'ent-hidden' }); const chanSel = h('select', { id: 'ent-channel' }); chanSelect(chanSel, null); f.appendChild(h('label', {}, 'SSID', ssidIn)); f.appendChild(h('label', {}, 'Encryption', encSel)); - f.appendChild(h('label', {}, 'Passphrase (EAP server secret)', pskIn)); + f.appendChild(h('label', {}, 'Inner EAP', methodSel)); + f.appendChild(h('label', {}, 'EAP user password (optional \u2014 not a RADIUS shared secret)', pskIn)); f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); - f.appendChild(h('label', {}, 'Channel (5 GHz only — Auto uses the target SSID\u2019s recon channel)', chanSel)); + f.appendChild(h('label', {}, 'Channel (5 GHz only \u2014 Auto uses the target SSID\u2019s recon channel)', chanSel)); f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, - h('div', {}, btn('Deploy Attack', () => { - PagerAPI.post('/api/attacks/deploy', { - kind: 'enterprise', ssid: ssidIn.value.trim(), - enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked, - channel: chanSel.value ? parseInt(chanSel.value, 10) : null - }).then((r) => { verifiedToast(r.data || {}); load(); }) - .catch((e) => App.toast(e.message || 'Deploy failed', 'error')); - })), - h('div', {}, btn('Stop Attack', () => { - PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' }) - .then(() => { App.toast('Attack stopped'); load(); }) - .catch(() => App.toast('Stop failed', 'error')); - }, 'danger')))); + h('div', {}, (function () { + const deployBtn = btn('Deploy Attack', () => { + runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', { + kind: 'enterprise', ssid: ssidIn.value.trim(), + enctype: encSel.value, passphrase: pskIn.value, + hidden: hiddenCb.checked, auth_method: methodSel.value, + channel: chanSel.value ? parseInt(chanSel.value, 10) : null + }).then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…'); + }); + return deployBtn; + })()), + h('div', {}, (function () { + const stopBtn = btn('Stop Attack', () => { + runAction(stopBtn, () => PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' }) + .then(() => { App.toast('Attack stopped'); load(); }), 'Stopping…'); + }, 'danger'); + return stopBtn; + })()))); const status = attackStatusCard(); box.appendChild(status.card); - const pb = h('div', { class: 'pineap-title-card' }); - pb.appendChild(h('div', { class: 'pineap-card-title' }, 'Playbook')); - const pbHint = h('div', { style: 'font-size:13px;line-height:1.8', text: - '1. Deploy the enterprise twin (5 GHz). 2. When a client attempts 802.1X, its identity and MSCHAPv2 challenge/response land in the tables below. 3. Crack offline: hashcat -m 5500 on the challenge data.' }); - pb.appendChild(pbHint); - box.appendChild(pb); - const pApe = h('div', { class: 'pineap-title-card' }); pApe.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAPE')); const pApeBody = h('div', {}); pApe.appendChild(pApeBody); box.appendChild(pApe); const authCb = h('input', { type: 'checkbox', id: 'ent-auth' }); - authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }) - .then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })); + authCb.addEventListener('change', () => { + if (authCb.disabled) return; + authCb.disabled = true; + PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }) + .then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); }) + .finally(() => { authCb.disabled = false; }); + }); - const tables = [['Basic Data', '/api/pineap/enterprise/basic', 'basic'], - ['Challenge Data', '/api/pineap/enterprise/challenge', 'challenge']] - .map(([name, endpoint, tableKey]) => { - const tb = h('div', { class: 'pineap-title-card' }); - const body = h('div', {}); - tb.appendChild(h('div', { class: 'pineap-card-title-flex' }, - h('span', { text: name }), - h('span', { class: 'toolbar-spacer' }), - btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: tableKey }).then(load), 'danger'))); - tb.appendChild(body); - box.appendChild(tb); - return { body, endpoint }; - }); + const radiusCard = h('div', { class: 'pineap-title-card' }); + radiusCard.appendChild(h('div', { class: 'pineap-card-title-flex' }, + h('span', { text: 'RADIUS / EAP captures' }), + h('span', { class: 'toolbar-spacer' }), + btn('Export hashcat -m 5500', () => { + return PagerAPI.get('/api/pineap/enterprise/export/hashcat').then((r) => { + downloadText('pineape-mschapv2.5500', typeof r.data === 'string' ? r.data : ''); + App.toast('Exported hashcat -m 5500'); + }); + }, 'ghost'), + btn('Export john', () => { + return PagerAPI.get('/api/pineap/enterprise/export/john').then((r) => { + downloadText('pineape-mschapv2.john', typeof r.data === 'string' ? r.data : ''); + App.toast('Exported john netntlm'); + }); + }, 'ghost'), + (function () { + const clearAll = btn('Clear all', () => { + if (!confirm('Clear all enterprise captures?')) return; + runAction(clearAll, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'all' }).then(load), 'Clearing…'); + }, 'danger'); + return clearAll; + })())); + const radiusNote = h('div', { class: 'muted', style: 'font-size:12px;margin-bottom:8px' }); + const radiusBody = h('div', {}); + radiusCard.appendChild(radiusNote); + radiusCard.appendChild(radiusBody); + box.appendChild(radiusCard); + + const identCard = h('div', { class: 'pineap-title-card' }); + identCard.appendChild(h('div', { class: 'pineap-card-title-flex' }, + h('span', { text: 'EAP identities' }), + h('span', { class: 'toolbar-spacer' }), + (function () { + const clearIdent = btn('Clear', () => { + if (!confirm('Clear EAP identities?')) return; + runAction(clearIdent, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'basic' }).then(load), 'Clearing…'); + }, 'danger'); + return clearIdent; + })())); + const identBody = h('div', {}); + identCard.appendChild(identBody); + box.appendChild(identCard); + + const mschapCard = h('div', { class: 'pineap-title-card' }); + mschapCard.appendChild(h('div', { class: 'pineap-card-title-flex' }, + h('span', { text: 'MSCHAPv2 / RADIUS inner auth' }), + h('span', { class: 'toolbar-spacer' }), + (function () { + const clearMschap = btn('Clear', () => { + if (!confirm('Clear MSCHAPv2 captures?')) return; + runAction(clearMschap, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'challenge' }).then(load), 'Clearing…'); + }, 'danger'); + return clearMschap; + })())); + const mschapBody = h('div', {}); + mschapCard.appendChild(mschapBody); + box.appendChild(mschapCard); + + const clientCard = h('div', { class: 'pineap-title-card' }); + clientCard.appendChild(h('div', { class: 'pineap-card-title' }, 'Associated enterprise clients')); + const clientBody = h('div', {}); + clientCard.appendChild(clientBody); + box.appendChild(clientCard); + + const logCard = h('div', { class: 'pineap-title-card' }); + logCard.appendChild(h('div', { class: 'pineap-card-title' }, 'hostapd / PineAPE log')); + const logBody = h('pre', { class: 'muted', style: 'font-size:11px;white-space:pre-wrap;max-height:240px;overflow:auto' }); + logCard.appendChild(logBody); + box.appendChild(logCard); + + const hashCol = { + label: 'hashcat -m 5500', + key: 'hashcat', + render: (r) => { + const val = r.hashcat || r.hashcat || ''; + const shown = val.length > 42 ? val.slice(0, 38) + '\u2026' : val; + return h('div', { class: 'row' }, + h('span', { text: shown || '\u2014', title: val }), + val ? btn('Copy', () => copyText(val, 'Hash copied'), 'ghost') : null); + } + }; function load() { PagerAPI.get('/api/attacks/status').then((r) => { @@ -1370,28 +1534,93 @@ views.pineap_enterprise = (root) => { const ap = ent.ap || null; status.rows.innerHTML = ''; status.append('Attack', attackBadge(ap)); - status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '—' })); - status.append('Interface', h('span', { text: ap ? ap.iface : '—' })); + status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '\u2014' })); + status.append('Interface', h('span', { text: ap ? ap.iface : '\u2014' })); status.append('Band', h('span', { text: ap && ap.band ? ap.band + ' GHz' : '5 GHz' })); - status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '—' })); + status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '\u2014' })); + status.append('Identities', h('span', { text: String(ent.identities != null ? ent.identities : 0) })); + status.append('MSCHAPv2', h('span', { text: String(ent.mschapv2 != null ? ent.mschapv2 : 0) })); status.append('Credentials', h('span', { text: String(ent.creds || 0) })); + const stations = ent.stations || (ap && ap.stations) || []; + status.append('Stations', h('span', { text: stations.length ? stations.join(', ') : '\u2014' })); + status.append('Inner EAP', h('span', { text: ent.auth_method || (ap && ap.auth_method) || '\u2014' })); + status.append('TLS certs', h('span', { text: (ap && ap.certs) || ent.certs ? 'installed' : '\u2014' })); + status.append('Log captures', h('span', { text: String((ap && ap.captures != null) ? ap.captures : (ent.captures != null ? ent.captures : 0)) })); pApeBody.innerHTML = ''; + authCb.checked = !!(ent.pineape && ent.pineape.enabled) || !!(ap && ap.live); pApeBody.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture')); if (ent.pineape) { pApeBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', - text: 'PineAPE ' + (ent.pineape.enabled ? 'enabled' : 'disabled') })); + text: 'PineAPE ' + (ent.pineape.enabled ? 'enabled' : 'disabled') + + ((ap && ap.ctrl_linked) || ent.ctrl_linked ? ' · pineapd linked' : '') })); } - }).catch(() => {}); - tables.forEach((t) => { - PagerAPI.get(t.endpoint).then((r) => { - const rows = (r.data.rows || []).slice(); - t.body.innerHTML = ''; - const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k })) - : [{ label: '—', key: '_none' }]; - t.body.appendChild(table(cols, rows)); - if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' })); - }).catch(() => {}); + }).catch((e) => { status.rows.innerHTML = ''; status.append('Status', h('span', { text: e.message || 'Failed to load' })); }); + PagerAPI.get('/api/pineap/enterprise/radius').then((r) => { + const d = r.data || {}; + radiusNote.textContent = d.note || d.note || ''; + const rows = (d.captures || []).slice(); + radiusBody.innerHTML = ''; + radiusBody.appendChild(table([ + { label: 'When', key: 'time', render: (x) => fmtTime(x.time) }, + { label: 'Kind', key: 'kind' }, + { label: 'Username', key: 'username' }, + { label: 'Password', key: 'password' }, + { label: 'Challenge', key: 'challenge' }, + { label: 'Response', key: 'response' }, + { label: 'Source', key: 'source' }, + hashCol + ], rows)); + if (!rows.length) radiusBody.appendChild(h('div', { class: 'empty', text: 'No EAP/RADIUS captures yet. Accept the client certificate warning, then retry login.' })); + const liveMacs = d.stations || []; + const byMac = {}; + (d.clients || []).forEach((c) => { if (c && c.mac) byMac[String(c.mac).toUpperCase()] = c; }); + liveMacs.forEach((mac) => { + const key = String(mac || '').toUpperCase(); + if (!key) return; + if (!byMac[key]) byMac[key] = { mac: key, ssid: '', source: 'hostapd' }; + }); + const clients = Object.keys(byMac).map((k) => byMac[k]); + clientBody.innerHTML = ''; + clientBody.appendChild(table([ + { label: 'MAC', key: 'mac' }, + { label: 'SSID', key: 'ssid' }, + { label: 'Source', key: 'source' }, + { label: 'Connected', key: 'connected_time', render: (x) => fmtTime(x.connected_time || x.connected_time) }, + { label: 'Disconnected', key: 'disconnected_time', render: (x) => fmtTime(x.disconnected_time || x.disconnected_time) } + ], clients)); + if (!clients.length) clientBody.appendChild(h('div', { class: 'empty', text: 'No associated enterprise clients yet.' })); + }).catch((e) => { + radiusBody.innerHTML = ''; + radiusBody.appendChild(h('div', { class: 'empty', text: 'Capture list failed: ' + (e.message || 'error') })); }); + PagerAPI.get('/api/pineap/enterprise/log').then((r) => { + const lines = ((r.data || {}).lines || []); + logBody.textContent = lines.length ? lines.join('\n') : '(no hostapd log yet)'; + }).catch((e) => { logBody.textContent = e.message || 'log unavailable'; }); + PagerAPI.get('/api/pineap/enterprise/basic').then((r) => { + const rows = (r.data.rows || []).slice(); + identBody.innerHTML = ''; + identBody.appendChild(table([ + { label: 'When', key: 'time', render: (x) => fmtTime(x.time) }, + { label: 'Identity', key: 'identity' }, + { label: 'Password', key: 'password' }, + { label: 'Type', key: 'type' }, + { label: 'Verified', key: 'verified' } + ], rows)); + if (!rows.length) identBody.appendChild(h('div', { class: 'empty', text: 'No identities captured.' })); + }).catch(() => {}); + PagerAPI.get('/api/pineap/enterprise/challenge').then((r) => { + const rows = (r.data.rows || []).slice(); + mschapBody.innerHTML = ''; + mschapBody.appendChild(table([ + { label: 'When', key: 'time', render: (x) => fmtTime(x.time) }, + { label: 'Username', key: 'username' }, + { label: 'Challenge', key: 'challenge' }, + { label: 'Response', key: 'response' }, + hashCol + ], rows)); + if (!rows.length) mschapBody.appendChild(h('div', { class: 'empty', text: 'No MSCHAPv2 captures yet.' })); + }).catch(() => {}); } load(); const iv = setInterval(load, 5000); @@ -1462,6 +1691,7 @@ views.recon = (root) => { compare: [], history: {}, mapBand: null, archive: null, archives: [], scanRemaining: null, hopperOnline: null, historyReset: false, scanErr: null, + hopperWarning: null, hopperHint: null, hopperIfaces: [], wlan0Pinned: false, wlan0Sta: false }; const cols = reconLoadCols(); @@ -1521,13 +1751,17 @@ views.recon = (root) => { const hsAuto = h('label', { class: 'recon-toggle' }, h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Auto-collect handshakes'); hsAuto.querySelector('input').addEventListener('change', () => { - const requested = hsAuto.querySelector('input').checked; + const cb = hsAuto.querySelector('input'); + if (cb.disabled) return; + const requested = cb.checked; + cb.disabled = true; PagerAPI.post('/api/pineap/set_config', { loghandshake: requested }) .then(() => App.toast('Settings saved')) .catch(() => { - hsAuto.querySelector('input').checked = !requested; + cb.checked = !requested; App.toast('Failed to save', 'error'); - }); + }) + .finally(() => { cb.disabled = false; }); }); hsBody.appendChild(hsAuto); @@ -1573,19 +1807,17 @@ views.recon = (root) => { const delBtn = iconBtn('delete', 'Delete scan', () => { if (state.selected == null || state.archive) return; if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return; - PagerAPI.del('/api/recon/scans/' + state.selected) - .then(() => { App.toast('Scan deleted'); load(); }) - .catch(() => App.toast('Delete failed', 'error')); + return PagerAPI.del('/api/recon/scans/' + state.selected) + .then(() => { App.toast('Scan deleted'); load(); }); }); const delAllBtn = iconBtn('delete_forever', 'Delete all scans', () => { if (!confirm('Delete ALL recorded scans? This cannot be undone.')) return; - PagerAPI.del('/api/recon/scans') + return PagerAPI.del('/api/recon/scans') .then((r) => { App.toast('All scans deleted' + (r.data && r.data.deleted ? ' (' + r.data.deleted + ')' : '')); state.selected = null; state.archive = null; load(); - }) - .catch(() => App.toast('Delete failed', 'error')); + }); }); psActions.appendChild(dlJson); psActions.appendChild(dlCsv); @@ -1632,22 +1864,27 @@ views.recon = (root) => { wiglePill.textContent = state.wigle ? 'WiGLE on' : 'WiGLE off'; } gpsPill.addEventListener('click', () => { + if (gpsPill.disabled) return; + gpsPill.disabled = true; + gpsPill.classList.add('busy'); PagerAPI.post('/api/recon/gps/configure', {}).then((r) => { state.gps = r.data; renderPills(); if (r.data.error) App.toast(r.data.error, 'error'); else if (r.data.lock) App.toast('GPS locked: ' + Number(r.data.lat).toFixed(5) + ', ' + Number(r.data.lon).toFixed(5)); else App.toast((r.data.note || 'GPS bound, waiting for a fix')); - }).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error')); + }).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error')) + .finally(() => { gpsPill.disabled = false; gpsPill.classList.remove('busy'); }); }); wiglePill.addEventListener('click', () => { const next = !state.wigle; wiglePill.disabled = true; + wiglePill.classList.add('busy'); PagerAPI.post('/api/recon/wigle', { enable: next }).then((r) => { state.wigle = next; App.toast(next ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped'); }).catch((err) => App.toast((err && err.message) || 'WiGLE toggle failed', 'error')) - .finally(() => { wiglePill.disabled = false; renderPills(); }); + .finally(() => { wiglePill.disabled = false; wiglePill.classList.remove('busy'); renderPills(); }); }); let pendingScan = false; function renderScanBar() { @@ -1660,11 +1897,15 @@ 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.wlan0Sta) bits.push('2.4GHz starved — client STA (wlan0) pins phy0'); + const hop24 = (state.hopperIfaces || []).indexOf('wlan0mon') >= 0; + if (state.hopperWarning) bits.push(state.hopperWarning); + else { + if (state.wlan0Pinned && !hop24) bits.push('2.4GHz under-sampled — OpenAP/Evil WPA holds wlan0mon'); + if (state.wlan0Sta && !hop24) bits.push('2.4GHz starved — client STA (wlan0) pins phy0'); + } 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.scanErr); + scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || (!hop24 && (state.wlan0Pinned || state.wlan0Sta)) || !!state.scanErr || !!state.hopperWarning); } scanToggle.addEventListener('change', () => { if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } @@ -1672,35 +1913,51 @@ views.recon = (root) => { pendingScan = true; scanToggle.disabled = true; PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {}) - .then(() => { + .then((r) => { + const data = (r && r.data) || {}; if (on) { state.scanActive = true; state.autoFollow = true; state.apPage = 0; state.clientPage = 0; - App.toast('Scan started'); + state.hopperWarning = data.warning || null; + state.hopperHint = data.hint || null; + state.hopperIfaces = data.hopping || []; + if (data.warning) App.toast(data.warning, 'error'); + else App.toast('Scan started'); } else { state.scanActive = false; state.autoFollow = false; + state.hopperWarning = null; + state.hopperHint = null; + state.hopperIfaces = []; App.toast('Scan stopped'); } + renderScanBar(); restartPoll(); load(); }) .catch((err) => { + const detail = err && err.data || {}; if (err && err.status === 409) { - // A native timed scan is already running; keep the toggle on and - // show the remaining time instead of pretending the start failed. + // Timed scans cannot be aborted on this firmware. Keep the toggle + // on and show remaining time instead of looking like a dead click. scanToggle.checked = true; state.scanActive = true; - state.scanRemaining = (err.data && err.data.scan_remaining) != null - ? err.data.scan_remaining : null; + state.scanRemaining = detail.scan_remaining != null + ? detail.scan_remaining : null; renderScanBar(); - App.toast('Scan already running — it will finish automatically' + - (state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : ''), 'error'); + const left = state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : ''; + App.toast(on + ? ('Scan already running — it will finish automatically' + left) + : ('Pager cannot abort a timed scan — it will finish automatically' + left)); } else { scanToggle.checked = !on; - App.toast((err && err.message) || 'Recon control failed', 'error'); + const hint = detail.hint || detail.detail; + const msg = (err && err.message) || 'Recon control failed'; + state.scanErr = hint || msg; + renderScanBar(); + App.toast(hint ? msg + ' — ' + hint : msg, 'error'); } }) .finally(() => { pendingScan = false; scanToggle.disabled = state.scanActive; }); @@ -1763,47 +2020,31 @@ views.recon = (root) => { }); actions.appendChild(twin); const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' }); - capture.addEventListener('click', () => { - capture.disabled = true; - PagerAPI.post('/api/pineap/set_config', { loghandshake: true }) - .then(() => { - App.toast('Handshake capture enabled (device-wide on Pager)'); - if (hsAuto) hsAuto.querySelector('input').checked = true; - }) - .catch(() => App.toast('Failed to enable handshake capture', 'error')) - .finally(() => { capture.disabled = false; }); - }); + capture.addEventListener('click', () => runAction(capture, () => PagerAPI.post('/api/pineap/set_config', { loghandshake: true }) + .then(() => { + App.toast('Handshake capture enabled (device-wide on Pager)'); + if (hsAuto) hsAuto.querySelector('input').checked = true; + }), 'Enabling…')); actions.appendChild(capture); const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' }); - stopHs.addEventListener('click', () => { - stopHs.disabled = true; - PagerAPI.post('/api/pineap/set_config', { loghandshake: false }) - .then(() => { - App.toast('Handshake capture disabled'); - if (hsAuto) hsAuto.querySelector('input').checked = false; - }) - .catch(() => App.toast('Failed to disable handshake capture', 'error')) - .finally(() => { stopHs.disabled = false; }); - }); + stopHs.addEventListener('click', () => runAction(stopHs, () => PagerAPI.post('/api/pineap/set_config', { loghandshake: false }) + .then(() => { + App.toast('Handshake capture disabled'); + if (hsAuto) hsAuto.querySelector('input').checked = false; + }), 'Stopping…')); actions.appendChild(stopHs); const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' }); exB.addEventListener('click', () => { if (!ap.bssid) return; - exB.disabled = true; - PagerAPI.post('/api/recon/examine', { bssid: ap.bssid }) - .then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen')) - .catch(() => App.toast('Examine failed', 'error')) - .finally(() => { exB.disabled = false; }); + runAction(exB, () => PagerAPI.post('/api/recon/examine', { bssid: ap.bssid, seconds: 30 }) + .then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen')), 'Examining…'); }); actions.appendChild(exB); const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' }); exC.addEventListener('click', () => { if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; } - exC.disabled = true; - PagerAPI.post('/api/recon/examine', { channel: ap.channel }) - .then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')) - .catch(() => App.toast('Examine failed', 'error')) - .finally(() => { exC.disabled = false; }); + runAction(exC, () => PagerAPI.post('/api/recon/examine', { channel: ap.channel, seconds: 30 }) + .then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…'); }); actions.appendChild(exC); @@ -2528,6 +2769,10 @@ views.recon = (root) => { state.historyReset = !!r.data.history_reset; state.wlan0Pinned = !!r.data.wlan0_pinned; state.wlan0Sta = !!r.data.wlan0_sta; + state.hopperIfaces = r.data.hopper_ifaces || []; + if (r.data.hopper_warning) state.hopperWarning = r.data.hopper_warning; + else if (!scanning) state.hopperWarning = null; + state.hopperHint = r.data.hopper_hint || state.hopperHint; if (!pendingScan) scanToggle.checked = scanning; renderScanBar(); if (wasScanning !== scanning) restartPoll(); @@ -2676,8 +2921,9 @@ views.recon_handshakes = (root) => { } function hsIconBtn(name, title, cls, onclk) { - const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk }); + const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title }); b.innerHTML = PineappleIcons[name] || ''; + armBusy(b, onclk); return b; } @@ -2702,7 +2948,7 @@ views.recon_handshakes = (root) => { } function load(done) { - PagerAPI.get('/api/pineap/handshakes').then((r) => { + return PagerAPI.get('/api/pineap/handshakes').then((r) => { box.innerHTML = ''; const head = h('div', { class: 'recon-table-head' }, h('h2', { text: 'Captured WPA Handshakes' }), @@ -2711,7 +2957,12 @@ views.recon_handshakes = (root) => { box.appendChild(head); box.appendChild(h('div', { class: 'row' }, h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })), - h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))), + h('div', {}, (function () { + const archiveBtn = btn('Archive', () => { + return runAction(archiveBtn, () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')), 'Archiving…'); + }); + return archiveBtn; + })()), h('div', {}, btn('Refresh', () => load(), 'ghost')))); const hs = r.data.handshakes || []; @@ -2744,9 +2995,8 @@ views.recon_handshakes = (root) => { window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name); }), hsIconBtn('delete', 'Delete', 'hs-warn', () => { - PagerAPI.del('/api/pineap/handshakes', { name: f.name }) - .then(() => load(() => flash(true, 'Deleted ' + f.name))) - .catch(() => flash(false, 'Failed to delete ' + f.name)); + return PagerAPI.del('/api/pineap/handshakes', { name: f.name }) + .then(() => load(() => flash(true, 'Deleted ' + f.name))); }))); trr.appendChild(act); tb.appendChild(trr); @@ -2761,7 +3011,7 @@ views.recon_handshakes = (root) => { } function openSettings() { - PagerAPI.get('/api/pineap/handshakes/location').then((r) => { + return PagerAPI.get('/api/pineap/handshakes/location').then((r) => { const loc = (r.data || {}).location || '--'; const overlay = h('div', { class: 'modal-overlay' }); function close() { overlay.remove(); } @@ -2772,11 +3022,13 @@ views.recon_handshakes = (root) => { h('div', { class: 'hs-settings-row' }, h('span', { class: 'hs-settings-label', text: 'Handshake Location' }), h('span', { class: 'hs-settings-value', text: loc })), - btn('Delete All Handshakes', () => { - PagerAPI.del('/api/pineap/handshakes/all') - .then(() => { close(); load(() => flash(true, 'All handshakes deleted')); }) - .catch(() => { close(); flash(false, 'Failed to delete all handshakes'); }); - }, 'danger')), + (function () { + const delAll = btn('Delete All Handshakes', () => { + return runAction(delAll, () => PagerAPI.del('/api/pineap/handshakes/all') + .then(() => { close(); load(() => flash(true, 'All handshakes deleted')); }), 'Deleting…'); + }, 'danger'); + return delAll; + })()), h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost'))); overlay.appendChild(modal); document.body.appendChild(overlay); @@ -2809,12 +3061,15 @@ views.logging = (root) => { search.addEventListener('input', render); let pending = false; function load() { - if (pending) return; + if (pending) return Promise.resolve(); pending = true; - PagerAPI.get('/api/logging/pineap?lines=200').then((r) => { + return PagerAPI.get('/api/logging/pineap?lines=200').then((r) => { lines.splice(0, lines.length, ...((r.data || {}).lines || [])); render(); - }).catch(() => { App.toast('Failed to load PineAP log', 'error'); }) + }).catch((e) => { + lines.splice(0, lines.length, e.message || 'PineAP log unavailable'); + render(); + }) .finally(() => { pending = false; }); } load(); @@ -2844,12 +3099,15 @@ views.logging_system = (root) => { search.addEventListener('input', render); let pending = false; function load() { - if (pending) return; + if (pending) return Promise.resolve(); pending = true; - PagerAPI.get('/api/logging/system?lines=400').then((r) => { + return PagerAPI.get('/api/logging/system?lines=400').then((r) => { lines.splice(0, lines.length, ...((r.data || {}).lines || [])); render(); - }).catch(() => { App.toast('Failed to load system log', 'error'); }) + }).catch((e) => { + lines.splice(0, lines.length, e.message || 'System log unavailable'); + render(); + }) .finally(() => { pending = false; }); } load(); @@ -2911,9 +3169,7 @@ function payloadCard(item, actions, tags) { } function setPayloadBusy(button, busy, label) { - button.disabled = busy; - button.textContent = busy ? (label || button.dataset.label || button.textContent) - : (button.dataset.label || button.textContent); + markBusy(button, busy, label); } views.modules = (root) => { @@ -2941,13 +3197,10 @@ views.modules = (root) => { return; } visible.forEach((item) => { - const launch = btn('Run', () => { - setPayloadBusy(launch, true, 'Starting...'); - PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => { + const launch = btn('Run', () => runAction(launch, () => PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => { App.toast(item.title + ' started'); location.hash = '#/modules/running'; - }).catch(apiError('Unable to start payload')).finally(() => setPayloadBusy(launch, false)); - }); + }), 'Starting...')); launch.dataset.label = 'Run'; if (item.disabled || item.key === 'user~remote_access~pager-webui') { launch.disabled = true; @@ -2957,10 +3210,9 @@ views.modules = (root) => { if (update) update.dataset.label = 'Update'; const remove = btn('Remove', () => { if (!confirm('Remove "' + item.title + '" from this Pager?')) return; - setPayloadBusy(remove, true, 'Removing...'); - PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => { + return runAction(remove, () => PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => { App.toast(item.title + ' removed'); load(); - }).catch(apiError('Unable to remove payload')).finally(() => setPayloadBusy(remove, false)); + }), 'Removing...'); }, 'danger'); remove.dataset.label = 'Remove'; if (item.key === 'user~remote_access~pager-webui') { @@ -2974,21 +3226,19 @@ views.modules = (root) => { } function installPayload(item, button) { - setPayloadBusy(button, true, 'Updating...'); - PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { + return runAction(button, () => PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { App.toast(item.title + ' updated'); load(); - }).catch(apiError('Unable to update payload')).finally(() => setPayloadBusy(button, false)); + }), 'Updating...'); } function load() { - refresh.disabled = true; - PagerAPI.get('/api/payloads/installed').then((r) => { + return PagerAPI.get('/api/payloads/installed').then((r) => { if (!alive) return; rows = (r.data && r.data.payloads) || []; filters.category.innerHTML = ''; fillPayloadCategories(filters.category, rows, 'category'); render(); - }).catch(apiError('Unable to load installed payloads')).finally(() => { refresh.disabled = false; }); + }).catch(apiError('Unable to load installed payloads')); } load(); return { destroy: () => { alive = false; } }; @@ -3002,10 +3252,9 @@ views.modules_online = (root) => { const status = h('p', { class: 'payload-result-count muted' }); const filters = payloadFilters(render); const refresh = btn('Get Available Payloads', () => { - refresh.disabled = true; - PagerAPI.post('/api/payloads/refresh').then(() => { + return PagerAPI.post('/api/payloads/refresh').then(() => { App.toast('Pager Portal listings refreshed'); return load(); - }).catch(apiError('Unable to refresh Pager Portal')).finally(() => { refresh.disabled = false; }); + }); }); box.appendChild(h('div', { class: 'section' }, payloadToolbar('Available Payloads', 'Browse community payloads from the Pager Portal and install them directly on this Pager.', refresh), @@ -3029,11 +3278,9 @@ views.modules_online = (root) => { const isInstalled = installed.has(item.key); const install = btn(isInstalled ? 'Installed' : 'Install', () => { if (installed.has(item.key)) return; - install.dataset.label = 'Install'; - setPayloadBusy(install, true, 'Installing...'); - PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { + return runAction(install, () => PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { installed.add(item.key); App.toast(item.title + ' installed'); render(); - }).catch(apiError('Unable to install payload')).finally(() => setPayloadBusy(install, false)); + }), 'Installing...'); }, isInstalled ? 'ghost' : ''); install.dataset.label = isInstalled ? 'Installed' : 'Install'; install.disabled = isInstalled; @@ -3076,12 +3323,9 @@ views.modules_running = (root) => { text: run.running ? 'Running' : (run.returncode === 0 ? 'Completed' : 'Exited ' + run.returncode) }); const actions = h('div', { class: 'payload-actions' }); if (run.running) { - const stop = btn('Stop', () => { - stop.disabled = true; - PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => { + const stop = btn('Stop', () => PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => { App.toast(run.title + ' stop requested'); setTimeout(load, 500); - }).catch(apiError('Unable to stop payload')).finally(() => { stop.disabled = false; }); - }, 'danger'); + }), 'danger'); actions.appendChild(stop); } const output = h('pre', { class: 'payload-output', text: run.output || 'No output yet.' }); @@ -3094,10 +3338,10 @@ views.modules_running = (root) => { } function load() { - if (pending) return; - pending = true; refresh.disabled = true; - PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || [])) - .catch(apiError('Unable to load payload runs')).finally(() => { pending = false; refresh.disabled = false; }); + if (pending) return Promise.resolve(); + pending = true; + return PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || [])) + .catch(apiError('Unable to load payload runs')).finally(() => { pending = false; }); } load(); const iv = setInterval(load, 3000); @@ -3175,6 +3419,8 @@ function clientModePanel(box, options) { App.toast('Client mode must be enabled before routing can be changed', 'error'); return; } + if (routingSwitch.disabled) return; + routingSwitch.disabled = true; PagerAPI.post('/api/settings/wifi/client/route', { routed: routingSwitch.checked }) .then(() => { if (state.status) state.status.routed = routingSwitch.checked; @@ -3184,7 +3430,8 @@ function clientModePanel(box, options) { .catch((e) => { routingSwitch.checked = !routingSwitch.checked; App.toast(e.message || 'Failed to update routing', 'error'); - }); + }) + .finally(() => { routingSwitch.disabled = false; }); }); function renderStatus() { @@ -3207,7 +3454,7 @@ function clientModePanel(box, options) { } function refresh() { - PagerAPI.get('/api/settings/wifi/client').then((r) => { + return PagerAPI.get('/api/settings/wifi/client').then((r) => { state.status = r.data || {}; renderStatus(); }).catch(apiError('Failed to load client mode status')); @@ -3243,18 +3490,24 @@ function clientModePanel(box, options) { const routedCb = h('input', { type: 'checkbox' }); const routedLabel = h('label', { class: 'switch client-routing' }, routedCb, h('span', { class: 'track' }), 'Route LAN clients'); + const connectBtn = btn('Connect', () => { + doConnect({ ssid: net.ssid, encryption: encSel.value, + password: pw.value, routed: routedCb.checked }, connectBtn); + }, 'primary'); const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel, - btn('Connect', () => doConnect({ ssid: net.ssid, encryption: encSel.value, - password: pw.value, routed: routedCb.checked }), 'primary'), + connectBtn, btn('Cancel', () => renderNetworks(), 'ghost')); row.appendChild(form); pw.focus(); } - function doConnect(opts) { + let connectingButton = null; + function doConnect(opts, button) { if (state.connecting) return; state.connecting = true; + connectingButton = button || null; setBusy(true); + if (connectingButton) markBusy(connectingButton, true, 'Connecting…'); PagerAPI.post('/api/settings/wifi/client/connect', opts) .then(() => { App.toast('Connecting to ' + opts.ssid + '\u2026'); @@ -3262,13 +3515,21 @@ function clientModePanel(box, options) { pollConnected(opts.ssid); }) .catch((e) => { - state.connecting = false; - setBusy(false); + clearConnecting(); App.toast(e.message || 'Failed to connect', 'error'); refresh(); }); } + function clearConnecting() { + state.connecting = false; + setBusy(false); + if (connectingButton) { + markBusy(connectingButton, false); + connectingButton = null; + } + } + function pollConnected(targetSsid) { const gen = ++state.generation; let attempts = 0; @@ -3291,14 +3552,12 @@ function clientModePanel(box, options) { finish('Connected to ' + (s.connected_ssid || targetSsid)); } }).catch(() => { - state.connecting = false; - setBusy(false); + clearConnecting(); App.toast('Failed to check connection status', 'error'); }); }; function finish(message, kind) { - state.connecting = false; - setBusy(false); + clearConnecting(); App.toast(message, kind); App.checkInternet(false); } @@ -3306,32 +3565,43 @@ function clientModePanel(box, options) { } function doDisconnect() { - if (state.connecting) return; - PagerAPI.post('/api/settings/wifi/client/disconnect') + if (state.connecting) return Promise.resolve(); + state.connecting = true; + setBusy(true); + return PagerAPI.post('/api/settings/wifi/client/disconnect') .then(() => { App.toast('WiFi client disabled'); state.networks = []; refresh(); App.checkInternet(false); }) - .catch((e) => App.toast(e.message || 'Failed to disconnect', 'error')); + .catch((e) => App.toast(e.message || 'Failed to disconnect', 'error')) + .finally(() => { state.connecting = false; setBusy(false); }); } + let scanningNets = false; function scan() { - if (state.connecting) return; + if (state.connecting || scanningNets) return Promise.resolve(); + scanningNets = true; + setBusy(true); netsEl.textContent = 'Scanning\u2026'; - PagerAPI.post('/api/settings/wifi/client/scan').then((r) => { + return PagerAPI.post('/api/settings/wifi/client/scan').then((r) => { state.networks = (r.data && r.data.networks) || []; renderNetworks(); }).catch((e) => { netsEl.innerHTML = ''; netsEl.appendChild(h('div', { class: 'empty', text: 'Scan failed: ' + (e.message || 'unknown error') })); - }); + }).finally(() => { scanningNets = false; setBusy(false); }); } function setBusy(busy) { const buttons = actionsEl.querySelectorAll('button'); - buttons.forEach((b) => { b.disabled = busy; }); + buttons.forEach((b) => { + b.disabled = busy; + b.classList.toggle('busy', busy); + if (busy) b.setAttribute('aria-busy', 'true'); + else b.removeAttribute('aria-busy'); + }); } actionsEl.appendChild(btn('Scan for Networks', scan)); @@ -3369,18 +3639,19 @@ views.settings = (root) => { h('label', {}, 'Current Password', currentPw), h('label', {}, 'New Password', newPw), h('label', {}, 'Repeat New Password', repeatPw))); - user.appendChild(btn('Update Password', () => { + const pwBtn = btn('Update Password', () => { if (!currentPw.value) { App.toast('Current password is required', 'error'); return; } if (newPw.value.length < 4) { App.toast('New password is too short', 'error'); return; } if (newPw.value !== repeatPw.value) { App.toast('New passwords do not match', 'error'); return; } - PagerAPI.post('/api/settings/password', { + runAction(pwBtn, () => PagerAPI.post('/api/settings/password', { current_password: currentPw.value, new_password: newPw.value, repeat_password: repeatPw.value }).then(() => { currentPw.value = ''; newPw.value = ''; repeatPw.value = ''; App.toast('Root password updated'); - }).catch(apiError('Failed to update password')); - })); + }), 'Updating…'); + }); + user.appendChild(pwBtn); const timezones = [ ['UTC', '(GMT+0) Coordinated Universal Time', 'Etc/UTC'], @@ -3398,20 +3669,20 @@ views.settings = (root) => { ]; const tzSel = h('select', { 'aria-label': 'Timezone' }); timezones.forEach(([value, label, zone]) => tzSel.appendChild(h('option', { value, text: label, 'data-zone': zone }))); - const tzActions = h('div', { class: 'settings-actions' }, - btn('Update Timezone', () => { - const opt = tzSel.options[tzSel.selectedIndex]; - PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' }) - .then(() => App.toast('Timezone updated')).catch(apiError('Failed to update timezone')); - }), - btn('Sync Browser Time', () => { - const d = new Date(); - const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + - String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' + - String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0'); - PagerAPI.post('/api/settings/synctime', { timestamp: stamp }) - .then(() => App.toast('Pager time synchronized')).catch(apiError('Failed to synchronize time')); - }, 'ghost')); + const tzBtn = btn('Update Timezone', () => { + const opt = tzSel.options[tzSel.selectedIndex]; + runAction(tzBtn, () => PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' }) + .then(() => App.toast('Timezone updated')), 'Updating…'); + }); + const syncBtn = btn('Sync Browser Time', () => { + const d = new Date(); + const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' + + String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0'); + runAction(syncBtn, () => PagerAPI.post('/api/settings/synctime', { timestamp: stamp }) + .then(() => App.toast('Pager time synchronized')), 'Syncing…'); + }, 'ghost'); + const tzActions = h('div', { class: 'settings-actions' }, tzBtn, syncBtn); user.appendChild(h('label', {}, 'Timezone', tzSel)); user.appendChild(tzActions); PagerAPI.get('/api/settings/timezone').then((r) => { @@ -3427,10 +3698,13 @@ views.settings = (root) => { const ntpServers = h('input', {}); ntp.appendChild(h('label', { class: 'switch' }, ntpEnabled, h('span', { class: 'track' }), 'Enabled')); ntp.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers)); - ntp.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', { - enabled: ntpEnabled.checked, - servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) - }).then(() => App.toast('Network time settings saved')).catch(apiError('Failed to save network time')))); + const ntpSave = btn('Save', () => { + runAction(ntpSave, () => PagerAPI.post('/api/settings/ntp', { + enabled: ntpEnabled.checked, + servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) + }).then(() => App.toast('Network time settings saved')), 'Saving…'); + }); + ntp.appendChild(ntpSave); PagerAPI.get('/api/settings/ntp').then((r) => { ntpEnabled.checked = !!r.data.enabled; ntpServers.value = (r.data.servers || []).join(', '); @@ -3447,6 +3721,30 @@ views.settings = (root) => { .forEach(([k, v]) => detailBody.appendChild(h('div', { class: 'settings-kv' }, h('span', { text: k }), h('code', { text: v })))); }).catch(apiError('Unable to load device details')); + const overlay = settingsCard(box, 'Mark VIII overlay', + 'PineAP pool contents stay with the Pager daemon. Radio1 APs, hop pause, and enterprise overlays revert to the Pager snapshot when Mark VIII stops.'); + const overlayStatus = h('p', { class: 'muted', text: 'Loading overlay status…' }); + overlay.appendChild(overlayStatus); + function loadOverlay() { + PagerAPI.get('/api/mode').then((r) => { + const d = r.data || {}; + const ov = d.overlay || {}; + overlayStatus.textContent = (d.snapshot ? 'Pager snapshot is captured. ' : 'No snapshot. ') + + 'Radio1 AP ' + (ov.radio1_ap ? 'ON' : 'off') + + ' · Enterprise ' + (ov.enterprise ? 'ON' : 'off') + + ' · Recon hopper ' + (ov.recon_hopper ? 'ON' : 'off') + + '. Stop the Mark VIII service to make the Pager UI the source of truth.'; + }).catch(apiError('Unable to load overlay status')); + } + const restoreBtn = btn('Restore Pager overlays now', () => { + runAction(restoreBtn, () => PagerAPI.post('/api/mode/release').then(() => { + App.toast('Pager overlays restored'); + loadOverlay(); + }), 'Restoring…'); + }); + overlay.appendChild(restoreBtn); + loadOverlay(); + settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.'); const resources = settingsCard(box, 'Resources'); @@ -3520,11 +3818,9 @@ views.settings_wifi = (root) => { card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Password', password), h('label', {}, 'Confirm Password', confirm))); const save = btn('Save', () => { if (password.value !== confirm.value) { App.toast('Management passwords do not match', 'error'); return; } - save.disabled = true; - PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(), + return PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(), password: password.value, hidden: hidden.checked, enabled: enabled.checked }) - .then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); }) - .catch(apiError('Failed to save management WiFi')).finally(() => { save.disabled = false; }); + .then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); }); }); card.appendChild(save); PagerAPI.get('/api/settings/wifi/management').then((r) => { @@ -3553,8 +3849,11 @@ views.settings_led = (root) => { function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked, lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value), dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; } - card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values()) - .then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences')))); + const ledSave = btn('Save', () => { + runAction(ledSave, () => PagerAPI.post('/api/settings/hardware', values()) + .then(() => App.toast('Pager hardware preferences saved')), 'Saving…'); + }); + card.appendChild(ledSave); PagerAPI.get('/api/settings/hardware').then((r) => { const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate; clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness; @@ -3569,14 +3868,20 @@ views.settings_advanced = (root) => { const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }), h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' })); updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel)); - updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value }) - .then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel')))); + const chBtn = btn('Set Update Channel', () => { + runAction(chBtn, () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value }) + .then(() => App.toast('Update channel saved')), 'Saving…'); + }); + updates.appendChild(chBtn); const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.'); const hostname = h('input', {}); host.appendChild(h('label', {}, 'Hostname', hostname)); - host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() }) - .then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname')))); + const hostSave = btn('Save', () => { + runAction(hostSave, () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() }) + .then(() => App.toast('Hostname saved')), 'Saving…'); + }); + host.appendChild(hostSave); const webui = settingsCard(box, 'Web Interface'); const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' })); @@ -3607,10 +3912,9 @@ views.settings_help = (root) => { const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' }); let report = ''; const generate = btn('Generate Diagnostics', () => { - generate.disabled = true; output.textContent = 'Generating diagnostics…'; - PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; }) - .catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); }) - .finally(() => { generate.disabled = false; }); + output.textContent = 'Generating diagnostics…'; + return PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; }) + .catch((e) => { output.textContent = 'Diagnostics failed.'; throw e; }); }); diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate, btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost'))); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 9d7c96f..40cff3a 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -97,31 +97,75 @@ PY printf 'Built: %s\n' "$ZIP_PATH" TARGET="$PAGER_USER@$PAGER_HOST" +SSH_OPTS=(-o StrictHostKeyChecking=accept-new) +if [[ -n "$PASSWORD" && -z "$SSH_KEY" ]]; then + SSH_OPTS+=(-o PreferredAuthentications=password -o PubkeyAuthentication=no) +fi run_scp() { if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then - SSHPASS="$PASSWORD" sshpass -e scp -i "$SSH_KEY" "$@" + SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" elif [[ -n "$PASSWORD" ]]; then - SSHPASS="$PASSWORD" sshpass -e scp "$@" + SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" "$@" elif [[ -n "$SSH_KEY" ]]; then - scp -i "$SSH_KEY" "$@" + scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" else - scp "$@" + scp "${SSH_OPTS[@]}" "$@" fi } run_ssh() { if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then - SSHPASS="$PASSWORD" sshpass -e ssh -i "$SSH_KEY" "$@" + SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" elif [[ -n "$PASSWORD" ]]; then - SSHPASS="$PASSWORD" sshpass -e ssh "$@" + SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" "$@" elif [[ -n "$SSH_KEY" ]]; then - ssh -i "$SSH_KEY" "$@" + ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@" else - ssh "$@" + ssh "${SSH_OPTS[@]}" "$@" fi } +install_python3() { + if run_ssh "$TARGET" 'command -v python3 >/dev/null'; then + printf 'python3 already present on the pager.\n' + return 0 + fi + printf 'python3 missing on pager; installing python3-light (offline ipks).\n' + local cache="$ROOT/build/python-ipk" + local pkg_base='https://downloads.openwrt.org/releases/24.10.1/packages/mipsel_24kc' + mkdir -p "$cache" + local files=( + "base/libbz2-1.0_1.0.8-r1_mipsel_24kc.ipk" + "packages/libpython3-3.11_3.11.14-r1_mipsel_24kc.ipk" + "packages/python3-base_3.11.14-r1_mipsel_24kc.ipk" + "packages/python3-light_3.11.14-r1_mipsel_24kc.ipk" + ) + local names=() + local rel + for rel in "${files[@]}"; do + local name="${rel##*/}" + names+=("$name") + if [[ ! -s "$cache/$name" ]]; then + curl -fsSL --retry 3 -o "$cache/$name" "$pkg_base/$rel" + fi + done + run_ssh "$TARGET" 'mkdir -p /tmp/python-ipk && rm -rf /tmp/python-ipk/*' + ( + cd "$cache" + run_scp "${names[@]}" "$TARGET:/tmp/python-ipk/" + ) + run_ssh "$TARGET" 'set -e +cd /tmp/python-ipk +opkg install libbz2-1.0_*.ipk libpython3-3.11_*.ipk python3-base_*.ipk python3-light_*.ipk +command -v python3 >/dev/null +python3 -c "import json,socket,hashlib,threading,select,subprocess,struct,base64,re" +rm -rf /tmp/python-ipk +echo PYTHON_OK' +} + +install_python3 + run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/" REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY" @@ -151,16 +195,13 @@ else exit 1 fi rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json -webui_running=false -if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then - webui_running=true -fi -if [ -f /etc/init.d/pagerwebui ]; then - cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui - chmod +x /etc/init.d/pagerwebui -fi -if \$webui_running; then +cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui +chmod +x /etc/init.d/pagerwebui +/etc/init.d/pagerwebui enable +if /etc/init.d/pagerwebui running >/dev/null 2>&1; then /etc/init.d/pagerwebui restart +else + /etc/init.d/pagerwebui start fi echo EXTRACT_OK" run_ssh "$TARGET" "$REMOTE_COMMAND" diff --git a/tests/test_attacks.py b/tests/test_attacks.py index 11cf777..aca8512 100644 --- a/tests/test_attacks.py +++ b/tests/test_attacks.py @@ -80,11 +80,20 @@ class AttacksDeployTest(unittest.TestCase): self.old_state = server.PINEAP_STATE_FILE server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json') self.old_ent = {k: getattr(server, k) for k in - ('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE')} + ('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE', + 'ENT_DIR', 'ENT_CA_CERT', 'ENT_SERVER_CERT', 'ENT_SERVER_KEY', + 'ENT_LOG', 'ENT_CAPTURES', 'ENT_DH_FILE')} server.ENT_CONF = os.path.join(self.tmp, 'enterprise.conf') server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid') server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users') server.ENT_STATE = os.path.join(self.tmp, 'state.json') + server.ENT_DIR = os.path.join(self.tmp, 'ent') + server.ENT_CA_CERT = os.path.join(server.ENT_DIR, 'ca.pem') + server.ENT_SERVER_CERT = os.path.join(server.ENT_DIR, 'server.pem') + server.ENT_SERVER_KEY = os.path.join(server.ENT_DIR, 'server.key') + server.ENT_LOG = os.path.join(server.ENT_DIR, 'hostapd.log') + server.ENT_CAPTURES = os.path.join(server.ENT_DIR, 'captures.json') + server.ENT_DH_FILE = os.path.join(server.ENT_DIR, 'dh.pem') self.old_ent_running = server._ent_running self.old_ent_state = server._ent_state_loaded server._ent_running = lambda: True @@ -203,10 +212,25 @@ class AttacksDeployTest(unittest.TestCase): self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent', 'type', 'managed'], cmds) self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds) - self.assertIn(['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE, - server.ENT_CONF], cmds) + self.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE] + for c in cmds)) self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent') self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0') + with open(server.ENT_CONF) as f: + conf = f.read() + self.assertIn('ca_cert=', conf) + self.assertIn('server_cert=', conf) + self.assertIn('private_key=', conf) + self.assertIn('ieee8021x=1', conf) + self.assertIn('eap_server=1', conf) + self.assertNotIn('eap_server_identity', conf) + self.assertNotIn('eap_server_erp', conf) + self.assertNotIn('dh_file=', conf) + self.assertIn('ieee80211w=0', conf) + with open(server.ENT_EAP_USERS) as f: + users = f.read() + self.assertIn('PEAP,TTLS', users) + self.assertIn('[2]', users) def test_deploy_enterprise_rejects_non_5g_channel(self): status, _ = server.h_attacks_deploy(ctx({ @@ -223,6 +247,77 @@ class AttacksDeployTest(unittest.TestCase): cmds = [r[0] for r in self.f.runs] self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds) + def test_deploy_enterprise_writes_passphrase_and_hidden(self): + status, payload = server.h_attacks_deploy(ctx({ + 'kind': 'enterprise', 'ssid': 'CorpAP', 'passphrase': 'labsecret', + 'enctype': 'wpa2', 'hidden': True, 'channel': 36})) + self.assertEqual(status, 200) + with open(server.ENT_EAP_USERS) as f: + users = f.read() + self.assertIn('labsecret', users) + self.assertIn('PEAP,TTLS', users) + self.assertIn('[2]', users) + with open(server.ENT_CONF) as f: + conf = f.read() + self.assertIn('ignore_broadcast_ssid=1', conf) + self.assertIn('ca_cert=', conf) + + def test_eap_secret_sanitizes_quotes(self): + self.assertEqual(server._eap_secret(''), 'dummy') + self.assertEqual(server._eap_secret('ab"c\ndef'), 'abcdef') + + def test_hostapd_unknown_items_parse_pager_error(self): + err = ("Line 14: unknown configuration item 'eap_server_identity'\n" + "1 errors found in configuration file '/root/loot/enterprise.conf'\n" + "Failed to set up interface with /root/loot/enterprise.conf\n" + "Failed to initialize interface\n") + self.assertEqual(server._hostapd_unknown_items(err), ['eap_server_identity']) + + def test_drop_hostapd_keys_removes_only_named_lines(self): + conf = ('interface=wlan1ent\n' + 'eap_server=1\n' + 'eap_server_identity=hostapd\n' + 'dh_file=/tmp/dh.pem\n') + new, changed = server._drop_hostapd_keys( + conf, ['eap_server_identity', 'dh_file']) + self.assertTrue(changed) + self.assertIn('eap_server=1', new) + self.assertIn('interface=wlan1ent', new) + self.assertNotIn('eap_server_identity', new) + self.assertNotIn('dh_file=', new) + + def test_start_ent_hostapd_strips_unknown_keys_and_retries(self): + with open(server.ENT_CONF, 'w') as f: + f.write('interface=wlan1ent\neap_server=1\neap_server_identity=hostapd\n') + orig = server.device_run + seen = [] + + def wrapped(args, timeout=20, input_data=None): + a = list(args) + if a and a[0] == '/usr/sbin/hostapd': + with open(server.ENT_CONF) as fh: + text = fh.read() + seen.append(text) + if 'eap_server_identity' in text: + return (1, '', + "Line 3: unknown configuration item 'eap_server_identity'\n" + "1 errors found in configuration file '%s'\n" + "Failed to initialize interface\n" % server.ENT_CONF) + return (0, '', '') + return orig(args, timeout=timeout, input_data=input_data) + + server.device_run = wrapped + try: + rc, _out, _err = server._start_ent_hostapd() + finally: + server.device_run = orig + self.assertEqual(rc, 0) + self.assertGreaterEqual(len(seen), 2) + with open(server.ENT_CONF) as f: + conf = f.read() + self.assertNotIn('eap_server_identity', conf) + self.assertIn('eap_server=1', conf) + def test_deploy_validation(self): status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''})) self.assertEqual(status, 400) @@ -322,5 +417,43 @@ class AttacksExportTest(unittest.TestCase): self.assertIn('/root/loot/pcap/b.cap', hc) +class AttacksStatusTest(unittest.TestCase): + def setUp(self): + self.saved = { + '_count_table': server._count_table, + '_ent_summary': server._ent_summary, + 'daemon_sock_call': server.daemon_sock_call, + '_uci_ap_summary': server._uci_ap_summary, + '_read_hop': server._read_hop, + } + + def tearDown(self): + for name, fn in self.saved.items(): + setattr(server, name, fn) + + def test_status_exposes_enterprise_ap_for_ui(self): + server._count_table = lambda t: { + 'hostap_handshake': 2, 'hostap_basic': 3, 'hostap_chalresp': 1 + }.get(t, 0) + server._ent_summary = lambda detail=True: { + 'enabled': True, 'live': True, 'ssid': 'CorpLab', + 'iface': 'wlan1ent', 'stations': ['AA:BB:CC:DD:EE:FF'], + 'auth_method': 'mschapv2', 'certs': True, 'captures': 4, + 'ctrl_linked': True, + } + server.daemon_sock_call = lambda *a, **k: (200, {'pineape_disabled': False}) + server._uci_ap_summary = lambda *a, **k: None + server._read_hop = lambda: '1' + status, payload = server.h_attacks_status(ctx()) + self.assertEqual(status, 200) + self.assertTrue(payload['enterprise']['ap']['live']) + self.assertEqual(payload['enterprise']['ap']['ssid'], 'CorpLab') + self.assertEqual(payload['enterprise']['identities'], 3) + self.assertEqual(payload['enterprise']['mschapv2'], 1) + self.assertEqual(payload['enterprise']['creds'], 4) + self.assertEqual(payload['handshakes'], 2) + self.assertTrue(payload['enterprise']['pineape']['enabled']) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_env_check.py b/tests/test_env_check.py index 35f8bc9..2f76e62 100644 --- a/tests/test_env_check.py +++ b/tests/test_env_check.py @@ -106,15 +106,19 @@ class EnvCheckTest(unittest.TestCase): self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled') self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs) - def test_applies_sane_defaults_when_missing(self): + def test_warns_sane_defaults_when_missing_without_mutating(self): + self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg==' report = server.env_check() - self.assertEqual(self.steps(report, 'sane-off UCI defaults applied')[0]['ok'], 'fixed') self.assertEqual( - self.steps(report, 'runtime safety settings changed')[0]['ok'], - 'fixed') - self.assertIn(['/etc/init.d/pineapd', 'restart'], self.runs) - for key, value in server.PINEAPD_SAFE_UCI.items(): - self.assertEqual(self.uci_state[key], value) + self.steps(report, 'live PineAP UCI left unchanged')[0]['ok'], 'warn') + self.assertNotIn(['/etc/init.d/pineapd', 'restart'], self.runs) + self.assertNotIn(['/etc/init.d/pineapd', 'stop'], self.runs) + self.assertFalse(any(a[:2] == ['uci', 'set'] for a in self.runs)) + self.assertFalse(any(a[:2] == ['uci', 'commit'] for a in self.runs)) + self.assertFalse(any(a[:2] == ['uci', 'delete'] for a in self.runs)) + self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==') + for key in server.PINEAPD_SAFE_UCI: + self.assertNotIn(key, self.uci_state) def test_uci_pass_when_already_set(self): self.safe_set() @@ -213,17 +217,19 @@ class EnvCheckTest(unittest.TestCase): report = server.env_check() self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass') - def test_sta_uplink_disabled_when_enabled(self): + def test_sta_uplink_warns_when_enabled_without_mutating(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(['ip', 'link', 'set', 'wlan0', 'down'], self.runs) - self.assertNotIn(['wifi', 'reload'], self.runs, - 'STA fix must not bounce the radios') + self.assertEqual(self.steps(report, 'dummy_radio0 STA uplink is enabled')[0]['ok'], 'warn') + self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '0') + self.assertNotIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs) + self.assertNotIn(['wifi', 'reload'], self.runs) + self.assertFalse(any( + a[:2] == ['uci', 'set'] and 'dummy_radio0' in a[2] + for a in self.runs if len(a) > 2)) def test_sta_uplink_pass_when_absent(self): self.safe_set() diff --git a/tests/test_health.py b/tests/test_health.py index 2ff8e46..0be24cb 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -73,12 +73,15 @@ class HealthCheckTest(unittest.TestCase): self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], [r[0] for r in self.runs]) self.assertEqual(result['monitor_fixes'], 1) - def test_down_with_growing_sigsegv_disables_pool(self): + def test_down_restarts_pineapd_without_rewriting_uci(self): self.ping_ok = False self.sigsegs = 5 + self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg==' result = server.health_check() - self.assertIn('pool broadcast disabled', result['last_action']) - self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1') + self.assertEqual(result['last_action'], 'pineapd restart') + self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state) + self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==') + self.assertFalse(any(r[0][:2] == ['uci', 'set'] for r in self.runs)) self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs]) self.assertEqual(result['fixes'], 1) @@ -94,21 +97,15 @@ class HealthCheckTest(unittest.TestCase): self.assertEqual(result['last_action'], 'pineapd restart') self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs]) - def test_down_stabilizes_known_crash_sources(self): - self.ping_ok = False - self.uci_state['pineapd.@ssidpool[0].disable'] = '1' - result = server.health_check() - self.assertEqual(self.uci_state['pineapd.wlan2mon.disable'], '1') - self.assertEqual(self.uci_state['pineapd.wlan1mon.bands'], '5') - self.assertIn('stabilized', result['last_action']) - - def test_down_clears_refilled_pool_list(self): + def test_down_does_not_stabilize_or_clear_pool(self): self.ping_ok = False self.uci_state['pineapd.@ssidpool[0].disable'] = '1' self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg==' result = server.health_check() - self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state) - self.assertIn('pool-list cleared', result['last_action']) + self.assertEqual(result['last_action'], 'pineapd restart') + self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==') + self.assertNotIn('pineapd.wlan2mon.disable', self.uci_state) + self.assertNotIn('pineapd.wlan1mon.bands', self.uci_state) def test_down_without_crash_brings_monitors_up(self): self.ping_ok = False @@ -118,12 +115,12 @@ class HealthCheckTest(unittest.TestCase): self.assertEqual(result['last_action'], 'monitor interfaces brought up') self.assertIn(['ip', 'link', 'set', 'wlan1mon', 'up'], [r[0] for r in self.runs]) - def test_down_disables_pool_regardless_of_sigsegv_history(self): + def test_down_does_not_disable_pool_regardless_of_sigsegv_history(self): self.ping_ok = False server._health['sigsegv_last'] = 4 result = server.health_check() - self.assertIn('SSID pool broadcast disabled', result['last_action']) - self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1') + self.assertEqual(result['last_action'], 'pineapd restart') + self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state) def test_fix_cooldown_prevents_thrash(self): self.ping_ok = False diff --git a/tests/test_misc.py b/tests/test_misc.py index f571cd8..5969876 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -153,6 +153,26 @@ class LoggingTest(unittest.TestCase): self.assertEqual(server._line_count( type('C', (), {'query': {'lines': '-10'}})(), 200), 0) + def test_system_uses_bounded_logread(self): + calls = [] + + def fake(args, timeout=20, input_data=None): + calls.append(list(args)) + return (0, '\n'.join('line%d' % i for i in range(20)), '') + + server.device_run = fake + status, payload = server.h_logging_system(type('C', (), { + 'args': (), 'query': {'lines': '5'}})()) + self.assertEqual(status, 200) + self.assertEqual(len(payload['lines']), 5) + self.assertTrue(calls) + self.assertEqual(calls[0][:2], ['logread', '-l']) + + def test_json_or_and_pool_list_tolerate_empty(self): + self.assertIsNone(server._json_or(None)) + self.assertEqual(server._parse_pool_list(None), []) + self.assertEqual(server._unique_keep_order(['a', '', 'a', 'b']), ['a', 'b']) + class SettingsTest(unittest.TestCase): def test_hostname_get(self): diff --git a/tests/test_pineap_enterprise.py b/tests/test_pineap_enterprise.py index ecb8ece..5fef6ce 100644 --- a/tests/test_pineap_enterprise.py +++ b/tests/test_pineap_enterprise.py @@ -1,5 +1,8 @@ +import json import os +import sqlite3 import sys +import tempfile import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) @@ -10,34 +13,217 @@ def setUpModule(): __import__('importlib').reload(server) -class EnterpriseTest(unittest.TestCase): +SCHEMA = ''' +CREATE TABLE hostap_basic(id INTEGER PRIMARY KEY, scan INT, time INT, type TEXT, + identity TEXT, password TEXT, verified INT NOT NULL DEFAULT 0); +CREATE TABLE hostap_chalresp(id INTEGER PRIMARY KEY, scan INT, time INT, type TEXT, + username TEXT, challenge BLOB, response BLOB, + verified INT NOT NULL DEFAULT 0); +CREATE TABLE hostap_client(id INTEGER PRIMARY KEY, scan INT, hash INT, mac TEXT, ssid BLOB, + connected_time INT, disconnected_time INT); +''' + + +class EnterpriseApiTest(unittest.TestCase): + def setUp(self): + self._orig_rows = server._db_rows + self._orig_write = server._db_write + + def tearDown(self): + server._db_rows = self._orig_rows + server._db_write = self._orig_write + def test_basic_rows(self): server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}] status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})()) self.assertEqual(status, 200) + self.assertEqual(payload['table'], 'hostap_basic') self.assertEqual(payload['rows'][0]['username'], 'a') + self.assertEqual(payload['rows'][0]['identity'], 'a') - def test_challenge_rows(self): + def test_challenge_rows_empty(self): server._db_rows = lambda db, sql: [] status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})()) self.assertEqual(status, 200) + self.assertEqual(payload['table'], 'hostap_chalresp') self.assertEqual(payload['rows'], []) def test_unknown_table(self): status, payload = server.h_enterprise_data(type('C', (), {'args': ('nope',)})()) self.assertEqual(status, 400) - def test_clear(self): + def test_clear_uses_chalresp_table(self): calls = [] server._db_write = lambda db, sql: calls.append(sql) status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})()) self.assertEqual(status, 200) - self.assertTrue(any('hostap_challenge' in s for s in calls)) + self.assertTrue(any('hostap_chalresp' in s for s in calls)) + self.assertFalse(any('hostap_challenge' in s for s in calls)) + + def test_clear_all(self): + calls = [] + server._db_write = lambda db, sql: calls.append(sql) + status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'all'}})()) + self.assertEqual(status, 200) + joined = ' '.join(calls) + self.assertIn('hostap_basic', joined) + self.assertIn('hostap_chalresp', joined) def test_clear_unknown_table(self): status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'nope'}})()) self.assertEqual(status, 400) +class EnterpriseHashFormatTest(unittest.TestCase): + def test_blob_to_hex_bytes_and_hex_string(self): + self.assertEqual(server._blob_to_hex(b'\x11\x22\x33\x44'), '11223344') + self.assertEqual(server._blob_to_hex('AABBCCDD'), 'aabbccdd') + self.assertEqual(server._blob_to_hex("X'AABB'"), 'aabb') + self.assertEqual(server._blob_to_hex('\\xde\\xad'), 'dead') + + def test_hashcat_5500_and_john(self): + chal = '1122334455667788' + resp = '00112233445566778899aabbccddeeff0011223344556677' + self.assertEqual( + server._mschap_hashcat_5500('bob', chal, resp), + 'bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788') + self.assertEqual( + server._mschap_john('bob', chal, resp), + 'bob:$NETNTLM$1122334455667788$00112233445566778899aabbccddeeff0011223344556677') + + def test_format_chalresp_row_hexes_blobs_and_is_json_safe(self): + row = server._format_chalresp_row({ + 'time': 1700000000, + 'username': 'alice', + 'type': 'MSCHAPV2', + 'challenge': bytes.fromhex('1122334455667788'), + 'response': bytes.fromhex('00112233445566778899aabbccddeeff0011223344556677'), + 'verified': 0, + }) + self.assertEqual(row['challenge'], '1122334455667788') + self.assertEqual(row['response'], '00112233445566778899aabbccddeeff0011223344556677') + self.assertIn('alice::::', row['hashcat']) + self.assertIn(':$NETNTLM$', row['john']) + json.dumps(row) + + +class EnterpriseLogParseTest(unittest.TestCase): + def test_parse_wpe_mschapv2_and_identity(self): + log = ( + "mschapv2: Wed Aug 19 21:00:00 2026\n" + " username: bob\n" + " challenge: 11:22:33:44:55:66:77:88\n" + " response: 00112233445566778899aabbccddeeff0011223344556677\n" + "hashcat NETNTLM: bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788\n" + "EAP-Identity 'alice@corp.local'\n" + "GTC: username: carol password: hunter2\n" + ) + items = server._parse_ent_log(log) + kinds = [i['kind'] for i in items] + self.assertIn('mschapv2', kinds) + self.assertIn('eap-identity', kinds) + self.assertIn('gtc', kinds) + mschap = [i for i in items if i['kind'] == 'mschapv2'][0] + self.assertEqual(mschap['username'], 'bob') + self.assertIn('bob::::', mschap['hashcat']) + gtc = [i for i in items if i['kind'] == 'gtc'][0] + self.assertEqual(gtc['password'], 'hunter2') + + +class EnterpriseCaptureDbTest(unittest.TestCase): + def setUp(self): + fd, self.db = tempfile.mkstemp(suffix='.db') + os.close(fd) + conn = sqlite3.connect(self.db) + conn.executescript(SCHEMA) + chal = bytes.fromhex('1122334455667788') + resp = bytes.fromhex('00112233445566778899aabbccddeeff0011223344556677') + conn.execute( + "INSERT INTO hostap_basic (id, scan, time, type, identity, password, verified) " + "VALUES (1, 1, 1700000001, 'PEAP', 'bob', '', 0)") + conn.execute( + "INSERT INTO hostap_chalresp (id, scan, time, type, username, challenge, response, verified) " + "VALUES (1, 1, 1700000002, 'MSCHAPV2', 'bob', ?, ?, 0)", (chal, resp)) + conn.execute( + "INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) " + "VALUES (1, 1, 1, 'AABBCCDDEEFF', X'436F7270', 1700000003, NULL)") + conn.commit() + conn.close() + self.old_db = server.RECON_DB + server.RECON_DB = self.db + + def tearDown(self): + server.RECON_DB = self.old_db + try: + os.unlink(self.db) + except OSError: + pass + + def test_challenge_endpoint_returns_hashcat(self): + status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})()) + self.assertEqual(status, 200) + self.assertEqual(payload['table'], 'hostap_chalresp') + row = payload['rows'][0] + self.assertEqual(row['username'], 'bob') + self.assertEqual(row['challenge'], '1122334455667788') + self.assertEqual( + row['hashcat'], + 'bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788') + json.dumps(payload, default=server._json_default) + + def test_radius_payload_unifies_captures(self): + status, payload = server.h_enterprise_radius(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertTrue(payload['note']) + kinds = [c['kind'] for c in payload['captures']] + self.assertIn('eap-identity', kinds) + self.assertIn('mschapv2', kinds) + self.assertEqual(payload['hashcat']['mode'], 5500) + self.assertEqual(len(payload['hashcat']['lines']), 1) + self.assertEqual(payload['clients'][0]['ssid'], 'Corp') + + def test_export_hashcat_download(self): + status, payload = server.h_enterprise_export(type('C', (), {'args': ('hashcat',)})()) + self.assertEqual(status, 200) + self.assertIsInstance(payload, server.Download) + self.assertIn(b'bob::::', payload.data) + self.assertTrue(payload.filename.endswith('.5500')) + + def test_export_john_and_json(self): + status, payload = server.h_enterprise_export(type('C', (), {'args': ('john',)})()) + self.assertEqual(status, 200) + self.assertIn(b'$NETNTLM$', payload.data) + status, payload = server.h_enterprise_export(type('C', (), {'args': ('json',)})()) + self.assertEqual(status, 200) + body = json.loads(payload.data.decode('utf-8')) + self.assertEqual(body['hashcat']['mode'], 5500) + + +class EnterpriseHarvestTest(unittest.TestCase): + def test_harvest_reads_hostapd_file_and_skips_logread(self): + calls = [] + fd, log_path = tempfile.mkstemp() + os.write(fd, b"EAP-Identity 'fromfile'\n") + os.close(fd) + cap_path = log_path + '.json' + old_log, old_cap = server.ENT_LOG, server.ENT_CAPTURES + old_run = server.device_run + server.ENT_LOG = log_path + server.ENT_CAPTURES = cap_path + server.device_run = lambda args, timeout=20, input_data=None: ( + calls.append(list(args)) or (0, "identity: 'syslog-user'\n", '')) + try: + items = server._harvest_ent_log() + self.assertTrue(any(item.get('username') == 'fromfile' for item in items)) + self.assertFalse(any(args and args[0] == 'logread' for args in calls)) + finally: + server.ENT_LOG = old_log + server.ENT_CAPTURES = old_cap + server.device_run = old_run + os.unlink(log_path) + if os.path.exists(cap_path): + os.unlink(cap_path) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_pineap_pool.py b/tests/test_pineap_pool.py index ef57f5c..de59c93 100644 --- a/tests/test_pineap_pool.py +++ b/tests/test_pineap_pool.py @@ -53,7 +53,7 @@ class SsidPoolHandlersTest(unittest.TestCase): server.h_ssids_post(type('C', (), {'args': (), 'body': {'action': 'add', 'ssid': 'NewNet'}})()) self.assertTrue(any(c[0] == 'PINEAPPLE_SSID_POOL_ADD' for c in calls)) - def test_advertise_routes(self): + def test_advertise_enable_is_blocked(self): calls = [] def fake(method, path, body=None, timeout=10): @@ -61,10 +61,11 @@ class SsidPoolHandlersTest(unittest.TestCase): return (200, {'success': True}) server.daemon_sock_call = fake - server.h_pineap_advertise(type('C', (), {'body': {'enable': True}})()) + status, payload = server.h_pineap_advertise(type('C', (), {'body': {'enable': True}})()) + self.assertEqual(status, 400) + self.assertEqual(calls, []) server.h_pineap_advertise(type('C', (), {'body': {'enable': False}})()) - self.assertEqual(calls, [('/api/pineap/ssidpool/enable', {'enable': True}), - ('/api/pineap/ssidpool/disable', {'enable': False})]) + self.assertEqual(calls, [('/api/pineap/ssidpool/disable', {'enable': False})]) def test_collect_routes(self): calls = [] diff --git a/tests/test_pineap_proxy.py b/tests/test_pineap_proxy.py index d7aff0e..43f402b 100644 --- a/tests/test_pineap_proxy.py +++ b/tests/test_pineap_proxy.py @@ -144,6 +144,7 @@ class PineapProxyTest(unittest.TestCase): self.assertEqual(payload['enterprise']['enabled'], False) self.assertEqual(payload['enterprise']['ssid'], '') self.assertEqual(payload['pool']['collecting'], True) + self.assertTrue(payload['pool']['broadcast_blocked']) self.assertEqual(payload['radios']['radio0']['band'], '2.4') self.assertEqual(payload['radios']['radio1']['channel'], 'auto') self.assertEqual(payload['pineape']['enabled'], True) @@ -261,6 +262,32 @@ class PineapFilterTest(unittest.TestCase): [server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_CLEAR', 'deny'], [server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny']]) + def test_filter_get_falls_back_to_hak5cmd_when_daemon_is_down(self): + server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None) + + def fake(args, timeout=20, input_data=None): + cmd = args[1] if len(args) > 1 else '' + if cmd.endswith('_MODE'): + return (0, 'deny\n', '') + if cmd.endswith('_LIST'): + return (0, 'AA:BB:CC:DD:EE:FF\n', '') + return (0, '', '') + + server.device_run = fake + status, payload = server.h_filter_get(ctx(), 'client') + self.assertEqual(status, 200) + self.assertEqual(payload['mode'], 'deny') + self.assertEqual(payload['entries'], ['AA:BB:CC:DD:EE:FF']) + self.assertEqual(payload['source'], 'hak5cmd') + + def test_filter_get_returns_empty_list_when_everything_is_down(self): + server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None) + server.device_run = lambda args, timeout=20, input_data=None: (1, '', 'refused') + status, payload = server.h_filter_get(ctx(), 'ssid') + self.assertEqual(status, 200) + self.assertEqual(payload['entries'], []) + self.assertIn('error', payload) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_recon.py b/tests/test_recon.py index 6f7489d..420d814 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -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' diff --git a/tests/test_robustness.py b/tests/test_robustness.py new file mode 100644 index 0000000..cfc37e8 --- /dev/null +++ b/tests/test_robustness.py @@ -0,0 +1,206 @@ +import os +import sys +import unittest +from unittest import mock + +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) + + +def ctx(body=None): + return type('C', (), {'body': body, 'args': (), 'query': {}})() + + +class DaemonRetryTest(unittest.TestCase): + def setUp(self): + self.old_sleep = server.DAEMON_SOCK_RETRY_SLEEP + self.old_retries = server.DAEMON_SOCK_RETRIES + server.DAEMON_SOCK_RETRY_SLEEP = 0 + server.DAEMON_SOCK_RETRIES = 2 + + def tearDown(self): + server.DAEMON_SOCK_RETRY_SLEEP = self.old_sleep + server.DAEMON_SOCK_RETRIES = self.old_retries + + def test_sock_retries_then_succeeds(self): + attempts = {'n': 0} + + class Sock: + def __init__(self): + self.chunks = [b'HTTP/1.1 200 OK\r\n\r\n{"ok":true}', b''] + + def settimeout(self, t): + pass + + def connect(self, addr): + attempts['n'] += 1 + if attempts['n'] < 2: + raise OSError('busy') + + def sendall(self, data): + pass + + def recv(self, n): + return self.chunks.pop(0) if self.chunks else b'' + + def close(self): + pass + + with mock.patch.object(server.socket, 'socket', lambda *a, **k: Sock()): + status, data = server.daemon_sock_call('GET', '/api/pineap/get_config') + self.assertEqual(attempts['n'], 2) + self.assertEqual(status, 200) + self.assertEqual(data, {'ok': True}) + + def test_call_retries_on_oserror(self): + attempts = {'n': 0} + + def boom(*a, **k): + attempts['n'] += 1 + raise OSError('down') + + with mock.patch.object(server.socket, 'socket', boom): + status, data = server.daemon_call('GET', '/api/api_ping') + self.assertEqual(attempts['n'], 3) + self.assertEqual(status, 0) + self.assertIsNone(data) + + +class ConfigWriteSafetyTest(unittest.TestCase): + def setUp(self): + self.old_sock = server.daemon_sock_call + + def tearDown(self): + server.daemon_sock_call = self.old_sock + def test_set_config_refuses_when_get_fails(self): + calls = [] + + def fake(method, path, body=None, timeout=10): + calls.append((method, path)) + if method == 'GET': + return 0, None + return 200, {'success': True} + + server.daemon_sock_call = fake + status, payload = server.h_pineap_set_config(ctx({'loghandshake': True})) + self.assertEqual(status, 502) + self.assertIn('could not read', payload['error']) + self.assertFalse(any(c[0] == 'PUT' for c in calls)) + + def test_hostapd_set_refuses_when_get_fails(self): + calls = [] + + def fake(method, path, body=None, timeout=10): + calls.append((method, path)) + if method == 'GET': + return 0, None + return 200, {'success': True} + + server.daemon_sock_call = fake + status, payload = server.h_pineap_hostapd_set(ctx({'pineape_auth_pass': True})) + self.assertEqual(status, 502) + self.assertFalse(any(c[0] == 'PUT' for c in calls)) + + +class Hak5RetryTest(unittest.TestCase): + def setUp(self): + self.old_sleep = server.HAK5_RETRY_SLEEP + self.old_run = server.device_run + server.HAK5_RETRY_SLEEP = 0 + + def tearDown(self): + server.HAK5_RETRY_SLEEP = self.old_sleep + server.device_run = self.old_run + + def test_hak5_raises_after_retries(self): + calls = [] + + def fake(args, timeout=20, input_data=None): + calls.append(args) + return 1, '', 'busy' + + server.device_run = fake + with self.assertRaises(RuntimeError): + server.hak5('PINEAPPLE_SSID_POOL_LIST') + self.assertEqual(len(calls), 3) + + def test_hak5_succeeds_on_retry(self): + calls = [] + + def fake(args, timeout=20, input_data=None): + calls.append(args) + if len(calls) < 2: + return 1, '', 'busy' + return 0, 'ok\n', '' + + server.device_run = fake + out = server.hak5('PINEAPPLE_SSID_POOL_LIST') + self.assertEqual(out, 'ok\n') + self.assertEqual(len(calls), 2) + + def test_hak5_treats_error_text_as_failure(self): + calls = [] + + def fake(args, timeout=20, input_data=None): + calls.append(args) + return 0, '', 'ERROR: invalid time (expected number of seconds)' + + server.device_run = fake + with self.assertRaises(RuntimeError): + server.hak5('PINEAPPLE_EXAMINE_CHANNEL', '140') + self.assertEqual(len(calls), 3) + + +class SsidPoolFailureTest(unittest.TestCase): + def setUp(self): + self.old_hak5 = server.hak5 + + def tearDown(self): + server.hak5 = self.old_hak5 + def test_ssids_post_add_returns_502_on_hak5_failure(self): + server.hak5 = lambda *a, **k: (_ for _ in ()).throw(RuntimeError('busy')) + status, payload = server.h_ssids_post(ctx({'action': 'add', 'ssid': 'NewNet'})) + self.assertEqual(status, 502) + self.assertIn('ssid pool update failed', payload['error']) + + def test_examine_returns_502_on_hak5_failure(self): + server.hak5 = lambda *a, **k: (_ for _ in ()).throw(RuntimeError('busy')) + status, payload = server.h_recon_examine(type('C', (), { + 'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})()) + self.assertEqual(status, 502) + self.assertEqual(payload['error'], 'examine failed') + + +class AdvertiseBlockTest(unittest.TestCase): + def setUp(self): + self.old_proxy = server._daemon_proxy + self.old_uci = server._uci_section + + def tearDown(self): + server._daemon_proxy = self.old_proxy + server._uci_section = self.old_uci + def test_advertise_enable_always_refused(self): + server._uci_section = lambda name: {'disable': '0'} + calls = [] + server._daemon_proxy = lambda method, path, body=None, timeout=15: ( + calls.append((method, path, body)) or (200, {'success': True})) + status, payload = server.h_pineap_advertise(ctx({'enable': True})) + self.assertEqual(status, 400) + self.assertIn('cannot be re-enabled', payload['error']) + self.assertEqual(calls, []) + + def test_advertise_disable_still_proxies(self): + calls = [] + server._daemon_proxy = lambda method, path, body=None, timeout=15: ( + calls.append(path) or (200, {'success': True})) + status, payload = server.h_pineap_advertise(ctx({'enable': False})) + self.assertEqual(status, 200) + self.assertEqual(calls, ['ssidpool/disable']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py new file mode 100644 index 0000000..25354f7 --- /dev/null +++ b/tests/test_shutdown.py @@ -0,0 +1,159 @@ +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +class PagerTruthTest(unittest.TestCase): + def setUp(self): + self.uci = { + 'pineapd.wlan1mon.hop': '1', + 'pineapd.wlan2mon.disable': '0', + 'wireless.dummy_radio0.disabled': '0', + 'wireless.radio1.channel': 'auto', + 'wireless.radio1.band': '5g', + 'wireless.wlan1open': None, + 'wireless.wlan1wpa': None, + } + self.ifaces = {} + fd, self.snap = tempfile.mkstemp(suffix='.json') + os.close(fd) + os.unlink(self.snap) + self.old_file = server.PAGER_SNAPSHOT_FILE + self.old_pineap = server.PINEAP_STATE_FILE + server.PAGER_SNAPSHOT_FILE = self.snap + server.PINEAP_STATE_FILE = self.snap + '.pineap' + self.old_run = server.device_run + server.device_run = self.fake_run + self.old_ent = server._disable_enterprise_ap + server._disable_enterprise_ap = lambda resume_hop=True: None + + def tearDown(self): + server.device_run = self.old_run + server._disable_enterprise_ap = self.old_ent + server.PAGER_SNAPSHOT_FILE = self.old_file + server.PINEAP_STATE_FILE = self.old_pineap + for path in (self.snap, self.snap + '.tmp', self.snap + '.pineap'): + try: + os.unlink(path) + except OSError: + pass + + def fake_run(self, args, timeout=20, input_data=None): + a = list(args) + if a[:2] == ['uci', '-q'] and a[2] == 'get': + key = a[3] + if key.startswith('wireless.') and key.count('.') == 1: + name = key.split('.', 1)[1] + if name in self.ifaces: + return 0, 'wifi-iface\n', '' + return 1, '', '' + val = self.uci.get(key) + if val is None: + return 1, '', '' + return 0, val + '\n', '' + if a[:2] == ['uci', 'show']: + sec = a[2] + name = sec.split('.', 1)[-1] + cfg = self.ifaces.get(name) or {} + body = ''.join("%s.%s='%s'\n" % (sec, k, v) for k, v in cfg.items()) + return (0, body, '') if cfg or name in self.ifaces else (1, '', '') + if a[:2] == ['uci', 'set']: + expr = a[2] + if '=' not in expr: + return 0, '', '' + key, _, val = expr.partition('=') + parts = key.split('.') + if len(parts) == 2 and parts[0] == 'wireless' and val == 'wifi-iface': + self.ifaces.setdefault(parts[1], {}) + return 0, '', '' + if len(parts) == 3 and parts[0] == 'wireless' and ( + parts[1] in self.ifaces or parts[1] in ('wlan1open', 'wlan1wpa', 'wlan1ent')): + self.ifaces.setdefault(parts[1], {})[parts[2]] = val + return 0, '', '' + self.uci[key] = val + return 0, '', '' + if a[:2] == ['uci', 'delete']: + key = a[2] + parts = key.split('.') + if len(parts) == 2 and parts[0] == 'wireless': + self.ifaces.pop(parts[1], None) + self.uci.pop(key, None) + return 0, '', '' + if a[:2] == ['uci', 'commit']: + return 0, '', '' + if a[0] in ('wifi', '/etc/init.d/pineapd', 'kill', 'iw'): + return 0, '', '' + return 0, '', '' + + def test_restore_reverts_radio1_ap_and_hop(self): + server.capture_pager_snapshot() + self.assertTrue(os.path.isfile(self.snap)) + self.uci['pineapd.wlan1mon.hop'] = '0' + self.ifaces['wlan1open'] = {'ssid': 'EvilTwin', 'disabled': '0', 'device': 'radio1'} + self.uci['wireless.dummy_radio0.disabled'] = '1' + result = server.restore_pager_truth() + self.assertTrue(result['ok']) + self.assertTrue(result['restored']) + self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1') + self.assertNotIn('wlan1open', self.ifaces) + self.assertEqual(self.uci['wireless.dummy_radio0.disabled'], '0') + self.assertFalse(os.path.isfile(self.snap)) + + def test_restore_without_snapshot_is_safe(self): + result = server.restore_pager_truth() + self.assertTrue(result['ok']) + self.assertFalse(result['restored']) + self.assertEqual(result['reason'], 'no snapshot') + + def test_mode_get_reports_snapshot(self): + server.capture_pager_snapshot() + status, payload = server.h_mode_get(None) + self.assertEqual(status, 200) + self.assertTrue(payload['snapshot']) + self.assertTrue(payload['markviii']) + self.assertEqual(payload['pager_port'], 1471) + + def test_payload_refresh_falls_back_to_disk(self): + tmp = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True)) + payload_dir = tmp + for part in ('user', 'games', 'snake'): + payload_dir = os.path.join(payload_dir, part) + if not os.path.isdir(payload_dir): + os.mkdir(payload_dir) + with open(os.path.join(payload_dir, 'payload.sh'), 'w') as handle: + handle.write('#!/bin/sh\n') + old_roots = server.PAYLOAD_ROOTS + old_daemon = server._payload_daemon + server.PAYLOAD_ROOTS = (tmp,) + server._payload_daemon = lambda *a, **k: (500, {'error': 'portal down'}) + try: + status, data = server.h_payloads_refresh(None) + finally: + server.PAYLOAD_ROOTS = old_roots + server._payload_daemon = old_daemon + self.assertEqual(status, 200) + self.assertEqual(len(data['payloads']), 1) + self.assertEqual(data['payloads'][0]['key'], 'user~games~snake') + self.assertIn('warning', data) + + def test_respawn_keeps_original_snapshot(self): + server.capture_pager_snapshot() + self.uci['pineapd.wlan1mon.hop'] = '0' + server.capture_pager_snapshot() + result = server.restore_pager_truth() + self.assertTrue(result['restored']) + self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1') + + def test_uci_get_keeps_settings_default(self): + self.assertEqual(server._uci_get('missing.key', 'UTC'), 'UTC') + self.assertIsNone(server._uci_get('missing.key')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_status.py b/tests/test_status.py index bedc909..6852519 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -51,6 +51,8 @@ class StatusTest(unittest.TestCase): def fake(args, timeout=20): if args == ['iwinfo']: return 0, 'wlan0 ESSID: "Pineapple"\n', '' + if args[0] == 'iw': + return 1, '', 'busy' if args == ['iwinfo', 'wlan0', 'assoclist']: return 0, '00:11:22:33:44:55 -64 dBm Signal: -64 dBm Rate: 12 Mbit/s\nAA:BB:CC:DD:EE:FF -40 dBm Signal: -40 dBm Rate: 24 Mbit/s\n', '' return 0, '', '' @@ -61,6 +63,23 @@ class StatusTest(unittest.TestCase): self.assertEqual(clients[0]['rssi'], -64) self.assertEqual(clients[0]['iface'], 'wlan0') + def test_assoc_clients_skips_monitor_ifaces(self): + def fake(args, timeout=20): + if args == ['iwinfo']: + return 0, 'wlan0mon ESSID: unknown\nwlan0wpa ESSID: "x"\n', '' + if args[0] == 'iw' and len(args) > 2 and args[2] == 'wlan0mon': + raise AssertionError('must not query monitor ifaces') + if args[:2] == ['iwinfo', 'wlan0mon']: + raise AssertionError('must not query monitor ifaces') + if args == ['iw', 'dev', 'wlan0wpa', 'station', 'dump']: + return 0, 'Station aa:bb:cc:dd:ee:ff (on wlan0wpa)\n\tsignal: -50 dBm\n', '' + return 1, '', '' + server.device_run = fake + clients = server.assoc_clients() + self.assertEqual(len(clients), 1) + self.assertEqual(clients[0]['mac'], 'AA:BB:CC:DD:EE:FF') + self.assertEqual(clients[0]['iface'], 'wlan0wpa') + def test_h_status_shape(self): server.device_run = lambda args, timeout=20: (0, '', '') server.current_token = lambda: 'tok'