fix: non-disruptive startup checks + clean service lifecycle (v1.3.1)

- Env check is read-only when state is sane: no pineapd command-socket
  writes, no live pool-list commits, no wifi reload; pineapd restarts only
  when a runtime-sensitive UCI value changed or the daemon was down
- Failed monitor repairs now fail the startup contract instead of being
  reported as fixed; runtime pool state is read from active config
- Enterprise AP recovery runs only on device boot (PAGER_WEBUI_BOOT), not
  on every web-service restart
- serve() gates the HTTP port on startup checks with bounded retries and
  shuts down cleanly on SIGTERM/SIGINT; the recon watchdog waits
  interruptibly
- Recon uses a bounded userspace channel scheduler that drives both
  monitor radios over non-DFS channels, with preflight verification,
  serialized starts, and per-cycle error reporting
- payload.sh waits for real readiness on start, fully removes the boot
  service (stop + disable + delete) on stop, and surfaces a
  stopped-but-enabled boot service; deploy.sh refreshes the installed
  init script even when the service is stopped
- Bump version to 1.3.1

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-19 12:03:11 -05:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 6e3968c19a
commit cd26553d21
8 changed files with 451 additions and 68 deletions
@@ -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"
@@ -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
}
@@ -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"
+236 -50
View File
@@ -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()