diff --git a/README.md b/README.md index 696cb1a..eaf5997 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,32 @@ terminal. - WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` - `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) +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 '' +``` + +Windows: + ```powershell # deploy.ps1 needs either an SSH key or sshpass for password auth: & .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" # or set up a key and add it: ssh-copy-id root@172.16.52.1 ``` -This builds `build\pager-webui\payload-.zip`, uploads it, extracts it to -`/root/payloads/user/remote_access/pager-webui/`, and refreshes the portal index. +The deployment scripts build `build/pager-webui/payload-.zip`, upload it, +extract it to `/root/payloads/user/remote_access/pager-webui/`, and refresh the +portal index. Then on the Pager menu, run **Mark VIII**: - **Yes** to "Run as background service?" -> procd service (respawns on crash, diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 5a59eec..d711600 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -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): diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index 4d096da..7751e2e 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -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 = () => { diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..219cc36 --- /dev/null +++ b/scripts/deploy.sh @@ -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: /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" diff --git a/tests/test_recon.py b/tests/test_recon.py index 8aecb6b..32db236 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -182,9 +182,8 @@ class DaemonSockTest(unittest.TestCase): calls = [] 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_stop(type('C', (), {'args': ()})()) - self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) - self.assertEqual(calls[1], ('POST', '/api/pineap/log/recon/stop', {})) + self.assertEqual(calls, [ + ('POST', '/api/pineap/recon/new', {'scan_time': 30})]) def test_start_forwards_scan_time(self): calls = [] @@ -192,19 +191,36 @@ class DaemonSockTest(unittest.TestCase): ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})() status, data = server.h_recon_start(ctx) 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): calls = [] 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': ()})()) - 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): - """The daemon ignores scan_time and scans continuously until 'stop'. The webui - must track the requested duration itself so timed scans actually end and the - toggle can reflect real scan state.""" + """The webui mirrors the duration of the Pager's native timed scan.""" def setUp(self): self.db = make_db() @@ -248,26 +264,33 @@ class ReconScanStateTest(unittest.TestCase): self.assertFalse(data['scanning']) 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 - self._start(scan_time=0) - status, data = self._status() - self.assertTrue(data['scanning']) - self.assertIsNone(data['scan_remaining']) + status, data = self._start(scan_time=0) + self.assertEqual(status, 400) + self.assertFalse(server._recon_scan_state['active']) - def test_default_start_is_continuous(self): + def test_default_start_uses_thirty_seconds(self): server.time.time = lambda: 1000.0 self._start() status, data = self._status() 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 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() - 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): server.time.time = lambda: 1000.0 @@ -277,13 +300,13 @@ class ReconScanStateTest(unittest.TestCase): self.assertFalse(data['scanning']) def test_watchdog_stops_expired_timed_scan(self): - calls = [] server.time.time = lambda: 1000.0 self._start(scan_time=10) 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._recon_watchdog_tick() - self.assertEqual(calls, [('POST', '/api/pineap/log/recon/stop')]) + self.assertEqual(calls, []) self.assertFalse(server._recon_scan_state['active']) def test_watchdog_leaves_active_scan_alone(self): @@ -454,6 +477,21 @@ class CliFallbackTest(unittest.TestCase): server.RECON_DB = '/nonexistent.db' 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(): db = make_db()