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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
3e1805dab8
commit
2bf39ecb9d
@@ -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,
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
Executable
+186
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user