feat: startup environment check + self-heal (v1.3)
- env_check(): verifies daemon/pineapd/UCI/monitors/recon DB, auto-fixes fixable issues and re-verifies; forces runtime SSID-pool broadcast off to match the UI (kills the 'pool on but UI shows off' gap) - server.py --env-check CLI; payload.sh runs it verbosely before starting, aborts on core failure - serve() runs the check at every startup (boot + procd respawn) - /api/health exposes env report + pool_runtime; /api/recon/status exposes wlan0_pinned (2.4GHz under-sampling warning); recon page warns when a radio0 AP pins wlan0mon - recon start failures now include the daemon reason in the UI error - shared stabilization refactor (_stabilize_uci/PINEAPD_SAFE_UCI/_raise_monitors) - tests: test_env_check.py (18) + health/recon updates
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
"title": "Mark VIII",
|
||||
"author": "c4ch3c4d3",
|
||||
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
|
||||
"version": "1.2",
|
||||
"version": "1.3",
|
||||
"category": "remote_access",
|
||||
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
||||
"firmware": "Pineapple Pager 24.10.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Title: Mark VIII
|
||||
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
|
||||
# Author: c4ch3c4d3
|
||||
# Version: 1.1
|
||||
# Version: 1.3
|
||||
# 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.1 |"
|
||||
LOG "cyan" "| Mark VIII v1.3 |"
|
||||
LOG "cyan" "+---------------------------+"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
@@ -36,6 +36,16 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_env_check() {
|
||||
LOG "cyan" "Running environment check..."
|
||||
if ! python3 "$SCRIPT_DIR/server.py" --env-check; then
|
||||
LOG "red" "Environment check FAILED. Fix the issues above and re-run the payload."
|
||||
sleep 3
|
||||
exit 1
|
||||
fi
|
||||
LOG "green" "Environment check passed."
|
||||
}
|
||||
|
||||
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
LOG "green" "Mark VIII service is running"
|
||||
@@ -67,6 +77,7 @@ else
|
||||
fi
|
||||
|
||||
if user_confirmed "$resp"; then
|
||||
run_env_check
|
||||
LOG "cyan" "Starting as background service..."
|
||||
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
|
||||
cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT"
|
||||
@@ -82,6 +93,7 @@ if user_confirmed "$resp"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_env_check
|
||||
LOG "cyan" "Starting foreground mode..."
|
||||
cleanup() {
|
||||
LOG "yellow" "Stopping Mark VIII..."
|
||||
|
||||
@@ -1255,7 +1255,13 @@ def h_recon_start(ctx):
|
||||
# appends a scan without discarding history.
|
||||
status, data = daemon_sock_call('POST', '/api/pineap/recon/new', body=body)
|
||||
if status != 200 or not (data or {}).get('success'):
|
||||
return 502, {'error': 'native recon scan failed', 'detail': data}
|
||||
detail = data if isinstance(data, dict) else {}
|
||||
reason = detail.get('error') or detail.get('detail')
|
||||
if not reason and status == 0:
|
||||
reason = 'daemon unreachable'
|
||||
return 502, {'error': 'native recon scan failed',
|
||||
'detail': ('recon/new: %s' % reason) if reason else None,
|
||||
'daemon': detail or None}
|
||||
_recon_scan_state['active'] = True
|
||||
_recon_scan_state['started'] = time.time()
|
||||
_recon_scan_state['duration'] = scan_time
|
||||
@@ -1352,6 +1358,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(),
|
||||
'wlan0_pinned': _wlan0_pinned(),
|
||||
'history_reset': _recon_history_reset()}
|
||||
|
||||
|
||||
@@ -3502,6 +3509,41 @@ def _sigsegv_count():
|
||||
return out.count('SIGSEGV')
|
||||
|
||||
|
||||
PINEAPD_SAFE_UCI = {
|
||||
'pineapd.@ssidpool[0].disable': '1',
|
||||
'pineapd.wlan2mon.disable': '1',
|
||||
'pineapd.wlan2mon.hop': '0',
|
||||
'pineapd.wlan1mon.bands': '5',
|
||||
'pineapd.wlan0mon.bands': '2',
|
||||
'pineapd.wlan1mon.hop': '0',
|
||||
}
|
||||
|
||||
|
||||
def _apply_uci_wanted(wanted):
|
||||
"""Idempotently apply a wanted UCI key/value set. Returns changed keys."""
|
||||
actions = []
|
||||
for key, value in wanted.items():
|
||||
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])
|
||||
return actions
|
||||
|
||||
|
||||
def _stabilize_uci():
|
||||
"""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."""
|
||||
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 actions:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
return actions
|
||||
|
||||
|
||||
def _stabilize_pineapd():
|
||||
"""Idempotent crash-source pass. Field-verified SIGSEGV/terminate sources
|
||||
on this firmware:
|
||||
@@ -3513,26 +3555,8 @@ def _stabilize_pineapd():
|
||||
4. a large refilled pool (collect refills it; crashes observed even
|
||||
with broadcast disabled)
|
||||
The pool list itself is cleared. Returns what changed."""
|
||||
actions = []
|
||||
wanted = {
|
||||
'pineapd.@ssidpool[0].disable': '1',
|
||||
'pineapd.wlan2mon.disable': '1',
|
||||
'pineapd.wlan2mon.hop': '0',
|
||||
'pineapd.wlan1mon.bands': '5',
|
||||
'pineapd.wlan0mon.bands': '2',
|
||||
'pineapd.wlan1mon.hop': '0',
|
||||
}
|
||||
for key, value in wanted.items():
|
||||
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])
|
||||
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')
|
||||
actions = _stabilize_uci()
|
||||
if actions:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
return 'stabilized: ' + ', '.join(actions)
|
||||
return 'pineapd restart'
|
||||
|
||||
@@ -3589,10 +3613,18 @@ def health_check():
|
||||
return dict(h)
|
||||
|
||||
|
||||
def _bring_monitors_up(h):
|
||||
def _raise_monitors():
|
||||
"""Bring any down monitor interfaces up. Returns the interfaces raised."""
|
||||
raised = []
|
||||
for name in ('wlan1mon', 'wlan0mon'):
|
||||
if _monitor_down(name):
|
||||
device_run(['ip', 'link', 'set', name, 'up'], timeout=10)
|
||||
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
|
||||
|
||||
@@ -3603,6 +3635,15 @@ def h_health(ctx):
|
||||
h['pool_disabled'] = _uci_section('pineapd.@ssidpool[0]').get('disable') == '1'
|
||||
h['wlan1mon_up'] = not _monitor_down('wlan1mon')
|
||||
h['wlan0mon_up'] = not _monitor_down('wlan0mon')
|
||||
h['pool_runtime'] = ENV_CHECK_STATE.get('pool_runtime')
|
||||
if ENV_CHECK_STATE.get('report'):
|
||||
h['env'] = {
|
||||
'overall': ENV_CHECK_STATE['overall'],
|
||||
'updated': ENV_CHECK_STATE['updated'],
|
||||
'counts': {k: _env_count(ENV_CHECK_STATE['report'], k)
|
||||
for k in ('pass', 'fixed', 'warn', 'fail')},
|
||||
'steps': ENV_CHECK_STATE['report'],
|
||||
}
|
||||
return 200, h
|
||||
|
||||
|
||||
@@ -3619,6 +3660,126 @@ def start_health_monitor():
|
||||
threading.Thread(target=_health_loop, daemon=True).start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Startup environment check: run once at service startup (and via
|
||||
# ``server.py --env-check`` on the payload screen) to make the device match
|
||||
# the UI before the user interacts with it.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
ENV_CHECK_STATE = {'report': None, 'overall': None, 'updated': 0, 'pool_runtime': None}
|
||||
|
||||
|
||||
def _env_step(report, ok, detail, action=None):
|
||||
step = {'ok': ok, 'detail': detail}
|
||||
if action:
|
||||
step['action'] = action
|
||||
report.append(step)
|
||||
|
||||
|
||||
def _env_overall(report):
|
||||
if any(r['ok'] == 'fail' for r in report):
|
||||
return 'fail'
|
||||
if any(r['ok'] == 'fixed' for r in report):
|
||||
return 'fixed'
|
||||
if any(r['ok'] == 'warn' for r in report):
|
||||
return 'warn'
|
||||
return 'pass'
|
||||
|
||||
|
||||
def _env_count(report, kind):
|
||||
return sum(1 for r in report if r['ok'] == kind)
|
||||
|
||||
|
||||
def _pineapd_alive():
|
||||
rc, out, err = device_run(['pidof', 'pineapd'], 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'
|
||||
|
||||
|
||||
def _wlan0_pinned():
|
||||
"""True when a radio0 AP (OpenAP/Evil WPA) is enabled. A phy's channel is
|
||||
held by its AP interface, so an enabled radio0 AP pins wlan0mon to one
|
||||
2.4GHz channel and 2.4GHz recon results are under-sampled."""
|
||||
for name in ('wlan0open', 'wlan0wpa'):
|
||||
cfg = _uci_wifi_iface(name) or {}
|
||||
if cfg and cfg.get('disabled') != '1':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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."""
|
||||
report = []
|
||||
|
||||
status, data = daemon_sock_call('GET', '/api/pineap/get_config')
|
||||
if status != 200:
|
||||
_env_step(report, 'fail', 'daemon unreachable (status %s)' % status)
|
||||
else:
|
||||
_env_step(report, 'pass', 'daemon reachable')
|
||||
|
||||
if _pineapd_alive():
|
||||
_env_step(report, 'pass', 'pineapd running')
|
||||
else:
|
||||
actions = _stabilize_uci()
|
||||
device_run(['/etc/init.d/pineapd', 'restart'], timeout=30)
|
||||
if _pineapd_alive():
|
||||
_env_step(report, 'fixed', 'pineapd was down; stabilized and restarted',
|
||||
', '.join(actions) or 'restart')
|
||||
else:
|
||||
_env_step(report, 'fail', 'pineapd did not come back after restart',
|
||||
', '.join(actions) or 'restart')
|
||||
|
||||
actions = _stabilize_uci()
|
||||
if actions:
|
||||
_env_step(report, 'fixed', 'pineapd sane-off UCI defaults applied',
|
||||
', '.join(actions))
|
||||
else:
|
||||
_env_step(report, 'pass', 'pineapd sane-off UCI defaults already set')
|
||||
|
||||
pool_runtime, pool_detail = _sync_pool_runtime()
|
||||
ENV_CHECK_STATE['pool_runtime'] = pool_runtime
|
||||
if pool_runtime == 'disabled':
|
||||
_env_step(report, 'pass', 'SSID-pool broadcast off (runtime)')
|
||||
else:
|
||||
_env_step(report, 'warn', pool_detail)
|
||||
|
||||
raised = _raise_monitors()
|
||||
if raised:
|
||||
_env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised))
|
||||
else:
|
||||
_env_step(report, 'pass', 'monitors up (wlan0mon, wlan1mon)')
|
||||
|
||||
try:
|
||||
rows = _db_rows(RECON_DB, 'SELECT count(*) AS c FROM scan', timeout=15)
|
||||
count = rows[0]['c'] if rows else 0
|
||||
_env_step(report, 'pass', 'recon DB readable (%d scans)' % count)
|
||||
except RuntimeError as exc:
|
||||
_env_step(report, 'fail', 'recon DB unreadable: %s' % exc)
|
||||
|
||||
if _wlan0_pinned():
|
||||
_env_step(report, 'warn', '2.4GHz under-sampled: a radio0 AP is up and pins '
|
||||
'wlan0mon to one channel during scans')
|
||||
else:
|
||||
_env_step(report, 'pass', 'no radio0 AP pins wlan0mon')
|
||||
|
||||
ENV_CHECK_STATE['report'] = report
|
||||
ENV_CHECK_STATE['overall'] = _env_overall(report)
|
||||
ENV_CHECK_STATE['updated'] = time.time()
|
||||
return report
|
||||
|
||||
|
||||
def h_attacks_clients(ctx):
|
||||
"""Clients (recon devices) plus the APs matching an SSID, for targeting."""
|
||||
ssid = ((ctx.query or {}).get('ssid') or '').strip()
|
||||
@@ -5164,6 +5325,10 @@ def _recon_watchdog_loop():
|
||||
|
||||
|
||||
def serve():
|
||||
try:
|
||||
env_check()
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=live_loop, daemon=True).start()
|
||||
threading.Thread(target=_recon_watchdog_loop, daemon=True).start()
|
||||
start_health_monitor()
|
||||
@@ -5177,5 +5342,23 @@ def serve():
|
||||
threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start()
|
||||
|
||||
|
||||
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)
|
||||
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'],
|
||||
counts['warn'], counts['fail']))
|
||||
return 0 if ENV_CHECK_STATE['overall'] != 'fail' else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if '--env-check' in sys.argv:
|
||||
sys.exit(env_check_cli())
|
||||
serve()
|
||||
|
||||
@@ -516,6 +516,9 @@ const Live = (() => {
|
||||
if (h.pineap_up === false) {
|
||||
el.textContent = 'PINEAPD DOWN';
|
||||
el.className = 'health-chip bad';
|
||||
} else if (h.env && h.env.overall === 'fail') {
|
||||
el.textContent = 'ENV CHECK FAIL';
|
||||
el.className = 'health-chip bad';
|
||||
} else if (h.pool_disabled) {
|
||||
el.textContent = 'POOL OFF';
|
||||
el.className = 'health-chip warn';
|
||||
|
||||
@@ -177,7 +177,8 @@ views.dashboard = (root) => {
|
||||
liveCards.health.textContent = (h2.pineap_up ? 'pineapd up' : 'pineapd DOWN') +
|
||||
' · wlan0mon ' + (h2.wlan0mon_up ? 'up' : 'down') +
|
||||
' · wlan1mon ' + (h2.wlan1mon_up ? 'up' : 'down') +
|
||||
(h2.pool_disabled ? ' · pool off' : '');
|
||||
(h2.pool_disabled ? ' · pool off' : '') +
|
||||
((h2.env || {}).overall ? ' · env ' + h2.env.overall : '');
|
||||
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/recon/status').then((r) => {
|
||||
@@ -1460,7 +1461,8 @@ views.recon = (root) => {
|
||||
apBand: 'all', apEnc: 'all', gps: null, wigle: null,
|
||||
compare: [], history: {}, mapBand: null,
|
||||
archive: null, archives: [], scanRemaining: null,
|
||||
hopperOnline: null, historyReset: false, scanErr: null };
|
||||
hopperOnline: null, historyReset: false, scanErr: null,
|
||||
wlan0Pinned: false };
|
||||
const cols = reconLoadCols();
|
||||
|
||||
// ---- title cards (stat cards with optional mini charts) ----
|
||||
@@ -1658,9 +1660,10 @@ 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.scanErr) bits.push(state.scanErr);
|
||||
scanStatus.textContent = bits.join(' · ');
|
||||
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || !!state.scanErr);
|
||||
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || state.wlan0Pinned || !!state.scanErr);
|
||||
}
|
||||
scanToggle.addEventListener('change', () => {
|
||||
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
|
||||
@@ -2522,6 +2525,7 @@ views.recon = (root) => {
|
||||
state.scanRemaining = r.data.scan_remaining != null ? r.data.scan_remaining : null;
|
||||
state.hopperOnline = r.data.hopper_online;
|
||||
state.historyReset = !!r.data.history_reset;
|
||||
state.wlan0Pinned = !!r.data.wlan0_pinned;
|
||||
if (!pendingScan) scanToggle.checked = scanning;
|
||||
renderScanBar();
|
||||
if (wasScanning !== scanning) restartPoll();
|
||||
|
||||
Reference in New Issue
Block a user