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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
6e3968c19a
commit
cd26553d21
@@ -8,7 +8,7 @@
|
|||||||
"title": "Mark VIII",
|
"title": "Mark VIII",
|
||||||
"author": "c4ch3c4d3",
|
"author": "c4ch3c4d3",
|
||||||
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
|
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
|
||||||
"version": "1.3",
|
"version": "1.3.1",
|
||||||
"category": "remote_access",
|
"category": "remote_access",
|
||||||
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
||||||
"firmware": "Pineapple Pager 24.10.1"
|
"firmware": "Pineapple Pager 24.10.1"
|
||||||
|
|||||||
@@ -7,18 +7,24 @@ USE_PROCD=1
|
|||||||
PAGER_WEBUI_DIR="/root/payloads/user/remote_access/pager-webui"
|
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"
|
[ -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() {
|
start_service() {
|
||||||
[ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1
|
[ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1
|
||||||
chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null
|
chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null
|
||||||
procd_open_instance pagerwebui
|
procd_open_instance pagerwebui
|
||||||
procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py"
|
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 respawn
|
||||||
procd_set_param stdout 1
|
procd_set_param stdout 1
|
||||||
procd_set_param stderr 1
|
procd_set_param stderr 1
|
||||||
procd_set_param pidfile /tmp/pagerwebui.pid
|
procd_set_param term_timeout 10
|
||||||
procd_close_instance
|
procd_close_instance
|
||||||
}
|
}
|
||||||
|
|
||||||
stop_service() {
|
stop_service() {
|
||||||
rm -f /tmp/pagerwebui.pid
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Title: Mark VIII
|
# Title: Mark VIII
|
||||||
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
|
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
|
||||||
# Author: c4ch3c4d3
|
# Author: c4ch3c4d3
|
||||||
# Version: 1.3
|
# Version: 1.3.1
|
||||||
# Category: Remote-Access
|
# Category: Remote-Access
|
||||||
# Tags: remote-access, web-interface, device-management, pineap
|
# Tags: remote-access, web-interface, device-management, pineap
|
||||||
# Firmware: Pineapple Pager 24.10.1
|
# Firmware: Pineapple Pager 24.10.1
|
||||||
@@ -28,7 +28,7 @@ get_pager_ip() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LOG "cyan" "+---------------------------+"
|
LOG "cyan" "+---------------------------+"
|
||||||
LOG "cyan" "| Mark VIII v1.3 |"
|
LOG "cyan" "| Mark VIII v1.3.1 |"
|
||||||
LOG "cyan" "+---------------------------+"
|
LOG "cyan" "+---------------------------+"
|
||||||
|
|
||||||
if ! command -v python3 >/dev/null 2>&1; then
|
if ! command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -46,6 +46,36 @@ run_env_check() {
|
|||||||
LOG "green" "Environment check passed."
|
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
|
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||||
PAGER_IP=$(get_pager_ip)
|
PAGER_IP=$(get_pager_ip)
|
||||||
LOG "green" "Mark VIII service is running"
|
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?")
|
resp=$(CONFIRMATION_DIALOG "Stop service?")
|
||||||
if user_confirmed "$resp"; then
|
if user_confirmed "$resp"; then
|
||||||
LOG "yellow" "Stopping service..."
|
LOG "yellow" "Stopping service..."
|
||||||
"$INIT_SCRIPT" stop
|
remove_boot_service
|
||||||
"$INIT_SCRIPT" disable
|
if ! wait_for_server_stop 12; then
|
||||||
rm -f "$INIT_SCRIPT"
|
LOG "red" "Service is still listening on port $PORT"
|
||||||
LOG "cyan" "Service stopped"
|
exit 1
|
||||||
|
fi
|
||||||
|
LOG "cyan" "Service stopped and removed from boot"
|
||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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)
|
AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null)
|
||||||
RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_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"
|
cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT"
|
||||||
chmod +x "$INIT_SCRIPT"
|
chmod +x "$INIT_SCRIPT"
|
||||||
"$INIT_SCRIPT" enable
|
"$INIT_SCRIPT" enable
|
||||||
"$INIT_SCRIPT" start
|
if ! "$INIT_SCRIPT" start; then
|
||||||
sleep 1
|
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)
|
PAGER_IP=$(get_pager_ip)
|
||||||
LOG "green" "Service started!"
|
LOG "green" "Service started!"
|
||||||
LOG "green" "http://$PAGER_IP:$PORT"
|
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; }
|
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
|
||||||
python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 &
|
python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 &
|
||||||
echo $! > "$PID_FILE"
|
echo $! > "$PID_FILE"
|
||||||
sleep 1
|
|
||||||
|
|
||||||
PAGER_IP=$(get_pager_ip)
|
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 "green" "http://$PAGER_IP:$PORT"
|
||||||
LOG ""
|
LOG ""
|
||||||
LOG "magenta" "Press B to stop"
|
LOG "magenta" "Press B to stop"
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ PORT = int(os.environ.get('PAGER_PORT', '8080'))
|
|||||||
|
|
||||||
_recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
_recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
||||||
DEFAULT_RECON_DURATION = 30
|
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_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
|
||||||
_recon_status_cache = {
|
_recon_status_cache = {
|
||||||
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
|
'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,
|
'aps': aps, 'clients': clients, 'handshakes': handshakes,
|
||||||
'unassociated': unassociated}
|
'unassociated': unassociated}
|
||||||
def h_recon_start(ctx):
|
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
|
# 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
|
# second /recon/new while one is running just stacks another empty scan
|
||||||
# (the 3s-apart rows we saw). Refuse instead of piling on.
|
# (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'}
|
return 400, {'error': 'scan_time must be an integer'}
|
||||||
if scan_time < 1 or scan_time > 86400:
|
if scan_time < 1 or scan_time > 86400:
|
||||||
return 400, {'error': 'scan_time is out of range'}
|
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}
|
body = {'scan_time': scan_time}
|
||||||
# log/recon/start restarts the recon logger and can rotate the existing
|
# 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
|
# 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['active'] = True
|
||||||
_recon_scan_state['started'] = time.time()
|
_recon_scan_state['started'] = time.time()
|
||||||
_recon_scan_state['duration'] = scan_time
|
_recon_scan_state['duration'] = scan_time
|
||||||
|
_start_recon_hopper(scan_time)
|
||||||
return 200, {'ok': True}
|
return 200, {'ok': True}
|
||||||
|
|
||||||
|
|
||||||
@@ -1301,13 +1322,70 @@ def _recon_watchdog_tick():
|
|||||||
st['active'] = False
|
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 = {'updated': 0, 'online': None}
|
||||||
HOPPER_CACHE_SECONDS = 10.0
|
HOPPER_CACHE_SECONDS = 10.0
|
||||||
HOPPER_IFACE = 'wlan2mon'
|
|
||||||
|
|
||||||
|
|
||||||
def _hopper_online():
|
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
|
iwinfo is a subprocess, so the answer is cached for a few seconds; the
|
||||||
UI polls status every 5s.
|
UI polls status every 5s.
|
||||||
@@ -1316,7 +1394,8 @@ def _hopper_online():
|
|||||||
if now - _hopper_cache['updated'] < HOPPER_CACHE_SECONDS:
|
if now - _hopper_cache['updated'] < HOPPER_CACHE_SECONDS:
|
||||||
return _hopper_cache['online']
|
return _hopper_cache['online']
|
||||||
try:
|
try:
|
||||||
online = HOPPER_IFACE in wifi_ifaces()
|
interfaces = wifi_ifaces()
|
||||||
|
online = all(name in interfaces for name in RECON_CHANNELS)
|
||||||
except Exception:
|
except Exception:
|
||||||
online = None
|
online = None
|
||||||
_hopper_cache.update({'updated': now, 'online': online})
|
_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,
|
'active': last_activity is not None and int(time.time()) - last_activity < 300,
|
||||||
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale,
|
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale,
|
||||||
'hopper_online': _hopper_online(),
|
'hopper_online': _hopper_online(),
|
||||||
|
'hopper_error': _recon_hop_state.get('error'),
|
||||||
'wlan0_pinned': _wlan0_pinned(),
|
'wlan0_pinned': _wlan0_pinned(),
|
||||||
'wlan0_sta': _sta_uplink_enabled(),
|
'wlan0_sta': _sta_uplink_enabled(),
|
||||||
'history_reset': _recon_history_reset()}
|
'history_reset': _recon_history_reset()}
|
||||||
@@ -3518,6 +3598,10 @@ PINEAPD_SAFE_UCI = {
|
|||||||
'pineapd.wlan0mon.bands': '2',
|
'pineapd.wlan0mon.bands': '2',
|
||||||
'pineapd.wlan1mon.hop': '0',
|
'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):
|
def _apply_uci_wanted(wanted):
|
||||||
@@ -3527,19 +3611,30 @@ def _apply_uci_wanted(wanted):
|
|||||||
rc, out, err = device_run(['uci', 'get', key])
|
rc, out, err = device_run(['uci', 'get', key])
|
||||||
if rc != 0 or out.strip() != value:
|
if rc != 0 or out.strip() != value:
|
||||||
device_run(['uci', 'set', '%s=%s' % (key, value)])
|
device_run(['uci', 'set', '%s=%s' % (key, value)])
|
||||||
actions.append(key.split('.')[-1])
|
actions.append(key)
|
||||||
return actions
|
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
|
"""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
|
check. Clear the refilled pool only while pineapd is down or undergoing a
|
||||||
the action labels."""
|
controlled restart; committing that list while it is live makes the stock
|
||||||
|
watchdog SIGTERM pineapd."""
|
||||||
actions = _apply_uci_wanted(PINEAPD_SAFE_UCI)
|
actions = _apply_uci_wanted(PINEAPD_SAFE_UCI)
|
||||||
rc, out, err = device_run(['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
if clear_pool:
|
||||||
if rc == 0 and out.strip():
|
rc, out, err = device_run(['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
||||||
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
if rc == 0 and out.strip():
|
||||||
actions.append('pool-list cleared')
|
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
||||||
|
actions.append('pool-list cleared')
|
||||||
if actions:
|
if actions:
|
||||||
device_run(['uci', 'commit', 'pineapd'])
|
device_run(['uci', 'commit', 'pineapd'])
|
||||||
return actions
|
return actions
|
||||||
@@ -3615,19 +3710,27 @@ def health_check():
|
|||||||
|
|
||||||
|
|
||||||
def _raise_monitors():
|
def _raise_monitors():
|
||||||
"""Bring any down monitor interfaces up. Returns the interfaces raised."""
|
"""Bring down monitor interfaces up and return those verified up."""
|
||||||
raised = []
|
raised = []
|
||||||
for name in ('wlan1mon', 'wlan0mon'):
|
for name in ('wlan1mon', 'wlan0mon'):
|
||||||
if _monitor_down(name):
|
if _monitor_down(name):
|
||||||
device_run(['ip', 'link', 'set', name, 'up'], timeout=10)
|
rc, out, err = device_run(
|
||||||
raised.append(name)
|
['ip', 'link', 'set', name, 'up'], timeout=10)
|
||||||
|
if rc == 0 and not _monitor_down(name):
|
||||||
|
raised.append(name)
|
||||||
return raised
|
return raised
|
||||||
|
|
||||||
|
|
||||||
def _bring_monitors_up(h):
|
def _bring_monitors_up(h):
|
||||||
_raise_monitors()
|
raised = _raise_monitors()
|
||||||
h['last_action'] = 'monitor interfaces brought up'
|
unavailable = [
|
||||||
h['monitor_fixes'] = h.get('monitor_fixes', 0) + 1
|
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):
|
def h_health(ctx):
|
||||||
@@ -3668,6 +3771,8 @@ def start_health_monitor():
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
ENV_CHECK_STATE = {'report': None, 'overall': None, 'updated': 0, 'pool_runtime': None}
|
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):
|
def _env_step(report, ok, detail, action=None):
|
||||||
@@ -3696,14 +3801,29 @@ def _pineapd_alive():
|
|||||||
return rc == 0 and bool((out or '').strip())
|
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():
|
def _sync_pool_runtime():
|
||||||
"""Force pineapd's runtime SSID-pool broadcast off so it matches the
|
"""Report the crash-safe pool state without writing pineapd's socket.
|
||||||
sane-off UCI default the UI derives from. Returns (state, detail)."""
|
|
||||||
rc, out, err = _pineap('SSIDPOOL', 'DISABLE', timeout=15)
|
Runtime-sensitive UCI changes cause a controlled pineapd restart in
|
||||||
if rc != 0:
|
``env_check``. When UCI is already safe, writing either directly or
|
||||||
detail = (err or out or '').strip() or 'no output'
|
through the stock daemon races its own command traffic and makes its
|
||||||
return 'unknown', 'SSIDPOOL DISABLE failed: %s' % detail[-200:]
|
watchdog SIGTERM pineapd.
|
||||||
return 'disabled', 'SSIDPOOL DISABLE sent'
|
"""
|
||||||
|
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():
|
def _wlan0_pinned():
|
||||||
@@ -3739,29 +3859,36 @@ def _disable_sta_uplink():
|
|||||||
def env_check():
|
def env_check():
|
||||||
"""Full environment pass, run at service startup and via
|
"""Full environment pass, run at service startup and via
|
||||||
``server.py --env-check``. Auto-fixes everything fixable, re-verifies, and
|
``server.py --env-check``. Auto-fixes everything fixable, re-verifies, and
|
||||||
returns a list of step reports (ok: pass|fixed|warn|fail). Only core
|
returns a list of step reports (ok: pass|fixed|warn|fail). Core
|
||||||
dependencies (daemon, pineapd, recon DB) can fail."""
|
dependencies (daemon, pineapd, monitors, recon DB) can fail."""
|
||||||
report = []
|
report = []
|
||||||
|
|
||||||
status, data = daemon_sock_call('GET', '/api/pineap/get_config')
|
if not _daemon_alive():
|
||||||
if status != 200:
|
_env_step(report, 'fail', 'daemon unreachable (process not running)')
|
||||||
_env_step(report, 'fail', 'daemon unreachable (status %s)' % status)
|
|
||||||
else:
|
else:
|
||||||
_env_step(report, 'pass', 'daemon reachable')
|
_env_step(report, 'pass', 'daemon reachable')
|
||||||
|
|
||||||
if _pineapd_alive():
|
pineap_was_alive = _pineapd_alive()
|
||||||
_env_step(report, 'pass', 'pineapd running')
|
pending = _pending_uci(PINEAPD_SAFE_UCI)
|
||||||
else:
|
runtime_changed = any(action in PINEAPD_RUNTIME_UCI
|
||||||
actions = _stabilize_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)
|
device_run(['/etc/init.d/pineapd', 'restart'], timeout=30)
|
||||||
if _pineapd_alive():
|
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')
|
', '.join(actions) or 'restart')
|
||||||
else:
|
else:
|
||||||
_env_step(report, 'fail', 'pineapd did not come back after restart',
|
_env_step(report, 'fail', 'pineapd did not come back after restart',
|
||||||
', '.join(actions) or 'restart')
|
', '.join(actions) or 'restart')
|
||||||
|
else:
|
||||||
|
_env_step(report, 'pass', 'pineapd running')
|
||||||
|
|
||||||
actions = _stabilize_uci()
|
|
||||||
if actions:
|
if actions:
|
||||||
_env_step(report, 'fixed', 'pineapd sane-off UCI defaults applied',
|
_env_step(report, 'fixed', 'pineapd sane-off UCI defaults applied',
|
||||||
', '.join(actions))
|
', '.join(actions))
|
||||||
@@ -3782,8 +3909,15 @@ def env_check():
|
|||||||
else:
|
else:
|
||||||
_env_step(report, 'pass', 'no STA uplink pinning phy0')
|
_env_step(report, 'pass', 'no STA uplink pinning phy0')
|
||||||
|
|
||||||
|
monitors_before = [
|
||||||
|
name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)]
|
||||||
raised = _raise_monitors()
|
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))
|
_env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised))
|
||||||
else:
|
else:
|
||||||
_env_step(report, 'pass', 'monitors up (wlan0mon, wlan1mon)')
|
_env_step(report, 'pass', 'monitors up (wlan0mon, wlan1mon)')
|
||||||
@@ -3807,6 +3941,36 @@ def env_check():
|
|||||||
return report
|
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):
|
def h_attacks_clients(ctx):
|
||||||
"""Clients (recon devices) plus the APs matching an SSID, for targeting."""
|
"""Clients (recon devices) plus the APs matching an SSID, for targeting."""
|
||||||
ssid = ((ctx.query or {}).get('ssid') or '').strip()
|
ssid = ((ctx.query or {}).get('ssid') or '').strip()
|
||||||
@@ -5347,37 +5511,49 @@ def _handle_conn(conn, addr):
|
|||||||
|
|
||||||
def _recon_watchdog_loop():
|
def _recon_watchdog_loop():
|
||||||
while not LIVE_STOP.is_set():
|
while not LIVE_STOP.is_set():
|
||||||
time.sleep(1)
|
LIVE_STOP.wait(1)
|
||||||
_recon_watchdog_tick()
|
_recon_watchdog_tick()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_shutdown(signum=None, frame=None):
|
||||||
|
LIVE_STOP.set()
|
||||||
|
HEALTH_STOP.set()
|
||||||
|
_recon_hopper_stop.set()
|
||||||
|
|
||||||
|
|
||||||
def serve():
|
def serve():
|
||||||
try:
|
LIVE_STOP.clear()
|
||||||
env_check()
|
HEALTH_STOP.clear()
|
||||||
except Exception:
|
_recon_hopper_stop.set()
|
||||||
pass
|
startup_env_check()
|
||||||
threading.Thread(target=live_loop, daemon=True).start()
|
threading.Thread(target=live_loop, daemon=True).start()
|
||||||
threading.Thread(target=_recon_watchdog_loop, daemon=True).start()
|
threading.Thread(target=_recon_watchdog_loop, daemon=True).start()
|
||||||
start_health_monitor()
|
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 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
sock.bind((HOST, PORT))
|
sock.bind((HOST, PORT))
|
||||||
sock.listen(16)
|
sock.listen(16)
|
||||||
while True:
|
sock.settimeout(1)
|
||||||
conn, addr = sock.accept()
|
try:
|
||||||
threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start()
|
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():
|
def env_check_cli():
|
||||||
"""Run the environment check and print a verbose report to stdout.
|
"""Run the environment check and print a verbose report to stdout.
|
||||||
Returns the process exit code (0 = pass/warn, 1 = core failure)."""
|
Returns the process exit code (0 = pass/warn, 1 = core failure)."""
|
||||||
report = env_check()
|
report = env_check()
|
||||||
for step in report:
|
_print_env_report(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')}
|
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)' % (
|
print('ENVIRONMENT CHECK: %s (%d pass, %d fixed, %d warn, %d fail)' % (
|
||||||
ENV_CHECK_STATE['overall'].upper(), counts['pass'], counts['fixed'],
|
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
|
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 __name__ == '__main__':
|
||||||
if '--env-check' in sys.argv:
|
if '--env-check' in sys.argv:
|
||||||
sys.exit(env_check_cli())
|
sys.exit(env_check_cli())
|
||||||
|
signal.signal(signal.SIGTERM, _request_shutdown)
|
||||||
|
signal.signal(signal.SIGINT, _request_shutdown)
|
||||||
serve()
|
serve()
|
||||||
|
|||||||
@@ -151,7 +151,15 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
|
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
|
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
|
/etc/init.d/pagerwebui restart
|
||||||
fi
|
fi
|
||||||
echo EXTRACT_OK"
|
echo EXTRACT_OK"
|
||||||
|
|||||||
+65
-4
@@ -28,6 +28,7 @@ class EnvCheckTest(unittest.TestCase):
|
|||||||
self.runs = []
|
self.runs = []
|
||||||
self.ping_ok = True
|
self.ping_ok = True
|
||||||
self.daemon_ok = True
|
self.daemon_ok = True
|
||||||
|
self.ip_link_ok = True
|
||||||
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||||
self.uci_state = {}
|
self.uci_state = {}
|
||||||
server.ENV_CHECK_STATE.update({'report': None, 'overall': None, 'updated': 0,
|
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):
|
def fake_run(self, args, timeout=20, input_data=None):
|
||||||
self.runs.append(list(args))
|
self.runs.append(list(args))
|
||||||
a = 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':
|
if a[0] == 'pidof' and a[1] == 'pineapd':
|
||||||
return (0, '12345\n', '') if self.ping_ok else (1, '', '')
|
return (0, '12345\n', '') if self.ping_ok else (1, '', '')
|
||||||
if a[0] == 'uci':
|
if a[0] == 'uci':
|
||||||
@@ -78,6 +81,11 @@ class EnvCheckTest(unittest.TestCase):
|
|||||||
if k.startswith(sec + '.')), '')
|
if k.startswith(sec + '.')), '')
|
||||||
if a[0] == '_pineap':
|
if a[0] == '_pineap':
|
||||||
return (0, '', '')
|
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'):
|
if a[0] in ('ip', '/etc/init.d/pineapd'):
|
||||||
return (0, '', '')
|
return (0, '', '')
|
||||||
return (0, '', '')
|
return (0, '', '')
|
||||||
@@ -96,11 +104,15 @@ class EnvCheckTest(unittest.TestCase):
|
|||||||
self.assertEqual([r['ok'] for r in report],
|
self.assertEqual([r['ok'] for r in report],
|
||||||
['pass'] * len(report))
|
['pass'] * len(report))
|
||||||
self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled')
|
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):
|
def test_applies_sane_defaults_when_missing(self):
|
||||||
report = server.env_check()
|
report = server.env_check()
|
||||||
self.assertEqual(self.steps(report, 'sane-off UCI defaults applied')[0]['ok'], 'fixed')
|
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():
|
for key, value in server.PINEAPD_SAFE_UCI.items():
|
||||||
self.assertEqual(self.uci_state[key], value)
|
self.assertEqual(self.uci_state[key], value)
|
||||||
|
|
||||||
@@ -109,12 +121,13 @@ class EnvCheckTest(unittest.TestCase):
|
|||||||
report = server.env_check()
|
report = server.env_check()
|
||||||
self.assertEqual(self.steps(report, 'sane-off UCI defaults already set')[0]['ok'], 'pass')
|
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=='
|
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||||
report = server.env_check()
|
report = server.env_check()
|
||||||
actions = ' | '.join((r.get('action') or '') for r in report)
|
actions = ' | '.join((r.get('action') or '') for r in report)
|
||||||
self.assertIn('pool-list cleared', actions)
|
self.assertNotIn('pool-list cleared', actions)
|
||||||
self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state)
|
self.assertIn('pineapd.@ssidpool[0].ssid', self.uci_state)
|
||||||
|
|
||||||
def test_restarts_pineapd_when_down(self):
|
def test_restarts_pineapd_when_down(self):
|
||||||
self.safe_set()
|
self.safe_set()
|
||||||
@@ -154,6 +167,15 @@ class EnvCheckTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.steps(report, 'monitor interfaces brought up')[0]['ok'], 'fixed')
|
self.assertEqual(self.steps(report, 'monitor interfaces brought up')[0]['ok'], 'fixed')
|
||||||
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], self.runs)
|
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):
|
def test_monitors_up_pass(self):
|
||||||
self.safe_set()
|
self.safe_set()
|
||||||
report = server.env_check()
|
report = server.env_check()
|
||||||
@@ -240,6 +262,45 @@ class EnvCheckTest(unittest.TestCase):
|
|||||||
self.assertEqual(code, 1)
|
self.assertEqual(code, 1)
|
||||||
self.assertIn('[FAIL]', buf.getvalue())
|
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):
|
def test_health_exposes_env_and_pool_runtime(self):
|
||||||
self.safe_set()
|
self.safe_set()
|
||||||
server.env_check()
|
server.env_check()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class HealthCheckTest(unittest.TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.runs = []
|
self.runs = []
|
||||||
self.ping_ok = True
|
self.ping_ok = True
|
||||||
|
self.ip_link_ok = True
|
||||||
self.sigsegvs = 0
|
self.sigsegvs = 0
|
||||||
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||||
self.uci_state = {}
|
self.uci_state = {}
|
||||||
@@ -32,6 +33,11 @@ class HealthCheckTest(unittest.TestCase):
|
|||||||
return (1, '', '')
|
return (1, '', '')
|
||||||
if a[0] == 'logread':
|
if a[0] == 'logread':
|
||||||
return (0, 'SIGSEGV\n' * self.sigsegs if hasattr(self, 'sigsegs') else '', '')
|
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']:
|
if a[:2] == ['uci', 'set']:
|
||||||
k, _, v = a[2].partition('=')
|
k, _, v = a[2].partition('=')
|
||||||
self.uci_state[k] = v
|
self.uci_state[k] = v
|
||||||
|
|||||||
+68
-1
@@ -162,10 +162,56 @@ class FakeSock:
|
|||||||
pass
|
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):
|
class DaemonSockTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
# h_recon_start now reads shared scan state; keep these isolated.
|
# h_recon_start now reads shared scan state; keep these isolated.
|
||||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
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):
|
def test_socket_call_posts_json_to_sock(self):
|
||||||
server.DAEMON_SOCK = '/tmp/api.sock'
|
server.DAEMON_SOCK = '/tmp/api.sock'
|
||||||
@@ -201,6 +247,7 @@ class DaemonSockTest(unittest.TestCase):
|
|||||||
status, data = server.h_recon_start(ctx)
|
status, data = server.h_recon_start(ctx)
|
||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
|
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):
|
def test_start_defaults_empty_body(self):
|
||||||
calls = []
|
calls = []
|
||||||
@@ -228,6 +275,18 @@ class DaemonSockTest(unittest.TestCase):
|
|||||||
self.assertEqual(data['daemon'], {'error': 'no radio'})
|
self.assertEqual(data['daemon'], {'error': 'no radio'})
|
||||||
self.assertFalse(server._recon_scan_state['active'])
|
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):
|
class ReconScanStateTest(unittest.TestCase):
|
||||||
"""The webui mirrors the duration of the Pager's native timed scan."""
|
"""The webui mirrors the duration of the Pager's native timed scan."""
|
||||||
@@ -236,6 +295,13 @@ class ReconScanStateTest(unittest.TestCase):
|
|||||||
self.db = make_db()
|
self.db = make_db()
|
||||||
server.RECON_DB = self.db
|
server.RECON_DB = self.db
|
||||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
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):
|
def tearDown(self):
|
||||||
os.unlink(self.db)
|
os.unlink(self.db)
|
||||||
@@ -368,12 +434,13 @@ class ReconExtrasTest(unittest.TestCase):
|
|||||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
self.assertFalse(data['hopper_online'])
|
self.assertFalse(data['hopper_online'])
|
||||||
|
self.assertIn('hopper_error', data)
|
||||||
self.assertTrue(data['history_reset'])
|
self.assertTrue(data['history_reset'])
|
||||||
|
|
||||||
def test_hopper_online_cached(self):
|
def test_hopper_online_cached(self):
|
||||||
server._hopper_cache.update({'updated': 0, 'online': None})
|
server._hopper_cache.update({'updated': 0, 'online': None})
|
||||||
with mock.patch.object(server, 'wifi_ifaces',
|
with mock.patch.object(server, 'wifi_ifaces',
|
||||||
return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
|
return_value=['wlan0mon', 'wlan1mon']):
|
||||||
self.assertTrue(server._hopper_online())
|
self.assertTrue(server._hopper_online())
|
||||||
# Second call within the cache window must not re-run iwinfo.
|
# Second call within the cache window must not re-run iwinfo.
|
||||||
with mock.patch.object(server, 'wifi_ifaces',
|
with mock.patch.object(server, 'wifi_ifaces',
|
||||||
|
|||||||
Reference in New Issue
Block a user