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
+17 -3
View File
@@ -12,18 +12,32 @@ terminal.
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` - WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1`
- `python3` on the device (present on current firmware) - `python3` on the device (present on current firmware)
- Windows dev box with Python 3.11 (`winget install Python.Python.3.11`) - Python 3.11 on the development machine
## Install (sideload) ## Install (sideload)
macOS/Linux:
```bash
# Recommended: key authentication
./scripts/deploy.sh --ssh-key "$HOME/.ssh/pager_key"
# Password authentication requires sshpass
brew install hudochenkov/sshpass/sshpass
./scripts/deploy.sh --password '<device-password>'
```
Windows:
```powershell ```powershell
# deploy.ps1 needs either an SSH key or sshpass for password auth: # deploy.ps1 needs either an SSH key or sshpass for password auth:
& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" & .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key"
# or set up a key and add it: ssh-copy-id root@172.16.52.1 # or set up a key and add it: ssh-copy-id root@172.16.52.1
``` ```
This builds `build\pager-webui\payload-<b64>.zip`, uploads it, extracts it to The deployment scripts build `build/pager-webui/payload-<b64>.zip`, upload it,
`/root/payloads/user/remote_access/pager-webui/`, and refreshes the portal index. extract it to `/root/payloads/user/remote_access/pager-webui/`, and refresh the
portal index.
Then on the Pager menu, run **Mark VIII**: Then on the Pager menu, run **Mark VIII**:
- **Yes** to "Run as background service?" -> procd service (respawns on crash, - **Yes** to "Run as background service?" -> procd service (respawns on crash,
+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')) 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
_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 = {}
_payload_runs_lock = threading.Lock() _payload_runs_lock = threading.Lock()
PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs') 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') 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: if sqlite3 is not None:
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
try: try:
cur = conn.execute(sql) conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
cols = [d[0] for d in cur.description] try:
return [dict(zip(cols, row)) for row in cur.fetchall()] cur = conn.execute(sql)
finally: cols = [d[0] for d in cur.description]
conn.close() return [dict(zip(cols, row)) for row in cur.fetchall()]
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql]) 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 attempt = 1
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries: while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries:
time.sleep(0.3) 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 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(): if out.strip():
return json.loads(out) return json.loads(out)
return [] return []
@@ -944,25 +963,50 @@ def decode_encryption(v):
def recon_scans_data(limit=50): def recon_scans_data(limit=50):
rows = _db_rows(RECON_DB, 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 s.id, s.time, s.name, '
'(SELECT count(*) FROM wifi_device w WHERE w.scan = s.id) AS devices, ' 'COALESCE(w.devices, 0) AS devices, '
'(SELECT count(*) FROM ssid a WHERE a.scan = s.id AND a.type = 8) AS aps, ' 'COALESCE(a.aps, 0) AS aps, '
'(SELECT count(*) FROM handshake h WHERE h.scan = s.id) AS handshakes ' 'COALESCE(h.handshakes, 0) AS handshakes '
'FROM scan s ORDER BY s.id DESC LIMIT %d' % limit) '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'), return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'),
'devices': r['devices'], 'aps': r['aps'], 'devices': r['devices'], 'aps': r['aps'],
'handshakes': r['handshakes']} for r in rows]} 'handshakes': r['handshakes']} for r in rows]}
def recon_scan_data(scan_id): 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: if not scans:
return None return None
aps = [] aps = []
for r in _db_rows(RECON_DB, ap_macs = set()
'SELECT bssid, ssid, hidden, channel, encryption, signal, freq ' for r in (row for row in rows if row.get('kind') == 'ap'):
'FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL ' ap_macs.add((r.get('bssid') or '').strip().upper())
'ORDER BY signal ASC' % scan_id):
aps.append({'bssid': fmt_mac(r.get('bssid')), aps.append({'bssid': fmt_mac(r.get('bssid')),
'ssid': decode_ssid(r.get('ssid')), 'ssid': decode_ssid(r.get('ssid')),
'hidden': bool(r.get('hidden')), 'hidden': bool(r.get('hidden')),
@@ -970,51 +1014,55 @@ def recon_scan_data(scan_id):
'signal': r.get('signal'), 'signal': r.get('signal'),
'freq': r.get('freq'), 'freq': r.get('freq'),
'encryption': decode_encryption(r.get('encryption'))}) 'encryption': decode_encryption(r.get('encryption'))})
ap_macs = set() aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0)
for r in _db_rows(RECON_DB, devices = [r for r in rows if r.get('kind') == 'device']
'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())
clients = [] clients = []
for r in _db_rows(RECON_DB, for r in sorted(devices, key=lambda row: row.get('time') or 0):
'SELECT mac, signal, freq, packets FROM wifi_device WHERE scan = %d ORDER BY time ASC' % scan_id):
if (r.get('mac') or '').strip().upper() in ap_macs: if (r.get('mac') or '').strip().upper() in ap_macs:
continue continue
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'), clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
'freq': r.get('freq'), 'packets': r.get('packets')}) 'freq': r.get('freq'), 'packets': r.get('packets')})
mac_of = {} mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
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'))
handshakes = [] handshakes = []
for r in _db_rows(RECON_DB, for r in (row for row in rows if row.get('kind') == 'handshake'):
'SELECT stahash, aphash, time FROM handshake WHERE scan = %d' % scan_id):
handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'), handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'),
'client': mac_of.get(r.get('stahash'), '--'), 'client': mac_of.get(r.get('stahash'), '--'),
'time': r.get('time')}) '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')}, 'name': scans[0].get('name')},
'aps': aps, 'clients': clients, 'handshakes': handshakes} 'aps': aps, 'clients': clients, 'handshakes': handshakes}
def h_recon_start(ctx): def h_recon_start(ctx):
body = {} scan_time = (getattr(ctx, 'body', None) or {}).get(
scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time') 'scan_time', DEFAULT_RECON_DURATION)
if scan_time is not None: try:
body['scan_time'] = int(scan_time) scan_time = int(scan_time)
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body) 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'): 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['active'] = True
_recon_scan_state['started'] = time.time() _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} return 200, {'ok': True}
def h_recon_stop(ctx): def h_recon_stop(ctx):
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={}) scanning, remaining = _recon_scan_snapshot()
if status != 200 or not (data or {}).get('success'): if scanning:
return 502, {'error': 'daemon recon stop failed'} return 409, {
_recon_scan_state['active'] = False 'error': 'Pager firmware cannot stop a recon scan safely; '
'this scan will finish automatically',
'scan_remaining': remaining,
}
return 200, {'ok': True} return 200, {'ok': True}
@@ -1030,23 +1078,40 @@ def _recon_scan_snapshot():
def _recon_watchdog_tick(): def _recon_watchdog_tick():
"""The daemon ignores scan_time and scans until stopped, so the webui enforces """Clear the UI timer when the native timed scan reaches its duration.
the requested duration by issuing a stop when the timed scan expires."""
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 st = _recon_scan_state
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']: 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 st['active'] = False
def h_recon_status(ctx): 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() 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, return 200, {'last_scan': last, 'last_activity': last_activity,
'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} 'scanning': scanning, 'scan_remaining': remaining, 'stale': stale}
def _db_write(db, sql): def _db_write(db, sql):
@@ -1129,7 +1194,17 @@ def h_recon_examine(ctx):
def h_recon_scans(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): def h_recon_scan_detail(ctx):
@@ -1047,7 +1047,9 @@ views.recon = (root) => {
const state = { scans: [], selected: null, detail: null, const state = { scans: [], selected: null, detail: null,
apPage: 0, apSearch: '', clientPage: 0, clientSearch: '', apPage: 0, apSearch: '', clientPage: 0, clientSearch: '',
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10), 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(); const cols = reconLoadCols();
// ---- title cards ---- // ---- title cards ----
@@ -1133,7 +1135,7 @@ views.recon = (root) => {
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan'); const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
scanBar.appendChild(scanLabel); scanBar.appendChild(scanLabel);
const durSel = h('select', { class: 'sel', id: 'recon-duration' }); 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 }))); .forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
durSel.value = localStorage.getItem('pw_scan_duration') || '30'; durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value)); durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
@@ -1150,18 +1152,22 @@ views.recon = (root) => {
.then(() => { .then(() => {
if (on) { if (on) {
state.scanActive = true; state.scanActive = true;
state.selected = null; state.autoFollow = true;
state.apPage = 0; state.apPage = 0;
state.clientPage = 0; state.clientPage = 0;
App.toast('Scan started'); App.toast('Scan started');
} else { } else {
state.scanActive = false; state.scanActive = false;
state.autoFollow = false;
App.toast('Scan stopped'); App.toast('Scan stopped');
} }
restartPoll(); restartPoll();
load(); 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; }); .finally(() => { pendingScan = false; scanToggle.disabled = false; });
}); });
@@ -1452,19 +1458,39 @@ views.recon = (root) => {
function loadDetail() { function loadDetail() {
if (state.selected == null) return; 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.detail = r.data;
state.detailId = scanId;
drawCharts(r.data); drawCharts(r.data);
renderTables(); renderTables();
hsCount.textContent = (r.data.handshakes || []).length; hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {}); }).catch(() => {}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
} }
function load() { function load() {
PagerAPI.get('/api/recon/scans').then((r) => { PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || []; state.scans = r.data.scans || [];
const keep = state.selected && state.scans.some((s) => s.id === state.selected) const newest = state.scans[0] ? state.scans[0].id : null;
? state.selected : (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 = ''; sel.innerHTML = '';
state.scans.forEach((s) => { state.scans.forEach((s) => {
const opt = document.createElement('option'); const opt = document.createElement('option');
@@ -1482,17 +1508,23 @@ views.recon = (root) => {
state.selected = keep; state.selected = keep;
if (keep != null) loadDetail(); if (keep != null) loadDetail();
}).catch(() => {}); }).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => { PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning; const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning; state.scanActive = scanning;
if (!pendingScan) scanToggle.checked = scanning; if (!pendingScan) scanToggle.checked = scanning;
restartPoll(); if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {}); }).catch(() => {});
} }
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load(); load();
let pollIv = null; let pollIv = null;
const restartPoll = () => { const restartPoll = () => {
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
PAGER_HOST="172.16.52.1"
PAGER_USER="root"
PASSWORD=""
SSH_KEY=""
BUILD_DIR=""
PORTAL_REFRESH=true
usage() {
cat <<'EOF'
Usage: scripts/deploy.sh [options]
Options:
--host HOST Pager address (default: 172.16.52.1)
--user USER SSH user (default: root)
--password PASSWORD SSH/device password (requires sshpass)
--ssh-key PATH SSH private key
--build-dir PATH Build output directory (default: <repo>/build)
--no-portal-refresh Skip the best-effort portal refresh
-h, --help Show this help
If neither --password nor --ssh-key is supplied, ssh/scp prompt normally.
EOF
}
while (($#)); do
case "$1" in
--host) PAGER_HOST="${2:?missing value for --host}"; shift 2 ;;
--user) PAGER_USER="${2:?missing value for --user}"; shift 2 ;;
--password) PASSWORD="${2:?missing value for --password}"; shift 2 ;;
--ssh-key) SSH_KEY="${2:?missing value for --ssh-key}"; shift 2 ;;
--build-dir) BUILD_DIR="${2:?missing value for --build-dir}"; shift 2 ;;
--no-portal-refresh) PORTAL_REFRESH=false; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
for command in python3 zip scp ssh; do
command -v "$command" >/dev/null || {
printf 'Required command not found: %s\n' "$command" >&2
exit 1
}
done
if [[ -n "$PASSWORD" ]] && ! command -v sshpass >/dev/null; then
printf 'Password deployment requires sshpass (brew install hudochenkov/sshpass/sshpass).\n' >&2
exit 1
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PAYLOAD_KEY="pager-webui"
PAYLOAD_CATEGORY="remote_access"
PAYLOAD_DIR="$ROOT/payload/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
BUILD_DIR="${BUILD_DIR:-$ROOT/build}"
OUT_DIR="$BUILD_DIR/$PAYLOAD_KEY"
STAGE="$OUT_DIR/stage"
[[ -d "$PAYLOAD_DIR" ]] || {
printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2
exit 1
}
mkdir -p "$OUT_DIR"
rm -rf "$STAGE"
mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY"
cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
find "$STAGE" \( -type d -name __pycache__ -o -type f -name '*.pyc' \) -prune -exec rm -rf {} +
B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')"
ZIP_NAME="payload-$B64_KEY.zip"
ZIP_PATH="$OUT_DIR/$ZIP_NAME"
MANIFEST_PATH="$OUT_DIR/_hak5_manifest.json"
rm -f "$ZIP_PATH"
(
cd "$STAGE"
zip -q -r "$ZIP_PATH" user
)
HASH="$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$ZIP_PATH")"
python3 - "$PAYLOAD_DIR/_hak5_manifest.json" "$MANIFEST_PATH" "$HASH" "$ZIP_NAME" <<'PY'
import json
import sys
import time
source, destination, digest, zip_name = sys.argv[1:]
with open(source, encoding='utf-8') as handle:
manifest = json.load(handle)
manifest['time'] = int(time.time())
manifest['last_hash'] = digest
manifest['zip'] = zip_name
with open(destination, 'w', encoding='ascii') as handle:
json.dump(manifest, handle, indent=2)
handle.write('\n')
PY
printf 'Built: %s\n' "$ZIP_PATH"
TARGET="$PAGER_USER@$PAGER_HOST"
run_scp() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp -i "$SSH_KEY" "$@"
elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp "$@"
elif [[ -n "$SSH_KEY" ]]; then
scp -i "$SSH_KEY" "$@"
else
scp "$@"
fi
}
run_ssh() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh -i "$SSH_KEY" "$@"
elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh "$@"
elif [[ -n "$SSH_KEY" ]]; then
ssh -i "$SSH_KEY" "$@"
else
ssh "$@"
fi
}
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
LEGACY_PAYLOAD_DIR="user/general/$PAYLOAD_KEY"
REMOTE_COMMAND="set -e
cd /root/payloads
stage='.pager-webui.deploy.\$\$'
backup='.pager-webui.backup.\$\$'
trap 'rm -rf \"\$stage\" \"\$backup\"' EXIT
mkdir -p \"\$stage\"
cd \"\$stage\"
unzip -q '/tmp/$ZIP_NAME'
new=\"\$PWD/$REMOTE_PAYLOAD_DIR\"
[ -f \"\$new/server.py\" ] && [ -f \"\$new/payload.sh\" ] && [ -d \"\$new/www\" ]
cp /tmp/_hak5_manifest.json \"\$new/_hak5_manifest.json\"
chmod +x \"\$new/payload.sh\" \"\$new/pagerwebui.init\"
chmod -R 755 \"\$new/www\"
cd /root/payloads
if [ -d '$REMOTE_PAYLOAD_DIR' ]; then
mkdir -p \"\$(dirname \"\$backup\")\"
mv '$REMOTE_PAYLOAD_DIR' \"\$backup\"
fi
if mv \"\$new\" '$REMOTE_PAYLOAD_DIR'; then
rm -rf \"\$backup\" '$LEGACY_PAYLOAD_DIR'
else
[ ! -d \"\$backup\" ] || mv \"\$backup\" '$REMOTE_PAYLOAD_DIR'
exit 1
fi
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then
/etc/init.d/pagerwebui restart
fi
echo EXTRACT_OK"
run_ssh "$TARGET" "$REMOTE_COMMAND"
printf 'Installed to /root/payloads/%s/\n' "$REMOTE_PAYLOAD_DIR"
if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then
PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)"
PORTAL_OK=false
for attempt in 1 2 3; do
if printf '%s\n' "$PASSWORD_B64" | run_ssh "$TARGET" 'read -r password_b64
password=$(printf "%s" "$password_b64" | base64 -d)
login_body=$(printf "%s" "$password" | python3 -c '"'"'import json, sys; print(json.dumps({"username": "root", "password": sys.stdin.read()}))'"'"')
token=$(curl -sS -X POST http://127.0.0.1:1471/api/login -H "Content-Type: application/json" -d "$login_body" |
python3 -c '"'"'import json, sys; print(json.load(sys.stdin).get("token", ""))'"'"')
[ -n "$token" ] &&
curl -fsS -X POST http://127.0.0.1:1471/api/payloads/portal/refresh -H "Authorization: Bearer $token" >/dev/null'; then
PORTAL_OK=true
break
fi
sleep 2
done
if $PORTAL_OK; then
printf 'Portal refreshed.\n'
else
printf 'Warning: portal refresh failed; payload installation is complete.\n' >&2
fi
elif $PORTAL_REFRESH; then
printf 'Skipping portal refresh without --password; payload installation is complete.\n'
fi
printf 'Deploy complete. Browse http://%s:8080/\n' "$PAGER_HOST"
+58 -20
View File
@@ -182,9 +182,8 @@ class DaemonSockTest(unittest.TestCase):
calls = [] calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True}) server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
server.h_recon_start(type('C', (), {'args': ()})()) server.h_recon_start(type('C', (), {'args': ()})())
server.h_recon_stop(type('C', (), {'args': ()})()) self.assertEqual(calls, [
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) ('POST', '/api/pineap/recon/new', {'scan_time': 30})])
self.assertEqual(calls[1], ('POST', '/api/pineap/log/recon/stop', {}))
def test_start_forwards_scan_time(self): def test_start_forwards_scan_time(self):
calls = [] calls = []
@@ -192,19 +191,36 @@ class DaemonSockTest(unittest.TestCase):
ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})() ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})()
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/log/recon/start', {'scan_time': 60})) self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
def test_start_defaults_empty_body(self): def test_start_defaults_empty_body(self):
calls = [] calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True}) server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
server.h_recon_start(type('C', (), {'args': ()})()) server.h_recon_start(type('C', (), {'args': ()})())
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 30}))
def test_start_rejects_invalid_scan_time(self):
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body))
ctx = type('C', (), {'args': (), 'body': {'scan_time': 'forever'}})()
status, data = server.h_recon_start(ctx)
self.assertEqual(status, 400)
self.assertIn('scan_time', data['error'])
self.assertEqual(calls, [])
def test_start_reports_native_failure(self):
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
server.daemon_sock_call = lambda m, p, body=None: (500, {'error': 'no radio'})
status, data = server.h_recon_start(
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
self.assertEqual(status, 502)
self.assertEqual(data['error'], 'native recon scan failed')
self.assertEqual(data['detail'], {'error': 'no radio'})
self.assertFalse(server._recon_scan_state['active'])
class ReconScanStateTest(unittest.TestCase): class ReconScanStateTest(unittest.TestCase):
"""The daemon ignores scan_time and scans continuously until 'stop'. The webui """The webui mirrors the duration of the Pager's native timed scan."""
must track the requested duration itself so timed scans actually end and the
toggle can reflect real scan state."""
def setUp(self): def setUp(self):
self.db = make_db() self.db = make_db()
@@ -248,26 +264,33 @@ class ReconScanStateTest(unittest.TestCase):
self.assertFalse(data['scanning']) self.assertFalse(data['scanning'])
self.assertEqual(data['scan_remaining'], 0) self.assertEqual(data['scan_remaining'], 0)
def test_continuous_scan_has_no_remaining(self): def test_zero_duration_is_rejected(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start(scan_time=0) status, data = self._start(scan_time=0)
status, data = self._status() self.assertEqual(status, 400)
self.assertTrue(data['scanning']) self.assertFalse(server._recon_scan_state['active'])
self.assertIsNone(data['scan_remaining'])
def test_default_start_is_continuous(self): def test_default_start_uses_thirty_seconds(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start() self._start()
status, data = self._status() status, data = self._status()
self.assertTrue(data['scanning']) self.assertTrue(data['scanning'])
self.assertIsNone(data['scan_remaining']) self.assertEqual(data['scan_remaining'], 30)
def test_stop_clears_scanning(self): def test_stop_rejects_active_native_scan(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start(scan_time=30) self._start(scan_time=30)
self._stop() status, data = self._stop()
self.assertEqual(status, 409)
self.assertIn('finish automatically', data['error'])
self.assertEqual(data['scan_remaining'], 30)
status, data = self._status() status, data = self._status()
self.assertFalse(data['scanning']) self.assertTrue(data['scanning'])
def test_stop_is_idempotent_when_inactive(self):
status, data = self._stop()
self.assertEqual(status, 200)
self.assertEqual(data, {'ok': True})
def test_start_failure_does_not_mark_scanning(self): def test_start_failure_does_not_mark_scanning(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
@@ -277,13 +300,13 @@ class ReconScanStateTest(unittest.TestCase):
self.assertFalse(data['scanning']) self.assertFalse(data['scanning'])
def test_watchdog_stops_expired_timed_scan(self): def test_watchdog_stops_expired_timed_scan(self):
calls = []
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start(scan_time=10) self._start(scan_time=10)
server.time.time = lambda: 1012.0 server.time.time = lambda: 1012.0
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True}) server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True})
server._recon_watchdog_tick() server._recon_watchdog_tick()
self.assertEqual(calls, [('POST', '/api/pineap/log/recon/stop')]) self.assertEqual(calls, [])
self.assertFalse(server._recon_scan_state['active']) self.assertFalse(server._recon_scan_state['active'])
def test_watchdog_leaves_active_scan_alone(self): def test_watchdog_leaves_active_scan_alone(self):
@@ -454,6 +477,21 @@ class CliFallbackTest(unittest.TestCase):
server.RECON_DB = '/nonexistent.db' server.RECON_DB = '/nonexistent.db'
self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_'), 'casaalicia\x00.\ufffd_') self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_'), 'casaalicia\x00.\ufffd_')
def test_completed_recon_lock_uses_immutable_read(self):
calls = []
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
def locked_then_read(args, timeout=20):
calls.append(args)
if len(calls) == 1:
return 5, '', 'Error: database is locked'
return 0, '[{"id": 2}]', ''
server.device_run = locked_then_read
rows = server._db_rows(self.db, 'SELECT MAX(id) AS id FROM scan')
self.assertEqual(rows, [{'id': 2}])
self.assertEqual(calls[1][-2], 'file:%s?immutable=1' % self.db)
def make_hs_db(): def make_hs_db():
db = make_db() db = make_db()