diff --git a/payload/user/remote_access/pager-webui/_hak5_manifest.json b/payload/user/remote_access/pager-webui/_hak5_manifest.json index 4dfdc5a..33454bb 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", + "version": "1.3.1", "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 cfa7d33..5ff4cc8 100755 --- a/payload/user/remote_access/pager-webui/pagerwebui.init +++ b/payload/user/remote_access/pager-webui/pagerwebui.init @@ -7,18 +7,24 @@ USE_PROCD=1 PAGER_WEBUI_DIR="/root/payloads/user/remote_access/pager-webui" [ -f "$PAGER_WEBUI_DIR/server.py" ] || PAGER_WEBUI_DIR="/mmc/root/payloads/user/remote_access/pager-webui" +boot() { + PAGER_WEBUI_BOOT=1 + start +} + start_service() { [ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1 chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null procd_open_instance pagerwebui procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py" + procd_set_param env PAGER_WEBUI_BOOT="${PAGER_WEBUI_BOOT:-0}" procd_set_param respawn procd_set_param stdout 1 procd_set_param stderr 1 - procd_set_param pidfile /tmp/pagerwebui.pid + procd_set_param term_timeout 10 procd_close_instance } stop_service() { - rm -f /tmp/pagerwebui.pid + return 0 } diff --git a/payload/user/remote_access/pager-webui/payload.sh b/payload/user/remote_access/pager-webui/payload.sh index 277be54..68f1699 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 +# Version: 1.3.1 # 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 |" +LOG "cyan" "| Mark VIII v1.3.1 |" LOG "cyan" "+---------------------------+" if ! command -v python3 >/dev/null 2>&1; then @@ -46,6 +46,36 @@ run_env_check() { LOG "green" "Environment check passed." } +wait_for_server() { + attempts="${1:-30}" + while [ "$attempts" -gt 0 ]; do + if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + attempts=$((attempts - 1)) + done + return 1 +} + +wait_for_server_stop() { + attempts="${1:-12}" + while [ "$attempts" -gt 0 ]; do + if ! curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + attempts=$((attempts - 1)) + done + return 1 +} + +remove_boot_service() { + "$INIT_SCRIPT" stop 2>/dev/null || true + "$INIT_SCRIPT" disable 2>/dev/null || true + rm -f "$INIT_SCRIPT" +} + if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then PAGER_IP=$(get_pager_ip) LOG "green" "Mark VIII service is running" @@ -53,14 +83,26 @@ if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then resp=$(CONFIRMATION_DIALOG "Stop service?") if user_confirmed "$resp"; then LOG "yellow" "Stopping service..." - "$INIT_SCRIPT" stop - "$INIT_SCRIPT" disable - rm -f "$INIT_SCRIPT" - LOG "cyan" "Service stopped" + remove_boot_service + if ! wait_for_server_stop 12; then + LOG "red" "Service is still listening on port $PORT" + exit 1 + fi + LOG "cyan" "Service stopped and removed from boot" fi exit 0 fi +if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" enabled 2>/dev/null; then + LOG "yellow" "Mark VIII boot service is installed but not running" + resp=$(CONFIRMATION_DIALOG "Remove boot service?") + if user_confirmed "$resp"; then + remove_boot_service + LOG "cyan" "Boot service removed" + exit 0 + fi +fi + AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null) RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_mode 2>/dev/null) @@ -83,8 +125,15 @@ if user_confirmed "$resp"; then cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT" chmod +x "$INIT_SCRIPT" "$INIT_SCRIPT" enable - "$INIT_SCRIPT" start - sleep 1 + if ! "$INIT_SCRIPT" start; then + LOG "red" "Service start command failed" + exit 1 + fi + if ! wait_for_server 90; then + remove_boot_service + LOG "red" "Service failed startup checks; inspect logread" + exit 1 + fi PAGER_IP=$(get_pager_ip) LOG "green" "Service started!" LOG "green" "http://$PAGER_IP:$PORT" @@ -106,10 +155,10 @@ trap cleanup EXIT INT TERM [ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; } python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 & echo $! > "$PID_FILE" -sleep 1 PAGER_IP=$(get_pager_ip) -if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then +if wait_for_server 90 && [ -f "$PID_FILE" ] && + kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then LOG "green" "http://$PAGER_IP:$PORT" LOG "" LOG "magenta" "Press B to stop" diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index f70e51a..b8c9525 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -41,6 +41,15 @@ PORT = int(os.environ.get('PAGER_PORT', '8080')) _recon_scan_state = {'active': False, 'started': 0, 'duration': 0} DEFAULT_RECON_DURATION = 30 +RECON_HOP_INTERVAL = 0.8 +RECON_CHANNELS = { + 'wlan0mon': (1, 6, 11), + # Avoid DFS channels: changing to one can trigger CAC or fail entirely. + 'wlan1mon': (36, 40, 44, 48, 149, 153, 157, 161, 165), +} +_recon_scan_lock = threading.Lock() +_recon_hopper_stop = threading.Event() +_recon_hop_state = {'active': False, 'error': None} _recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}} _recon_status_cache = { 'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None} @@ -1231,6 +1240,11 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None, db=None): 'aps': aps, 'clients': clients, 'handshakes': handshakes, 'unassociated': unassociated} def h_recon_start(ctx): + with _recon_scan_lock: + return _h_recon_start_locked(ctx) + + +def _h_recon_start_locked(ctx): # Serialize starts: the firmware has no abort for a timed scan, so a # second /recon/new while one is running just stacks another empty scan # (the 3s-apart rows we saw). Refuse instead of piling on. @@ -1249,6 +1263,12 @@ def h_recon_start(ctx): return 400, {'error': 'scan_time must be an integer'} if scan_time < 1 or scan_time > 86400: return 400, {'error': 'scan_time is out of range'} + hop_ok, hop_detail = _recon_hopper_preflight() + if not hop_ok: + return 503, { + 'error': 'recon radio preflight failed', + 'detail': hop_detail, + } body = {'scan_time': scan_time} # log/recon/start restarts the recon logger and can rotate the existing # database. recon/new is the Pager's native "start another scan" action and @@ -1265,6 +1285,7 @@ def h_recon_start(ctx): _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} @@ -1301,13 +1322,70 @@ def _recon_watchdog_tick(): st['active'] = False +def _set_monitor_channel(interface, channel): + rc, out, err = device_run( + ['iw', 'dev', interface, 'set', 'channel', str(channel)], timeout=5) + if rc == 0: + return True, '' + detail = (err or out or 'command failed').strip() + 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. + + pineapd can report ``Hop fast`` while both radios remain fixed. Direct + channel changes are the only reliable contract on this Pager firmware. + """ + for interface, channels in RECON_CHANNELS.items(): + for channel in channels: + ok, detail = _set_monitor_channel(interface, channel) + if not ok: + return False, detail + 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}) + try: + while not stop_event.is_set() and time.monotonic() < deadline: + cycle_error = None + for interface, channels in RECON_CHANNELS.items(): + index = offsets[interface] % len(channels) + ok, detail = _set_monitor_channel(interface, channels[index]) + if not ok: + cycle_error = detail + offsets[interface] = index + 1 + _recon_hop_state['error'] = cycle_error + remaining = deadline - time.monotonic() + if remaining <= 0: + break + stop_event.wait(min(RECON_HOP_INTERVAL, remaining)) + finally: + if stop_event is _recon_hopper_stop: + _recon_hop_state['active'] = False + + +def _start_recon_hopper(duration): + global _recon_hopper_stop + _recon_hopper_stop.set() + stop_event = threading.Event() + _recon_hopper_stop = stop_event + thread = threading.Thread( + target=_recon_hopper_loop, args=(duration, stop_event), daemon=True, + name='recon-channel-hopper') + thread.start() + return thread + + _hopper_cache = {'updated': 0, 'online': None} HOPPER_CACHE_SECONDS = 10.0 -HOPPER_IFACE = 'wlan2mon' def _hopper_online(): - """Whether the fast-hopping radio (wlan2mon per pineapd config) exists. + """Whether both monitor interfaces required by the scheduler exist. iwinfo is a subprocess, so the answer is cached for a few seconds; the UI polls status every 5s. @@ -1316,7 +1394,8 @@ def _hopper_online(): if now - _hopper_cache['updated'] < HOPPER_CACHE_SECONDS: return _hopper_cache['online'] try: - online = HOPPER_IFACE in wifi_ifaces() + interfaces = wifi_ifaces() + online = all(name in interfaces for name in RECON_CHANNELS) except Exception: online = None _hopper_cache.update({'updated': now, 'online': online}) @@ -1358,6 +1437,7 @@ def h_recon_status(ctx): 'active': last_activity is not None and int(time.time()) - last_activity < 300, 'scanning': scanning, 'scan_remaining': remaining, 'stale': stale, 'hopper_online': _hopper_online(), + 'hopper_error': _recon_hop_state.get('error'), 'wlan0_pinned': _wlan0_pinned(), 'wlan0_sta': _sta_uplink_enabled(), 'history_reset': _recon_history_reset()} @@ -3518,6 +3598,10 @@ PINEAPD_SAFE_UCI = { 'pineapd.wlan0mon.bands': '2', 'pineapd.wlan1mon.hop': '0', } +PINEAPD_RUNTIME_UCI = { + key for key in PINEAPD_SAFE_UCI if '.wlan' in key +} +PINEAPD_RUNTIME_UCI.add('pineapd.@ssidpool[0].disable') def _apply_uci_wanted(wanted): @@ -3527,19 +3611,30 @@ def _apply_uci_wanted(wanted): rc, out, err = device_run(['uci', 'get', key]) if rc != 0 or out.strip() != value: device_run(['uci', 'set', '%s=%s' % (key, value)]) - actions.append(key.split('.')[-1]) + actions.append(key) return actions -def _stabilize_uci(): +def _pending_uci(wanted): + pending = [] + for key, value in wanted.items(): + rc, out, err = device_run(['uci', 'get', key]) + if rc != 0 or out.strip() != value: + pending.append(key) + return pending + + +def _stabilize_uci(clear_pool=True): """Sane-off UCI pass shared by the health monitor and the startup env - check. Clears the refilled pool list too. Commits when changed and returns - the action labels.""" + 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.""" actions = _apply_uci_wanted(PINEAPD_SAFE_UCI) - rc, out, err = device_run(['uci', 'get', 'pineapd.@ssidpool[0].ssid']) - if rc == 0 and out.strip(): - device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid']) - actions.append('pool-list cleared') + if clear_pool: + rc, out, err = device_run(['uci', 'get', 'pineapd.@ssidpool[0].ssid']) + if rc == 0 and out.strip(): + device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid']) + actions.append('pool-list cleared') if actions: device_run(['uci', 'commit', 'pineapd']) return actions @@ -3615,19 +3710,27 @@ def health_check(): def _raise_monitors(): - """Bring any down monitor interfaces up. Returns the interfaces raised.""" + """Bring down monitor interfaces up and return those verified up.""" raised = [] for name in ('wlan1mon', 'wlan0mon'): if _monitor_down(name): - device_run(['ip', 'link', 'set', name, 'up'], timeout=10) - raised.append(name) + rc, out, err = device_run( + ['ip', 'link', 'set', name, 'up'], timeout=10) + if rc == 0 and not _monitor_down(name): + raised.append(name) return raised def _bring_monitors_up(h): - _raise_monitors() - h['last_action'] = 'monitor interfaces brought up' - h['monitor_fixes'] = h.get('monitor_fixes', 0) + 1 + raised = _raise_monitors() + unavailable = [ + name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)] + if unavailable: + h['last_action'] = 'monitor repair failed: ' + ', '.join(unavailable) + else: + h['last_action'] = 'monitor interfaces brought up' + if raised: + h['monitor_fixes'] = h.get('monitor_fixes', 0) + len(raised) def h_health(ctx): @@ -3668,6 +3771,8 @@ def start_health_monitor(): # -------------------------------------------------------------------------- ENV_CHECK_STATE = {'report': None, 'overall': None, 'updated': 0, 'pool_runtime': None} +STARTUP_CHECK_ATTEMPTS = 6 +STARTUP_CHECK_DELAY = 5 def _env_step(report, ok, detail, action=None): @@ -3696,14 +3801,29 @@ def _pineapd_alive(): return rc == 0 and bool((out or '').strip()) +def _daemon_alive(): + """Passive stock-daemon readiness check. + + Even a config GET makes the stock daemon write to pineapd's command + socket on this firmware, which can trigger its watchdog. Process state is + the safe startup contract; functional API calls report their own failures. + """ + rc, out, err = device_run(['pidof', 'pineapple'], timeout=10) + return rc == 0 and bool((out or '').strip()) + + def _sync_pool_runtime(): - """Force pineapd's runtime SSID-pool broadcast off so it matches the - sane-off UCI default the UI derives from. Returns (state, detail).""" - rc, out, err = _pineap('SSIDPOOL', 'DISABLE', timeout=15) - if rc != 0: - detail = (err or out or '').strip() or 'no output' - return 'unknown', 'SSIDPOOL DISABLE failed: %s' % detail[-200:] - return 'disabled', 'SSIDPOOL DISABLE sent' + """Report the crash-safe pool state without writing pineapd's socket. + + 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. + """ + pool = _uci_section('pineapd.@ssidpool[0]') + if pool.get('disable') == '1': + return 'disabled', 'SSID-pool broadcast disabled by active configuration' + return 'unknown', 'SSID-pool disable setting is not active' def _wlan0_pinned(): @@ -3739,29 +3859,36 @@ 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). Only core - dependencies (daemon, pineapd, recon DB) can fail.""" + returns a list of step reports (ok: pass|fixed|warn|fail). Core + dependencies (daemon, pineapd, monitors, recon DB) can fail.""" report = [] - status, data = daemon_sock_call('GET', '/api/pineap/get_config') - if status != 200: - _env_step(report, 'fail', 'daemon unreachable (status %s)' % status) + if not _daemon_alive(): + _env_step(report, 'fail', 'daemon unreachable (process not running)') else: _env_step(report, 'pass', 'daemon reachable') - if _pineapd_alive(): - _env_step(report, 'pass', 'pineapd running') - else: - actions = _stabilize_uci() + 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: device_run(['/etc/init.d/pineapd', 'restart'], timeout=30) if _pineapd_alive(): - _env_step(report, 'fixed', 'pineapd was down; stabilized and restarted', + reason = ('runtime safety settings changed' if runtime_changed + else 'pineapd was down') + _env_step(report, 'fixed', '%s; pineapd restarted' % reason, ', '.join(actions) or 'restart') else: _env_step(report, 'fail', 'pineapd did not come back after restart', ', '.join(actions) or 'restart') + else: + _env_step(report, 'pass', 'pineapd running') - actions = _stabilize_uci() if actions: _env_step(report, 'fixed', 'pineapd sane-off UCI defaults applied', ', '.join(actions)) @@ -3782,8 +3909,15 @@ def env_check(): else: _env_step(report, 'pass', 'no STA uplink pinning phy0') + monitors_before = [ + name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)] raised = _raise_monitors() - if raised: + monitors_down = [ + name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)] + if monitors_down: + _env_step(report, 'fail', 'monitor interfaces unavailable after repair: %s' % + ', '.join(monitors_down)) + elif monitors_before: _env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised)) else: _env_step(report, 'pass', 'monitors up (wlan0mon, wlan1mon)') @@ -3807,6 +3941,36 @@ def env_check(): return report +def startup_env_check(attempts=STARTUP_CHECK_ATTEMPTS, + delay=STARTUP_CHECK_DELAY): + """Run and print the startup contract, allowing boot dependencies time. + + A failed core dependency must prevent the HTTP server from presenting a + healthy-looking UI. procd can then respawn it instead of leaving a broken + process bound to the port. + """ + last_report = [] + for attempt in range(1, attempts + 1): + try: + last_report = env_check() + except Exception as exc: + last_report = [] + ENV_CHECK_STATE['overall'] = 'fail' + print('[FAIL] environment check raised: %s' % exc, flush=True) + _print_env_report(last_report, flush=True) + if ENV_CHECK_STATE.get('overall') != 'fail': + print('ENVIRONMENT CHECK: %s' % + ENV_CHECK_STATE['overall'].upper(), flush=True) + return last_report + if attempt < attempts: + print('ENVIRONMENT CHECK: FAIL; retry %d/%d in %ds' % + (attempt + 1, attempts, delay), flush=True) + if LIVE_STOP.wait(delay): + raise RuntimeError('startup environment check interrupted') + raise RuntimeError('startup environment check failed after %d attempts' % + attempts) + + def h_attacks_clients(ctx): """Clients (recon devices) plus the APs matching an SSID, for targeting.""" ssid = ((ctx.query or {}).get('ssid') or '').strip() @@ -5347,37 +5511,49 @@ def _handle_conn(conn, addr): def _recon_watchdog_loop(): while not LIVE_STOP.is_set(): - time.sleep(1) + LIVE_STOP.wait(1) _recon_watchdog_tick() +def _request_shutdown(signum=None, frame=None): + LIVE_STOP.set() + HEALTH_STOP.set() + _recon_hopper_stop.set() + + def serve(): - try: - env_check() - except Exception: - pass + LIVE_STOP.clear() + HEALTH_STOP.clear() + _recon_hopper_stop.set() + startup_env_check() threading.Thread(target=live_loop, daemon=True).start() threading.Thread(target=_recon_watchdog_loop, daemon=True).start() start_health_monitor() - _enterprise_boot_recover() + if os.environ.get('PAGER_WEBUI_BOOT') == '1': + _enterprise_boot_recover() sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((HOST, PORT)) sock.listen(16) - while True: - conn, addr = sock.accept() - threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start() + sock.settimeout(1) + try: + while not LIVE_STOP.is_set(): + try: + conn, addr = sock.accept() + except socket.timeout: + continue + threading.Thread( + target=_handle_conn, args=(conn, addr), daemon=True).start() + finally: + sock.close() + _request_shutdown() def env_check_cli(): """Run the environment check and print a verbose report to stdout. Returns the process exit code (0 = pass/warn, 1 = core failure).""" report = env_check() - for step in report: - line = '[%s] %s' % (step['ok'].upper(), step['detail']) - if step.get('action'): - line += ' (%s)' % step['action'] - print(line) + _print_env_report(report) counts = {k: _env_count(report, k) for k in ('pass', 'fixed', 'warn', 'fail')} print('ENVIRONMENT CHECK: %s (%d pass, %d fixed, %d warn, %d fail)' % ( ENV_CHECK_STATE['overall'].upper(), counts['pass'], counts['fixed'], @@ -5385,7 +5561,17 @@ def env_check_cli(): return 0 if ENV_CHECK_STATE['overall'] != 'fail' else 1 +def _print_env_report(report, flush=False): + for step in report: + line = '[%s] %s' % (step['ok'].upper(), step['detail']) + if step.get('action'): + line += ' (%s)' % step['action'] + print(line, flush=flush) + + if __name__ == '__main__': if '--env-check' in sys.argv: sys.exit(env_check_cli()) + signal.signal(signal.SIGTERM, _request_shutdown) + signal.signal(signal.SIGINT, _request_shutdown) serve() diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 219cc36..9d7c96f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -151,7 +151,15 @@ 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 /etc/init.d/pagerwebui restart fi echo EXTRACT_OK" diff --git a/tests/test_env_check.py b/tests/test_env_check.py index 0b86ea2..35f8bc9 100644 --- a/tests/test_env_check.py +++ b/tests/test_env_check.py @@ -28,6 +28,7 @@ class EnvCheckTest(unittest.TestCase): self.runs = [] self.ping_ok = True self.daemon_ok = True + self.ip_link_ok = True self.iface_up = {'wlan0mon': True, 'wlan1mon': True} self.uci_state = {} server.ENV_CHECK_STATE.update({'report': None, 'overall': None, 'updated': 0, @@ -56,6 +57,8 @@ class EnvCheckTest(unittest.TestCase): def fake_run(self, args, timeout=20, input_data=None): self.runs.append(list(args)) a = list(args) + if a[0] == 'pidof' and a[1] == 'pineapple': + return (0, '23456\n', '') if self.daemon_ok else (1, '', '') if a[0] == 'pidof' and a[1] == 'pineapd': return (0, '12345\n', '') if self.ping_ok else (1, '', '') if a[0] == 'uci': @@ -78,6 +81,11 @@ class EnvCheckTest(unittest.TestCase): if k.startswith(sec + '.')), '') if a[0] == '_pineap': return (0, '', '') + if a[:3] == ['ip', 'link', 'set']: + if self.ip_link_ok: + self.iface_up[a[3]] = True + return (0, '', '') + return (1, '', 'interface unavailable') if a[0] in ('ip', '/etc/init.d/pineapd'): return (0, '', '') return (0, '', '') @@ -96,11 +104,15 @@ class EnvCheckTest(unittest.TestCase): self.assertEqual([r['ok'] for r in report], ['pass'] * len(report)) self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled') - self.assertIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs) + self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs) def test_applies_sane_defaults_when_missing(self): 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) @@ -109,12 +121,13 @@ class EnvCheckTest(unittest.TestCase): report = server.env_check() self.assertEqual(self.steps(report, 'sane-off UCI defaults already set')[0]['ok'], 'pass') - def test_clears_refilled_pool_list(self): + def test_does_not_commit_refilled_pool_while_live(self): + self.safe_set() self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg==' report = server.env_check() actions = ' | '.join((r.get('action') or '') for r in report) - self.assertIn('pool-list cleared', actions) - self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state) + self.assertNotIn('pool-list cleared', actions) + self.assertIn('pineapd.@ssidpool[0].ssid', self.uci_state) def test_restarts_pineapd_when_down(self): self.safe_set() @@ -154,6 +167,15 @@ class EnvCheckTest(unittest.TestCase): self.assertEqual(self.steps(report, 'monitor interfaces brought up')[0]['ok'], 'fixed') self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], self.runs) + def test_unavailable_monitor_fails_startup_contract(self): + self.safe_set() + self.iface_up = {'wlan0mon': False, 'wlan1mon': True} + self.ip_link_ok = False + report = server.env_check() + step = self.steps(report, 'monitor interfaces unavailable')[0] + self.assertEqual(step['ok'], 'fail') + self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail') + def test_monitors_up_pass(self): self.safe_set() report = server.env_check() @@ -240,6 +262,45 @@ class EnvCheckTest(unittest.TestCase): self.assertEqual(code, 1) self.assertIn('[FAIL]', buf.getvalue()) + def test_startup_check_retries_core_failure(self): + reports = [ + [{'ok': 'fail', 'detail': 'daemon unreachable'}], + [{'ok': 'pass', 'detail': 'daemon reachable'}], + ] + old_check = server.env_check + old_sleep = server.time.sleep + + def check(): + report = reports.pop(0) + server.ENV_CHECK_STATE['overall'] = report[0]['ok'] + return report + + server.env_check = check + server.time.sleep = lambda seconds: None + try: + result = server.startup_env_check(attempts=2, delay=0) + finally: + server.env_check = old_check + server.time.sleep = old_sleep + self.assertEqual(result[0]['ok'], 'pass') + + def test_startup_check_raises_after_retries(self): + old_check = server.env_check + old_sleep = server.time.sleep + + def check(): + server.ENV_CHECK_STATE['overall'] = 'fail' + return [{'ok': 'fail', 'detail': 'daemon unreachable'}] + + server.env_check = check + server.time.sleep = lambda seconds: None + try: + with self.assertRaises(RuntimeError): + server.startup_env_check(attempts=2, delay=0) + finally: + server.env_check = old_check + server.time.sleep = old_sleep + def test_health_exposes_env_and_pool_runtime(self): self.safe_set() server.env_check() diff --git a/tests/test_health.py b/tests/test_health.py index c3ac3db..2ff8e46 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -14,6 +14,7 @@ class HealthCheckTest(unittest.TestCase): def setUp(self): self.runs = [] self.ping_ok = True + self.ip_link_ok = True self.sigsegvs = 0 self.iface_up = {'wlan0mon': True, 'wlan1mon': True} self.uci_state = {} @@ -32,6 +33,11 @@ class HealthCheckTest(unittest.TestCase): return (1, '', '') if a[0] == 'logread': return (0, 'SIGSEGV\n' * self.sigsegs if hasattr(self, 'sigsegs') else '', '') + if a[:3] == ['ip', 'link', 'set']: + if self.ip_link_ok: + self.iface_up[a[3]] = True + return (0, '', '') + return (1, '', 'interface unavailable') if a[:2] == ['uci', 'set']: k, _, v = a[2].partition('=') self.uci_state[k] = v diff --git a/tests/test_recon.py b/tests/test_recon.py index cdcad9c..6f7489d 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -162,10 +162,56 @@ class FakeSock: pass +class ReconHopperTest(unittest.TestCase): + def test_preflight_verifies_every_non_dfs_channel(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')) + expected = [ + (interface, channel) + for interface, channels in server.RECON_CHANNELS.items() + for channel in channels + ] + self.assertEqual(calls, expected) + + def test_preflight_stops_at_first_unusable_channel(self): + def set_channel(interface, channel): + if interface == 'wlan1mon' and channel == 44: + return False, 'wlan1mon channel 44: busy' + return True, '' + + with mock.patch.object( + server, '_set_monitor_channel', side_effect=set_channel): + ok, detail = server._recon_hopper_preflight() + self.assertFalse(ok) + self.assertIn('wlan1mon channel 44', detail) + + def test_set_channel_surfaces_iw_failure(self): + with mock.patch.object( + server, 'device_run', + return_value=(240, '', 'Device or resource busy')): + ok, detail = server._set_monitor_channel('wlan0mon', 6) + self.assertFalse(ok) + self.assertIn('wlan0mon channel 6', detail) + self.assertIn('Device or resource busy', detail) + + class DaemonSockTest(unittest.TestCase): def setUp(self): # h_recon_start now reads shared scan state; keep these isolated. server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0} + preflight = mock.patch.object( + server, '_recon_hopper_preflight', return_value=(True, 'ready')) + start = mock.patch.object(server, '_start_recon_hopper') + preflight.start() + start.start() + self.addCleanup(preflight.stop) + self.addCleanup(start.stop) def test_socket_call_posts_json_to_sock(self): server.DAEMON_SOCK = '/tmp/api.sock' @@ -201,6 +247,7 @@ class DaemonSockTest(unittest.TestCase): status, data = server.h_recon_start(ctx) self.assertEqual(status, 200) self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60})) + server._start_recon_hopper.assert_called_once_with(60) def test_start_defaults_empty_body(self): calls = [] @@ -228,6 +275,18 @@ class DaemonSockTest(unittest.TestCase): self.assertEqual(data['daemon'], {'error': 'no radio'}) self.assertFalse(server._recon_scan_state['active']) + def test_start_reports_hopper_preflight_failure(self): + calls = [] + server._recon_hopper_preflight.return_value = ( + False, 'wlan1mon channel 36: Device or resource busy') + 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(calls, []) + class ReconScanStateTest(unittest.TestCase): """The webui mirrors the duration of the Pager's native timed scan.""" @@ -236,6 +295,13 @@ class ReconScanStateTest(unittest.TestCase): self.db = make_db() server.RECON_DB = self.db server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0} + preflight = mock.patch.object( + server, '_recon_hopper_preflight', return_value=(True, 'ready')) + start = mock.patch.object(server, '_start_recon_hopper') + preflight.start() + start.start() + self.addCleanup(preflight.stop) + self.addCleanup(start.stop) def tearDown(self): os.unlink(self.db) @@ -368,12 +434,13 @@ class ReconExtrasTest(unittest.TestCase): status, data = server.h_recon_status(type('C', (), {'args': ()})()) self.assertEqual(status, 200) self.assertFalse(data['hopper_online']) + self.assertIn('hopper_error', data) self.assertTrue(data['history_reset']) def test_hopper_online_cached(self): server._hopper_cache.update({'updated': 0, 'online': None}) with mock.patch.object(server, 'wifi_ifaces', - return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']): + return_value=['wlan0mon', 'wlan1mon']): self.assertTrue(server._hopper_online()) # Second call within the cache window must not re-run iwinfo. with mock.patch.object(server, 'wifi_ifaces',