fix: repair recon scanning and add macOS deployment

Start scans via the Pager's native /api/pineap/recon/new so history
appends instead of rotating. Enforce finite durations (1-86400s,
default 30s), remove the unsupported Continuous mode, and refuse the
unsafe manual stop that left recon.db locked.

Rework scan list and detail reads into single-pass aggregate SQL,
shorten lock retries, and serve cached results during short exclusive
lock windows. Fall back to immutable read-only access when the
firmware leaves a stale lock after native completion.

Frontend auto-follows new scans, queues a single in-flight detail
refresh, keeps previous tables visible while a scan starts, and shows
completion toasts and daemon error details.

Add scripts/deploy.sh for macOS/Linux (zip packaging, scp/ssh install,
atomic payload replacement, service restart, portal refresh) with
README instructions, plus regression coverage for native start,
safe stop semantics, aggregate queries, and stale-lock fallback.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-17 17:46:54 -05:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 3e1805dab8
commit 2bf39ecb9d
5 changed files with 431 additions and 86 deletions
+126 -51
View File
@@ -40,6 +40,10 @@ HOST = os.environ.get('PAGER_HOST', '0.0.0.0')
PORT = int(os.environ.get('PAGER_PORT', '8080'))
_recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
DEFAULT_RECON_DURATION = 30
_recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
_recon_status_cache = {
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
_payload_runs = {}
_payload_runs_lock = threading.Lock()
PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs')
@@ -864,21 +868,36 @@ def h_deauth_client(ctx):
SQLITE_BUSY_MSGS = ('database is locked', 'database is busy')
def _db_rows(db, sql, _retries=5):
def _db_rows(db, sql, _retries=1):
if sqlite3 is not None:
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
try:
cur = conn.execute(sql)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql])
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
try:
cur = conn.execute(sql)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
except sqlite3.Error as exc:
raise RuntimeError('sqlite read failed: %s' % exc)
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql])
attempt = 1
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries:
time.sleep(0.3)
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql])
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql])
attempt += 1
st = _recon_scan_state
elapsed = time.time() - st['started'] if st['started'] else 0
completed_here = st['duration'] > 0 and elapsed >= st['duration'] + 2
if (rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS)
and db == RECON_DB and (not st['active'] or completed_here)):
# Pager firmware leaves a stale exclusive lock after native completion.
# The file is stable by this point, so bypass that stale lock read-only.
immutable_db = 'file:%s?immutable=1' % db
rc, out, err = device_run(
[SQLITE_CLI, '-json', immutable_db, sql])
if rc != 0:
raise RuntimeError('sqlite read failed: %s' % (err or out).strip())
if out.strip():
return json.loads(out)
return []
@@ -944,25 +963,50 @@ def decode_encryption(v):
def recon_scans_data(limit=50):
rows = _db_rows(RECON_DB,
'WITH recent AS (SELECT id, time, name FROM scan ORDER BY id DESC LIMIT %d), '
'devices AS (SELECT scan, count(*) AS devices FROM wifi_device '
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan), '
'aps AS (SELECT scan, count(*) AS aps FROM ssid '
'WHERE type = 8 AND scan IN (SELECT id FROM recent) GROUP BY scan), '
'captures AS (SELECT scan, count(*) AS handshakes FROM handshake '
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan) '
'SELECT s.id, s.time, s.name, '
'(SELECT count(*) FROM wifi_device w WHERE w.scan = s.id) AS devices, '
'(SELECT count(*) FROM ssid a WHERE a.scan = s.id AND a.type = 8) AS aps, '
'(SELECT count(*) FROM handshake h WHERE h.scan = s.id) AS handshakes '
'FROM scan s ORDER BY s.id DESC LIMIT %d' % limit)
'COALESCE(w.devices, 0) AS devices, '
'COALESCE(a.aps, 0) AS aps, '
'COALESCE(h.handshakes, 0) AS handshakes '
'FROM recent s '
'LEFT JOIN devices w ON w.scan = s.id '
'LEFT JOIN aps a ON a.scan = s.id '
'LEFT JOIN captures h ON h.scan = s.id '
'ORDER BY s.id DESC' % limit)
return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'),
'devices': r['devices'], 'aps': r['aps'],
'handshakes': r['handshakes']} for r in rows]}
def recon_scan_data(scan_id):
scans = _db_rows(RECON_DB, 'SELECT id, time, name FROM scan WHERE id = %d' % scan_id)
rows = _db_rows(RECON_DB,
"SELECT 'scan' AS kind, id AS row_id, time, name, "
"NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
"NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
"NULL AS packets, NULL AS stahash, NULL AS aphash "
"FROM scan WHERE id = %d "
"UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
"channel, encryption, signal, freq, NULL, NULL, NULL "
"FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
"UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
"NULL, NULL, signal, freq, packets, NULL, NULL "
"FROM wifi_device WHERE scan = %d "
"UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
"NULL, NULL, NULL, NULL, NULL, stahash, aphash "
"FROM handshake WHERE scan = %d" % (scan_id, scan_id, scan_id, scan_id))
scans = [r for r in rows if r.get('kind') == 'scan']
if not scans:
return None
aps = []
for r in _db_rows(RECON_DB,
'SELECT bssid, ssid, hidden, channel, encryption, signal, freq '
'FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL '
'ORDER BY signal ASC' % scan_id):
ap_macs = set()
for r in (row for row in rows if row.get('kind') == 'ap'):
ap_macs.add((r.get('bssid') or '').strip().upper())
aps.append({'bssid': fmt_mac(r.get('bssid')),
'ssid': decode_ssid(r.get('ssid')),
'hidden': bool(r.get('hidden')),
@@ -970,51 +1014,55 @@ def recon_scan_data(scan_id):
'signal': r.get('signal'),
'freq': r.get('freq'),
'encryption': decode_encryption(r.get('encryption'))})
ap_macs = set()
for r in _db_rows(RECON_DB,
'SELECT DISTINCT bssid FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL' % scan_id):
ap_macs.add((r.get('bssid') or '').strip().upper())
aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0)
devices = [r for r in rows if r.get('kind') == 'device']
clients = []
for r in _db_rows(RECON_DB,
'SELECT mac, signal, freq, packets FROM wifi_device WHERE scan = %d ORDER BY time ASC' % scan_id):
for r in sorted(devices, key=lambda row: row.get('time') or 0):
if (r.get('mac') or '').strip().upper() in ap_macs:
continue
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
'freq': r.get('freq'), 'packets': r.get('packets')})
mac_of = {}
for r in _db_rows(RECON_DB,
'SELECT hash, mac FROM wifi_device WHERE scan = %d' % scan_id):
mac_of[r['hash']] = fmt_mac(r.get('mac'))
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
handshakes = []
for r in _db_rows(RECON_DB,
'SELECT stahash, aphash, time FROM handshake WHERE scan = %d' % scan_id):
for r in (row for row in rows if row.get('kind') == 'handshake'):
handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'),
'client': mac_of.get(r.get('stahash'), '--'),
'time': r.get('time')})
return {'scan': {'id': scans[0]['id'], 'time': scans[0]['time'],
return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'],
'name': scans[0].get('name')},
'aps': aps, 'clients': clients, 'handshakes': handshakes}
def h_recon_start(ctx):
body = {}
scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time')
if scan_time is not None:
body['scan_time'] = int(scan_time)
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body)
scan_time = (getattr(ctx, 'body', None) or {}).get(
'scan_time', DEFAULT_RECON_DURATION)
try:
scan_time = int(scan_time)
except (TypeError, ValueError):
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'}
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
# 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': 'daemon recon start failed'}
return 502, {'error': 'native recon scan failed', 'detail': data}
_recon_scan_state['active'] = True
_recon_scan_state['started'] = time.time()
_recon_scan_state['duration'] = int(scan_time) if scan_time is not None else 0
_recon_scan_state['duration'] = scan_time
return 200, {'ok': True}
def h_recon_stop(ctx):
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={})
if status != 200 or not (data or {}).get('success'):
return 502, {'error': 'daemon recon stop failed'}
_recon_scan_state['active'] = False
scanning, remaining = _recon_scan_snapshot()
if scanning:
return 409, {
'error': 'Pager firmware cannot stop a recon scan safely; '
'this scan will finish automatically',
'scan_remaining': remaining,
}
return 200, {'ok': True}
@@ -1030,23 +1078,40 @@ def _recon_scan_snapshot():
def _recon_watchdog_tick():
"""The daemon ignores scan_time and scans until stopped, so the webui enforces
the requested duration by issuing a stop when the timed scan expires."""
"""Clear the UI timer when the native timed scan reaches its duration.
The Pager has no recon-stop operation. log/recon/stop controls the storage
service and leaves recon.db locked, so timed scans must end natively.
"""
st = _recon_scan_state
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']:
daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={})
st['active'] = False
def h_recon_status(ctx):
rows = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM scan')
last = rows[0]['t'] if rows and rows[0].get('t') is not None else None
act = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM wifi_device')
last_activity = act[0]['t'] if act and act[0].get('t') is not None else last
scanning, remaining = _recon_scan_snapshot()
cache = _recon_status_cache
if cache['db'] != RECON_DB:
cache.update({'db': RECON_DB, 'updated': 0,
'last_scan': None, 'last_activity': None})
stale = False
try:
rows = _db_rows(RECON_DB,
'SELECT (SELECT MAX(time) FROM scan) AS last_scan, '
'(SELECT MAX(time) FROM wifi_device) AS last_activity')
if rows:
cache['last_scan'] = rows[0].get('last_scan')
cache['last_activity'] = rows[0].get('last_activity')
cache['updated'] = time.time()
except RuntimeError:
stale = True
last = cache['last_scan']
last_activity = cache['last_activity']
if last_activity is None:
last_activity = last
return 200, {'last_scan': last, 'last_activity': last_activity,
'active': last_activity is not None and int(time.time()) - last_activity < 300,
'scanning': scanning, 'scan_remaining': remaining}
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale}
def _db_write(db, sql):
@@ -1129,7 +1194,17 @@ def h_recon_examine(ctx):
def h_recon_scans(ctx):
return 200, recon_scans_data()
cache = _recon_scans_cache
if cache['db'] != RECON_DB:
cache.update({'db': RECON_DB, 'updated': 0, 'data': {'scans': []}})
try:
cache['data'] = recon_scans_data()
cache['updated'] = time.time()
except RuntimeError:
if not cache['updated'] or time.time() - cache['updated'] > 120:
return 503, {'error': 'recon database is temporarily unavailable'}
return 200, dict(cache['data'], stale=True)
return 200, dict(cache['data'], stale=False)
def h_recon_scan_detail(ctx):
@@ -1047,7 +1047,9 @@ views.recon = (root) => {
const state = { scans: [], selected: null, detail: null,
apPage: 0, apSearch: '', clientPage: 0, clientSearch: '',
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
apSort: null, clientSort: null, focusAp: null, autoFollow: false, scanActive: false };
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null };
const cols = reconLoadCols();
// ---- title cards ----
@@ -1133,7 +1135,7 @@ views.recon = (root) => {
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
scanBar.appendChild(scanLabel);
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes'], [0, 'Continuous']]
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes']]
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
@@ -1150,18 +1152,22 @@ views.recon = (root) => {
.then(() => {
if (on) {
state.scanActive = true;
state.selected = null;
state.autoFollow = true;
state.apPage = 0;
state.clientPage = 0;
App.toast('Scan started');
} else {
state.scanActive = false;
state.autoFollow = false;
App.toast('Scan stopped');
}
restartPoll();
load();
})
.catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); })
.catch((err) => {
scanToggle.checked = !on;
App.toast((err && err.message) || 'Recon control failed', 'error');
})
.finally(() => { pendingScan = false; scanToggle.disabled = false; });
});
@@ -1452,19 +1458,39 @@ views.recon = (root) => {
function loadDetail() {
if (state.selected == null) return;
PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => {
const scanId = state.selected;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
if (!state.scanActive && state.detailId === scanId) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
PagerAPI.get('/api/recon/scans/' + scanId).then((r) => {
if (state.selected !== scanId) return;
state.detail = r.data;
state.detailId = scanId;
drawCharts(r.data);
renderTables();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {});
}).catch(() => {}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
const keep = state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : (state.scans[0] ? state.scans[0].id : null);
const newest = state.scans[0] ? state.scans[0].id : null;
const keep = state.autoFollow
? newest
: (state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : newest);
sel.innerHTML = '';
state.scans.forEach((s) => {
const opt = document.createElement('option');
@@ -1482,17 +1508,23 @@ views.recon = (root) => {
state.selected = keep;
if (keep != null) loadDetail();
}).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
if (!pendingScan) scanToggle.checked = scanning;
restartPoll();
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load();
let pollIv = null;
const restartPoll = () => {