diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py
index f79ce2d..6749364 100644
--- a/payload/user/remote_access/pager-webui/server.py
+++ b/payload/user/remote_access/pager-webui/server.py
@@ -45,18 +45,12 @@ _recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
_recon_status_cache = {
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
-SURVEY_DIR = os.environ.get('PAGER_SURVEY_DIR', '/root/loot/recon-surveys')
WIGLE_DIR = os.environ.get('PAGER_WIGLE_DIR', '/root/loot/wigle')
GPSD_CONFIG = os.environ.get('PAGER_GPSD_CONFIG', '/etc/config/gpsd')
GPSD_INIT = os.environ.get('PAGER_GPSD_INIT', '/etc/init.d/gpsd')
SERIAL_DIR = os.environ.get('PAGER_SERIAL_DIR', '/dev/serial/by-path')
-SURVEY_MAX_SAMPLES = int(os.environ.get('PAGER_SURVEY_MAX_SAMPLES', '1800'))
-SURVEY_SAMPLE_INTERVAL = float(os.environ.get('PAGER_SURVEY_SAMPLE_INTERVAL', '2.0'))
GPS_CACHE_SECONDS = 5.0
-_survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
- 'started': 0, 'samples': 0, 'last_sample': 0}
-_survey_lock = threading.Lock()
_gps_cache = {'updated': 0, 'data': None}
_gps_lock = threading.Lock()
_payload_runs = {}
@@ -1067,8 +1061,10 @@ def band_of(freq):
-def recon_scans_data(limit=50, _timeout=20):
- rows = _db_rows(RECON_DB,
+def recon_scans_data(limit=50, _timeout=20, db=None):
+ if db is None:
+ db = RECON_DB
+ rows = _db_rows(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), '
@@ -1090,15 +1086,17 @@ def recon_scans_data(limit=50, _timeout=20):
'handshakes': r['handshakes']} for r in rows]}
-def recon_scan_data(scan_id, _timeout=20, _limit=None):
+def recon_scan_data(scan_id, _timeout=20, _limit=None, db=None):
"""Scan detail with per-AP enrichment (band/vendor/first_seen/last_seen)
plus an unassociated count.
With `_limit`, client rows are capped and the unassociated rows collapse to
- a count. The live survey view never renders the full client list, and the
+ a count. The live Scanning view never renders the full client list, and the
unassociated/device branches dominate query time on slow recon dbs, so the
- bounded mode keeps the 2s survey poll cheap.
+ bounded mode keeps the poll and the report downloads cheap.
"""
+ if db is None:
+ db = RECON_DB
if _limit:
sql = ("WITH dev AS (SELECT hash, time, mac, signal, freq, packets "
"FROM wifi_device WHERE scan = %d LIMIT %d) "
@@ -1116,12 +1114,12 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None):
"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, _limit, scan_id, scan_id, scan_id))
- rows = _db_rows(RECON_DB, sql, timeout=_timeout)
- cnt = _db_rows(RECON_DB, 'SELECT count(*) AS c FROM ssid WHERE scan = %d AND type = 4'
+ rows = _db_rows(db, sql, timeout=_timeout)
+ cnt = _db_rows(db, 'SELECT count(*) AS c FROM ssid WHERE scan = %d AND type = 4'
% scan_id, timeout=_timeout)
unassociated = cnt[0]['c'] if cnt else 0
else:
- rows = _db_rows(RECON_DB,
+ rows = _db_rows(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, "
@@ -1189,6 +1187,16 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None):
def h_recon_start(ctx):
+ # Serialize starts: the firmware has no abort for a timed scan, so a
+ # second /recon/new while one is running just stacks another empty scan
+ # (the 3s-apart rows we saw). Refuse instead of piling on.
+ scanning, remaining = _recon_scan_snapshot()
+ if scanning:
+ return 409, {
+ 'error': 'A recon scan is already running and cannot be '
+ 'interrupted; it will finish automatically.',
+ 'scan_remaining': remaining,
+ }
scan_time = (getattr(ctx, 'body', None) or {}).get(
'scan_time', DEFAULT_RECON_DURATION)
try:
@@ -1237,15 +1245,42 @@ def _recon_watchdog_tick():
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.
- Also records a survey sample when a survey is active.
"""
st = _recon_scan_state
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']:
st['active'] = False
+
+
+_hopper_cache = {'updated': 0, 'online': None}
+HOPPER_CACHE_SECONDS = 10.0
+HOPPER_IFACE = 'wlan2mon'
+
+
+def _hopper_online():
+ """Whether the fast-hopping radio (wlan2mon per pineapd config) exists.
+
+ iwinfo is a subprocess, so the answer is cached for a few seconds; the
+ UI polls status every 5s.
+ """
+ now = time.time()
+ if now - _hopper_cache['updated'] < HOPPER_CACHE_SECONDS:
+ return _hopper_cache['online']
try:
- _survey_sample()
+ online = HOPPER_IFACE in wifi_ifaces()
except Exception:
- pass
+ online = None
+ _hopper_cache.update({'updated': now, 'online': online})
+ return online
+
+
+def _recon_history_reset():
+ """True when pineapd rotated the live db (error-*-recon.db exists)."""
+ directory = os.path.dirname(RECON_DB)
+ try:
+ names = os.listdir(directory)
+ except OSError:
+ return False
+ return any(n.startswith('error-') and n.endswith('recon.db') for n in names)
def h_recon_status(ctx):
@@ -1271,7 +1306,9 @@ def h_recon_status(ctx):
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, 'stale': stale}
+ 'scanning': scanning, 'scan_remaining': remaining, 'stale': stale,
+ 'hopper_online': _hopper_online(),
+ 'history_reset': _recon_history_reset()}
def _db_write(db, sql):
@@ -1369,13 +1406,20 @@ def h_recon_scans(ctx):
def h_recon_scan_detail(ctx):
scan_id = int(ctx.args[0])
- data = recon_scan_data(scan_id)
+ # Bounded + retried: the live view polls this every few seconds, and an
+ # unbounded read across ssid/wifi_device can hold the DB long enough to
+ # make pineapd rotate it (SQLITE_BUSY -> error-*-recon.db).
+ try:
+ data = _recon_read_retry(lambda: recon_scan_data(
+ scan_id, _timeout=15, _limit=300))
+ except RuntimeError:
+ return 503, {'error': 'recon database is temporarily unavailable'}
if data is None:
return 404, {'error': 'scan not found'}
return 200, data
# ---------------------------------------------------------------------------
-# Recon report helpers (CSV / HTML) for scan and survey downloads.
+# Recon report helpers (CSV / HTML) for scan downloads.
# ---------------------------------------------------------------------------
def _fmt_ts(ts):
@@ -1406,16 +1450,6 @@ def _aps_csv(data):
return '\r\n'.join(out) + '\r\n'
-def _survey_aps_csv(detail):
- out = ['bssid,ssid,band,channel,min_dbm,avg_dbm,max_dbm,samples,first_seen,last_seen']
- for a in (detail or {}).get('aps') or []:
- out.append(','.join(_csv_escape(x) for x in [
- a.get('bssid'), a.get('ssid'), a.get('band'), a.get('channel'),
- a.get('min'), a.get('avg'), a.get('max'), a.get('samples'),
- _fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))]))
- return '\r\n'.join(out) + '\r\n'
-
-
def _esc_html(v):
if v is None:
return ''
@@ -1425,42 +1459,97 @@ def _esc_html(v):
REPORT_CSS = """
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; margin: 24px; color: #222; background: #fff; }
-h1 { font-size: 20px; margin: 0 0 4px; }
-.sub { color: #666; margin-bottom: 16px; }
+h1 { font-size: 22px; margin: 0 0 2px; }
+.sub { color: #666; margin-bottom: 2px; }
+.meta { color: #888; font-size: 13px; margin: 4px 0 12px; }
+.stat-cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0 4px; }
+.stat-card { border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 16px; min-width: 108px; background: #fafafa; }
+.stat-value { font-size: 26px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
+.stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #888; }
+h2 { font-size: 15px; margin: 20px 0 8px; border-bottom: 2px solid #1a237e; padding-bottom: 4px; color: #1a237e; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
th { background: #f4f4f4; }
tr:nth-child(even) td { background: #fafafa; }
-.stats { margin: 12px 0; font-size: 13px; color: #333; }
+.sig-strong { color: #2e7d32; font-weight: 600; white-space: nowrap; }
+.sig-good { color: #f9a825; font-weight: 600; white-space: nowrap; }
+.sig-weak { color: #ef6c00; font-weight: 600; white-space: nowrap; }
+.sig-dead { color: #c62828; font-weight: 600; white-space: nowrap; }
+.empty { color: #888; font-style: italic; }
+@media print { body { margin: 12px; } .stat-card { border-color: #ccc; } }
"""
-def _html_table(headers, rows):
+def _html_table(headers, rows, raw_columns=None):
+ """Render an HTML table. Cells are HTML-escaped unless their column index
+ is listed in raw_columns (caller-provided safe markup, e.g. colored
+ signal spans)."""
+ raw = set(raw_columns or ())
out = ['
']
for header in headers:
out.append('| %s | ' % _esc_html(header))
out.append('
')
for row in rows:
out.append('')
- for cell in row:
- out.append('| %s | ' % _esc_html(cell))
+ for i, cell in enumerate(row):
+ out.append('%s | ' % (cell if i in raw else _esc_html(cell)))
out.append('
')
out.append('
')
return ''.join(out)
-def _html_doc(title, subtitle, body_html, stats=None):
+def _html_doc(title, subtitle, body_html, stats=None, meta=None):
parts = ['%s'
'' % (_esc_html(title), REPORT_CSS)]
parts.append('%s
' % _esc_html(title))
parts.append('%s
' % _esc_html(subtitle))
- for label, value in (stats or []):
- parts.append('%s: %s
' % (_esc_html(label), value))
+ for line in (meta or []):
+ parts.append('%s
' % _esc_html(line))
+ if stats:
+ parts.append('')
+ for label, value in stats:
+ parts.append('
'
+ % (_esc_html(value), _esc_html(label)))
+ parts.append('
')
parts.append(body_html)
parts.append('')
return ''.join(parts)
+def _signal_html(dbm):
+ """Color-coded dBm cell matching the UI thresholds."""
+ if dbm is None:
+ return '--'
+ if dbm >= -50:
+ cls = 'sig-strong'
+ elif dbm >= -67:
+ cls = 'sig-good'
+ elif dbm >= -80:
+ cls = 'sig-weak'
+ else:
+ cls = 'sig-dead'
+ return '%d dBm' % (cls, dbm)
+
+
+def _enc_bucket(enc):
+ """Encryption display string -> coarse bucket (mirrors the UI chips)."""
+ s = (enc or '').strip()
+ if not s or s == 'Open':
+ return 'Open'
+ if 'Enterprise' in s:
+ return 'Enterprise'
+ if 'WEP' in s:
+ return 'WEP'
+ if 'WPA2' in s:
+ return 'WPA2'
+ if 'WPA3' in s:
+ return 'WPA3'
+ if 'WPA' in s:
+ return 'WPA'
+ return 'Unknown'
+
+
def _recon_read_retry(fn, attempts=3, pause=1.0):
"""Retry a recon.db read briefly; pineapd's write bursts hold the DB
exclusive lock and reads can time out mid-burst."""
@@ -1474,8 +1563,10 @@ def _recon_read_retry(fn, attempts=3, pause=1.0):
raise last
-def _scan_client_count(scan_id, _timeout=12):
- rows = _db_rows(RECON_DB,
+def _scan_client_count(scan_id, _timeout=12, db=None):
+ if db is None:
+ db = RECON_DB
+ rows = _db_rows(db,
"SELECT count(*) AS c FROM wifi_device w WHERE w.scan = %d "
"AND w.mac NOT IN (SELECT DISTINCT bssid FROM ssid "
"WHERE scan = %d AND type = 8 AND bssid IS NOT NULL)"
@@ -1495,6 +1586,79 @@ def h_recon_scan_download_csv(ctx):
'scan-%d.csv' % scan_id)
+def _recon_html_download(scan_id, data, client_count, archive=None):
+ """Shared HTML report body for live and archived scans.
+
+ GPS metadata is only attached for live scans (an archive's data is
+ historical, so a current fix would be misleading).
+ """
+ aps = data.get('aps') or []
+ scan = data.get('scan') or {}
+ meta = []
+ if archive is None:
+ try:
+ gps = _gps_status_data()
+ if gps.get('lock'):
+ meta.append('GPS: %.5f, %.5f%s' % (
+ gps.get('lat') or 0, gps.get('lon') or 0,
+ (' \u00b7 %d sats' % gps.get('satellites'))
+ if gps.get('satellites') else ''))
+ except Exception:
+ pass
+ stats = [('Access Points', len(aps)),
+ ('Clients', client_count),
+ ('Handshakes', len(data.get('handshakes') or [])),
+ ('Unassociated', data.get('unassociated') or 0)]
+ body_parts = []
+ # Band and encryption breakdowns.
+ band_counts = {}
+ enc_counts = {}
+ for a in aps:
+ band_counts[a.get('band') or '--'] = band_counts.get(a.get('band') or '--', 0) + 1
+ b = _enc_bucket(a.get('encryption'))
+ enc_counts[b] = enc_counts.get(b, 0) + 1
+ body_parts.append('Band Breakdown
')
+ body_parts.append(_html_table(['Band', 'Access Points'], sorted(
+ band_counts.items(), key=lambda kv: kv[1], reverse=True)))
+ body_parts.append('Encryption Breakdown
')
+ body_parts.append(_html_table(['Encryption', 'Access Points'], sorted(
+ enc_counts.items(), key=lambda kv: kv[1], reverse=True)))
+ # Channel occupancy (count per channel, split by band).
+ chan_counts = {}
+ for a in aps:
+ if a.get('channel') is None:
+ continue
+ key = (a.get('band') or '--', a.get('channel'))
+ chan_counts[key] = chan_counts.get(key, 0) + 1
+ body_parts.append('Channel Occupancy
')
+ if chan_counts:
+ chan_rows = [[band + ' GHz', ch, n] for (band, ch), n in sorted(
+ chan_counts.items(), key=lambda kv: (kv[0][0], kv[0][1]))]
+ body_parts.append(_html_table(['Band', 'Channel', 'Access Points'], chan_rows))
+ else:
+ body_parts.append('No access points with a known channel.
')
+ # AP table with color-coded signal.
+ ap_rows = []
+ for a in aps:
+ ap_rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
+ a.get('band') or '--',
+ a.get('channel') if a.get('channel') is not None else '--',
+ _signal_html(a.get('signal')),
+ a.get('encryption') or '--',
+ a.get('vendor') or '--',
+ _fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
+ body_parts.append('Access Points
')
+ body_parts.append(_html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal',
+ 'Encryption', 'Vendor', 'First seen', 'Last seen'],
+ ap_rows, raw_columns=(4,)))
+ subtitle = ('Pager recon capture report (archived history)'
+ if archive else 'Pager recon capture report')
+ return Download(_html_doc('Scan #%d' % scan.get('id'),
+ subtitle, '\n'.join(body_parts),
+ stats=stats, meta=meta).encode('utf-8'),
+ 'text/html', 'scan-%d.html' % scan_id)
+
+
def h_recon_scan_download_html(ctx):
scan_id = int(ctx.args[0])
try:
@@ -1504,24 +1668,153 @@ def h_recon_scan_download_html(ctx):
return 503, {'error': 'recon database is temporarily unavailable'}
if data is None:
return 404, {'error': 'scan not found'}
- rows = []
- for a in data.get('aps') or []:
- rows.append([a.get('ssid') or '(hidden)', a.get('bssid'), a.get('band'),
- a.get('channel'), a.get('signal'), a.get('encryption'),
- a.get('vendor'), _fmt_ts(a.get('first_seen')),
- _fmt_ts(a.get('last_seen'))])
- scan = data.get('scan') or {}
- stats = [('Started', _fmt_ts(scan.get('time'))),
- ('Access points', len(data.get('aps') or [])),
- ('Clients', client_count),
- ('Handshakes', len(data.get('handshakes') or [])),
- ('Unassociated', data.get('unassociated') or 0)]
- body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal', 'Encryption',
- 'Vendor', 'First seen', 'Last seen'], rows)
- return 200, Download(_html_doc('Scan #%d' % scan.get('id'),
- 'Pager recon capture report', body,
- stats=stats).encode('utf-8'),
- 'text/html', 'scan-%d.html' % scan_id)
+ return 200, _recon_html_download(scan_id, data, client_count)
+
+
+# ---------------------------------------------------------------------------
+# Recon archives: read-only history from pineapd-rotated databases.
+# pineapd renames recon.db to error--recon.db when an INSERT hits
+# SQLITE_BUSY, then starts a fresh database at scan 1. These endpoints expose
+# the rotated files as read-only history. They never write and never change
+# the live RECON_DB.
+# ---------------------------------------------------------------------------
+
+def _recon_archive_path(archive_id):
+ """Resolve a URL-encoded archive id (a rotated db filename) to an absolute
+ path, or None if it is not a real archive in the recon directory."""
+ if not archive_id:
+ return None
+ base = os.path.basename(archive_id)
+ if base != archive_id or '/' in archive_id or '\\' in archive_id:
+ return None
+ if not (base.startswith('error-') or base.startswith('diagnostic-')):
+ return None
+ if not base.endswith('recon.db'):
+ return None
+ path = os.path.join(os.path.dirname(RECON_DB), base)
+ return path if os.path.isfile(path) else None
+
+
+_recon_archives_cache = {'dir': None, 'updated': 0, 'data': None}
+RECON_ARCHIVES_CACHE_SECONDS = 15.0
+
+
+def recon_archives_data():
+ now = time.time()
+ directory = os.path.dirname(RECON_DB)
+ cache = _recon_archives_cache
+ if (cache['dir'] == directory and cache['data'] is not None
+ and now - cache['updated'] < RECON_ARCHIVES_CACHE_SECONDS):
+ return cache['data']
+ archives = []
+ try:
+ names = sorted(os.listdir(directory))
+ except OSError:
+ return {'archives': []}
+ for name in names:
+ if not (name.startswith('error-') or name.startswith('diagnostic-')):
+ continue
+ if not name.endswith('recon.db'):
+ continue
+ path = os.path.join(directory, name)
+ try:
+ mtime = int(os.path.getmtime(path))
+ except OSError:
+ mtime = None
+ scans = []
+ try:
+ scans = recon_scans_data(limit=500, _timeout=15, db=path).get('scans') or []
+ except RuntimeError:
+ pass
+ lo = hi = n = None
+ if scans:
+ lo = min(s['id'] for s in scans)
+ hi = max(s['id'] for s in scans)
+ n = len(scans)
+ archives.append({'id': name, 'label': name, 'mtime': mtime,
+ 'min_id': lo, 'max_id': hi, 'scans_count': n,
+ 'scans': scans})
+ data = {'archives': archives}
+ cache.update({'dir': directory, 'updated': time.time(), 'data': data})
+ return data
+
+
+def h_recon_archives(ctx):
+ return 200, recon_archives_data()
+
+
+def h_recon_archive_scans(ctx):
+ path = _recon_archive_path(_unquote_plus(ctx.args[0]))
+ if path is None:
+ return 404, {'error': 'archive not found'}
+ try:
+ data = recon_scans_data(limit=500, db=path)
+ except RuntimeError:
+ return 503, {'error': 'archive database is temporarily unavailable'}
+ return 200, dict(data, archive=ctx.args[0])
+
+
+def h_recon_archive_scan_detail(ctx):
+ path = _recon_archive_path(_unquote_plus(ctx.args[0]))
+ if path is None:
+ return 404, {'error': 'archive not found'}
+ scan_id = int(ctx.args[1])
+ try:
+ data = _recon_read_retry(lambda: recon_scan_data(
+ scan_id, _timeout=15, _limit=300, db=path))
+ except RuntimeError:
+ return 503, {'error': 'archive database is temporarily unavailable'}
+ if data is None:
+ return 404, {'error': 'scan not found in archive'}
+ return 200, data
+
+
+def h_recon_archive_scan_download(ctx):
+ path = _recon_archive_path(_unquote_plus(ctx.args[0]))
+ if path is None:
+ return 404, {'error': 'archive not found'}
+ scan_id = int(ctx.args[1])
+ try:
+ data = _recon_read_retry(lambda: recon_scan_data(
+ scan_id, _timeout=15, _limit=300, db=path))
+ except RuntimeError:
+ return 503, {'error': 'archive database is temporarily unavailable'}
+ if data is None:
+ return 404, {'error': 'scan not found in archive'}
+ return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
+ 'application/json', 'scan-%d.json' % scan_id)
+
+
+def h_recon_archive_scan_download_csv(ctx):
+ path = _recon_archive_path(_unquote_plus(ctx.args[0]))
+ if path is None:
+ return 404, {'error': 'archive not found'}
+ scan_id = int(ctx.args[1])
+ try:
+ data = _recon_read_retry(lambda: recon_scan_data(
+ scan_id, _timeout=15, _limit=300, db=path))
+ except RuntimeError:
+ return 503, {'error': 'archive database is temporarily unavailable'}
+ if data is None:
+ return 404, {'error': 'scan not found in archive'}
+ return 200, Download(_aps_csv(data).encode('utf-8'), 'text/csv',
+ 'scan-%d.csv' % scan_id)
+
+
+def h_recon_archive_scan_download_html(ctx):
+ path = _recon_archive_path(_unquote_plus(ctx.args[0]))
+ if path is None:
+ return 404, {'error': 'archive not found'}
+ scan_id = int(ctx.args[1])
+ try:
+ data = _recon_read_retry(lambda: recon_scan_data(
+ scan_id, _timeout=15, _limit=300, db=path))
+ client_count = _scan_client_count(scan_id, _timeout=12, db=path)
+ except RuntimeError:
+ return 503, {'error': 'archive database is temporarily unavailable'}
+ if data is None:
+ return 404, {'error': 'scan not found in archive'}
+ return 200, _recon_html_download(scan_id, data, client_count, archive=True)
# ---------------------------------------------------------------------------
@@ -1640,7 +1933,9 @@ def _gps_status_data_nocache():
'satellites': None, 'gpsd_running': running,
'updated': None, 'wigle': _wigle_config().get('logwigle', False)}
fix = _gps_from_gpspipe() if running else None
- if fix is None:
+ # GPS_GET through the daemon can block for seconds when gpsd is down;
+ # only fall back to it while gpsd is actually running.
+ if fix is None and running:
fix = _gps_from_hak5cmd()
if fix:
data.update(fix)
@@ -1765,290 +2060,6 @@ def h_recon_wigle(ctx):
return 200, resp
-# ---------------------------------------------------------------------------
-# Surveys: JSONL overlay on the device (meta line + one 'sample' line per
-# tick of the always-on recon). The recon.db itself is never modified.
-# ---------------------------------------------------------------------------
-
-def _survey_path(sid):
- return _safe_join(SURVEY_DIR, '%s.jsonl' % sid)
-
-
-def _survey_slug(name):
- return re.sub(r'[^A-Za-z0-9]+', '-', (name or '').strip()).strip('-')
-
-
-def _survey_recording_state():
- st = _survey_state
- if not st['active']:
- return None
- return {'active': True, 'id': st['id'], 'name': st['name'],
- 'started': st['started'], 'samples': st['samples']}
-
-
-def _survey_sample():
- with _survey_lock:
- st = _survey_state
- if not st['active'] or not st['path']:
- return
- now = time.time()
- if now - st['last_sample'] < SURVEY_SAMPLE_INTERVAL:
- return
- if st['samples'] >= SURVEY_MAX_SAMPLES:
- st['active'] = False
- return
- try:
- scans = recon_scans_data(1, _timeout=6).get('scans') or []
- detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300) if scans else None
- gps = _gps_status_data()
- sample = {'t': int(now),
- 'scan': (detail or {}).get('scan'),
- 'aps': (detail or {}).get('aps') or [],
- 'clients': (detail or {}).get('clients') or [],
- 'handshakes': (detail or {}).get('handshakes') or [],
- 'unassociated': (detail or {}).get('unassociated') or 0,
- 'gps': {'fix': gps.get('fix'), 'lat': gps.get('lat'),
- 'lon': gps.get('lon'),
- 'satellites': gps.get('satellites')}}
- with open(st['path'], 'a') as fh:
- fh.write(json.dumps({'sample': sample}) + '\n')
- st['samples'] += 1
- st['last_sample'] = now
- except Exception:
- pass
-
-
-def h_recon_survey_start(ctx):
- with _survey_lock:
- if _survey_state['active']:
- return 409, {'error': 'a survey is already recording'}
- name = ((getattr(ctx, 'body', None) or {}).get('name') or '').strip()
- sid = time.strftime('%Y%m%d-%H%M%S')
- slug = _survey_slug(name)
- if slug:
- sid += '-' + slug
- path = _survey_path(sid)
- if path is None:
- return 500, {'error': 'invalid survey id'}
- try:
- os.makedirs(SURVEY_DIR, exist_ok=True)
- except OSError:
- return 500, {'error': 'cannot create survey directory'}
- meta = {'id': sid, 'name': name, 'started': int(time.time()),
- 'interval': SURVEY_SAMPLE_INTERVAL, 'max_samples': SURVEY_MAX_SAMPLES}
- try:
- with open(path, 'w') as fh:
- fh.write(json.dumps({'meta': meta}) + '\n')
- except OSError:
- return 500, {'error': 'cannot write survey file'}
- _survey_state.update({'active': True, 'id': sid, 'name': name, 'path': path,
- 'started': meta['started'], 'samples': 0,
- 'last_sample': 0})
- return 200, {'ok': True, 'id': sid, 'started': meta['started']}
-
-
-def h_recon_survey_stop(ctx):
- with _survey_lock:
- st = dict(_survey_state)
- if st['active']:
- _survey_state['active'] = False
- return 200, {'ok': True, 'samples': st['samples'], 'id': st['id']}
-
-
-def h_recon_survey_live(ctx):
- detail = None
- try:
- scans = recon_scans_data(1, _timeout=6).get('scans') or []
- if scans:
- detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300)
- except RuntimeError:
- pass
- try:
- gps = _gps_status_data()
- except Exception:
- gps = {}
- return 200, {'scan': (detail or {}).get('scan'),
- 'aps': (detail or {}).get('aps') or [],
- 'clients': (detail or {}).get('clients') or [],
- 'handshakes': (detail or {}).get('handshakes') or [],
- 'unassociated': (detail or {}).get('unassociated') or 0,
- 'gps': gps,
- 'recording': _survey_recording_state()}
-
-
-def recon_surveys_data():
- surveys = []
- if os.path.isdir(SURVEY_DIR):
- try:
- names = sorted(os.listdir(SURVEY_DIR), reverse=True)
- except OSError:
- names = []
- for name in names:
- if not name.endswith('.jsonl'):
- continue
- path = os.path.join(SURVEY_DIR, name)
- sid = name[:-6]
- meta = None
- samples = 0
- try:
- size = os.path.getsize(path)
- with open(path, 'r', errors='replace') as fh:
- for line in fh:
- line = line.strip()
- if not line:
- continue
- try:
- obj = json.loads(line)
- except ValueError:
- continue
- if 'meta' in obj:
- meta = obj['meta']
- elif 'sample' in obj:
- samples += 1
- except OSError:
- continue
- if meta is None:
- continue
- surveys.append({'id': sid, 'name': meta.get('name') or sid,
- 'started': meta.get('started'),
- 'samples': samples, 'size': size})
- return {'surveys': surveys}
-
-
-def h_recon_surveys(ctx):
- try:
- return 200, recon_surveys_data()
- except Exception:
- return 200, {'surveys': []}
-
-
-def recon_survey_data(sid):
- path = _survey_path(sid)
- if path is None or not os.path.isfile(path):
- return None
- agg = {}
- order = []
- gps_fixes = []
- try:
- with open(path, 'r', errors='replace') as fh:
- for line in fh:
- line = line.strip()
- if not line:
- continue
- try:
- obj = json.loads(line)
- except ValueError:
- continue
- s = obj.get('sample')
- if not isinstance(s, dict):
- continue
- g = s.get('gps') or {}
- if g.get('lat') is not None and g.get('lon') is not None:
- gps_fixes.append({'t': s.get('t'), 'lat': g['lat'],
- 'lon': g['lon']})
- for a in s.get('aps') or []:
- bssid = a.get('bssid')
- if not bssid:
- continue
- entry = agg.get(bssid)
- if entry is None:
- entry = {'ssid': a.get('ssid'), 'bssid': bssid,
- 'band': a.get('band'), 'channel': a.get('channel'),
- 'min': None, 'max': None, 'sum': 0, 'count': 0,
- 'first_seen': None, 'last_seen': None}
- agg[bssid] = entry
- order.append(bssid)
- sig = a.get('signal')
- if sig is not None:
- entry['min'] = sig if entry['min'] is None else min(entry['min'], sig)
- entry['max'] = sig if entry['max'] is None else max(entry['max'], sig)
- entry['sum'] += sig
- entry['count'] += 1
- t = s.get('t')
- if t:
- entry['first_seen'] = (t if entry['first_seen'] is None
- else min(entry['first_seen'], t))
- entry['last_seen'] = (t if entry['last_seen'] is None
- else max(entry['last_seen'], t))
- except OSError:
- return None
- aps = []
- for bssid in order:
- e = agg[bssid]
- aps.append({'ssid': e['ssid'], 'bssid': e['bssid'], 'band': e['band'],
- 'channel': e['channel'], 'min': e['min'],
- 'avg': round(e['sum'] / e['count']) if e['count'] else None,
- 'max': e['max'], 'samples': e['count'],
- 'first_seen': e['first_seen'], 'last_seen': e['last_seen']})
- aps.sort(key=lambda a: a['avg'] if a['avg'] is not None else 0)
- return {'id': sid, 'aps': aps, 'gps_fixes': len(gps_fixes),
- 'first_gps': gps_fixes[0] if gps_fixes else None,
- 'last_gps': gps_fixes[-1] if gps_fixes else None}
-
-
-def h_recon_survey_detail(ctx):
- sid = ctx.args[0]
- data = recon_survey_data(sid)
- if data is None:
- return 404, {'error': 'survey not found'}
- return 200, data
-
-
-def h_recon_survey_download(ctx):
- sid = ctx.args[0]
- fmt = ctx.args[1]
- if fmt == 'json':
- data = recon_survey_data(sid)
- if data is None:
- return 404, {'error': 'survey not found'}
- return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
- 'application/json', 'survey-%s.json' % sid)
- data = recon_survey_data(sid)
- if data is None:
- return 404, {'error': 'survey not found'}
- if fmt == 'csv':
- return 200, Download(_survey_aps_csv(data).encode('utf-8'), 'text/csv',
- 'survey-%s.csv' % sid)
- if fmt == 'html':
- rows = []
- for a in data.get('aps') or []:
- rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
- a.get('band'), a.get('channel'), a.get('min'),
- a.get('avg'), a.get('max'), a.get('samples'),
- _fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
- fixes = data.get('gps_fixes') or 0
- fg = data.get('first_gps') or {}
- lg = data.get('last_gps') or {}
- if fixes:
- gps = ('%d fixes · first %.5f, %.5f · last %.5f, %.5f'
- % (fixes, fg.get('lat') or 0, fg.get('lon') or 0,
- lg.get('lat') or 0, lg.get('lon') or 0))
- else:
- gps = 'No GPS fixes during this survey'
- body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Min', 'Avg', 'Max',
- 'Samples', 'First', 'Last'], rows)
- return 200, Download(_html_doc('Survey %s' % sid,
- 'AP signal aggregates', body,
- stats=[('GPS', gps)]).encode('utf-8'),
- 'text/html', 'survey-%s.html' % sid)
- return 404, {'error': 'unknown format'}
-
-
-def h_recon_survey_delete(ctx):
- sid = ctx.args[0]
- path = _survey_path(sid)
- if path is None or not os.path.isfile(path):
- return 404, {'error': 'survey not found'}
- with _survey_lock:
- if _survey_state.get('id') == sid and _survey_state['active']:
- return 409, {'error': 'cannot delete the survey that is recording'}
- try:
- os.remove(path)
- except OSError:
- return 500, {'error': 'delete failed'}
- return 200, {'ok': True}
-
-
HS_FILENAME_RE = re.compile(
r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
@@ -3757,13 +3768,12 @@ ROUTER.add('POST', r'/api/recon/gps/configure', h_recon_gps_configure)
ROUTER.add('POST', r'/api/recon/wigle', h_recon_wigle)
ROUTER.add('GET', r'/api/recon/wigle/files', h_recon_wigle_files)
ROUTER.add('GET', r'/api/recon/wigle/files/([^/]+)', h_recon_wigle_file)
-ROUTER.add('GET', r'/api/recon/survey/live', h_recon_survey_live)
-ROUTER.add('POST', r'/api/recon/survey/start', h_recon_survey_start)
-ROUTER.add('POST', r'/api/recon/survey/stop', h_recon_survey_stop)
-ROUTER.add('GET', r'/api/recon/surveys', h_recon_surveys)
-ROUTER.add('GET', r'/api/recon/surveys/([^/]+)', h_recon_survey_detail)
-ROUTER.add('GET', r'/api/recon/surveys/([^/]+)/download/(csv|json|html)', h_recon_survey_download)
-ROUTER.add('DELETE', r'/api/recon/surveys/([^/]+)', h_recon_survey_delete)
+ROUTER.add('GET', r'/api/recon/archives', h_recon_archives)
+ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans', h_recon_archive_scans)
+ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)', h_recon_archive_scan_detail)
+ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/json', h_recon_archive_scan_download)
+ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/csv', h_recon_archive_scan_download_csv)
+ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/html', h_recon_archive_scan_download_html)
ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css
index 7b8490e..4c68be2 100644
--- a/payload/user/remote_access/pager-webui/www/css/app.css
+++ b/payload/user/remote_access/pager-webui/www/css/app.css
@@ -316,6 +316,9 @@ html.dark .muted { color: #bdbdbd; }
.icon-btn svg { width: 22px; height: 22px; }
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.recon-scan-bar .sel { width: auto; }
+.recon-scan-status { font-size: 12px; }
+.recon-scan-status.warn { color: #b26a00; }
+html.dark .recon-scan-status.warn { color: #ffb74d; }
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.recon-table-head h2 { margin: 0; }
.recon-search { max-width: 180px; }
@@ -372,50 +375,33 @@ html.dark .recon-dbm-bar { background: #333; }
.recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
-/* ---- Survey view ---- */
-.survey-live-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
-.survey-live-status { font-size: 13px; color: var(--text); }
-.survey-dur { width: auto; }
-.survey-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 4px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; }
-.survey-pill:hover { color: var(--text); border-color: var(--primary); }
-.survey-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
-html.dark .survey-pill.on { background: #1b3a23; color: #81c784; }
-.survey-wigle { font-size: 13px; color: var(--text); margin: 0; }
-.survey-rec-card { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
-.survey-rec-name { flex: 1 1 220px; max-width: 340px; }
-.survey-rec-status { font-size: 12px; color: var(--muted); }
-.survey-rec-status.on { color: #e53935; font-weight: 600; }
-.survey-filter-row { margin-top: 10px; }
-.survey-search { width: auto; }
-.survey-chan-box { margin-top: 4px; }
-.survey-chan-band { font-size: 12px; font-weight: 600; color: var(--primary); margin: 10px 0 4px; }
-.survey-chan-row { display: flex; align-items: center; gap: 10px; margin: 3px 0; }
-.survey-chan-label { width: 46px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
-.survey-chan-track { flex: 1; height: 14px; border-radius: 3px; background: var(--surface-alt); overflow: hidden; }
-html.dark .survey-chan-track { background: #333; }
-.survey-chan-fill { display: block; height: 100%; background: var(--primary); border-radius: 3px; }
-.survey-chan-count { width: 30px; font-size: 12px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
-.survey-cmp-hint { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
-.survey-cmp-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
-.survey-cmp-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
-.survey-cmp-swatch { width: 10px; height: 10px; border-radius: 50%; flex: none; }
-.survey-cmp-sig { color: var(--muted); font-variant-numeric: tabular-nums; }
-.survey-cmp-check { display: inline-flex; }
-.survey-cmp-check input { width: auto; }
-.survey-discover { margin-top: 10px; }
-.survey-discover-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
-.survey-discover-title { font-size: 20px; font-weight: 500; }
-.survey-discover-readout { font-size: 44px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
-.survey-discover-sub { color: var(--muted); font-size: 13px; margin: 2px 0 8px; word-break: break-all; }
+/* ---- Recon compare + selection ---- */
+.recon-compare-hint { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
+.recon-compare-empty { margin: 6px 0; }
+.recon-compare-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
+.recon-compare-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
+.recon-compare-swatch { width: 10px; height: 10px; border-radius: 50%; flex: none; }
+.recon-compare-sig { color: var(--muted); font-variant-numeric: tabular-nums; }
+.recon-cmp-check { display: inline-flex; }
+.recon-cmp-check input { width: auto; }
+.recon-sel-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 2px 0 6px; }
+.recon-sel-chips.hidden { display: none; }
+.recon-sel-chip { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--primary); border-radius: 12px; padding: 2px 8px 2px 11px; font-size: 12px; color: var(--text); background: var(--surface-alt); }
+.recon-sel-chip-x { cursor: pointer; color: var(--muted); font-weight: 700; padding: 0 2px; }
+.recon-sel-chip-x:hover { color: #e53935; }
+.recon-sel-clear { padding: 2px 10px; font-size: 12px; }
+
+/* ---- Recon channel map ---- */
+.recon-map-sub { font-size: 12px; color: var(--muted); margin: -4px 0 6px; }
+.recon-map-chips { margin: 0 0 6px; }
+.recon-chip.disabled { opacity: .45; cursor: default; }
+.recon-chip.disabled:hover { color: var(--muted); border-color: var(--border); }
+.recon-map-box { position: relative; }
+.recon-map-box canvas { display: block; }
+.recon-map-box .recon-no-data { min-height: 60px; }
/* ---- Reports view ---- */
-.survey-detail-hint { font-size: 12px; color: var(--muted); margin-top: 10px; }
-.survey-detail-row { padding: 8px 10px; border: 1px solid var(--border); border-radius: 3px; margin-top: 6px; cursor: pointer; font-size: 13px; }
-.survey-detail-row:hover { border-color: var(--primary); }
-.survey-detail-row.open { border-color: var(--primary); background: var(--surface-alt); }
-.survey-detail-body { padding: 8px 4px; }
-.survey-detail-gps { font-size: 12px; color: var(--muted); margin: 6px 0; }
-.survey-wigle-warn { color: #ef6c00; font-size: 12px; }
+.wigle-warn { color: #ef6c00; font-size: 12px; }
/* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; }
diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js
index d1437fb..58b7671 100644
--- a/payload/user/remote_access/pager-webui/www/js/app.js
+++ b/payload/user/remote_access/pager-webui/www/js/app.js
@@ -84,6 +84,10 @@ const App = (() => {
function route() {
closeToolbarMenus();
const hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
+ if (hash === '#/recon/survey') {
+ location.hash = '#/recon';
+ return;
+ }
const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = '';
@@ -396,7 +400,6 @@ const App = (() => {
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
'#/recon': 'recon',
- '#/recon/survey': 'recon_survey',
'#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes',
'#/logging': 'logging',
diff --git a/payload/user/remote_access/pager-webui/www/js/chart.js b/payload/user/remote_access/pager-webui/www/js/chart.js
index 395c4d4..c0ca720 100644
--- a/payload/user/remote_access/pager-webui/www/js/chart.js
+++ b/payload/user/remote_access/pager-webui/www/js/chart.js
@@ -136,5 +136,114 @@ const MiniChart = (() => {
});
}
- return { draw, doughnut, bar };
+ function chanToMhz(band, ch) {
+ const n = Number(ch);
+ if (band === '2.4') return 2407 + 5 * n;
+ if (band === '5') return 5000 + 5 * n;
+ if (band === '6') return 5950 + 5 * n;
+ return null;
+ }
+
+ function bandRange(band) {
+ if (band === '2.4') return [2400, 2500];
+ if (band === '5') return [5150, 5900];
+ if (band === '6') return [5925, 7125];
+ return null;
+ }
+
+ function hexA(hex, alpha) {
+ const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
+ if (!m) return hex;
+ const n = parseInt(m[1], 16);
+ return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
+ }
+
+ // Channel map: each AP is a raised-cosine lobe at its reported center
+ // frequency with the peak at its signal strength. The radio does not report
+ // channel width, so every lobe assumes 20 MHz (half-width +-10 MHz).
+ function channelMap(canvas, aps, opts) {
+ const o = opts || {};
+ const dpr = window.devicePixelRatio || 1;
+ const H = o.height || 180;
+ canvas.width = canvas.clientWidth * dpr;
+ canvas.height = H * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ const w = canvas.clientWidth, h = H;
+ ctx.clearRect(0, 0, w, h);
+ if (!aps || !aps.length) return;
+ const pts = aps
+ .map((a) => ({
+ freq: a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel),
+ sig: a.signal
+ }))
+ .filter((p) => p.freq != null && p.sig != null);
+ if (!pts.length) return;
+ const def = bandRange(aps[0].band);
+ const lo = Math.min(def ? def[0] : Infinity, ...pts.map((p) => p.freq));
+ const hi = Math.max(def ? def[1] : -Infinity, ...pts.map((p) => p.freq));
+ const fMin = lo - 10, fMax = hi + 10;
+ const padL = 42, padR = 10, padT = 14, padB = 24;
+ const plotW = w - padL - padR, plotH = h - padT - padB;
+ const YMIN = -100, YMAX = -30;
+ const x = (f) => padL + (f - fMin) / (fMax - fMin) * plotW;
+ const y = (dbm) => padT + (1 - (dbm - YMIN) / (YMAX - YMIN)) * plotH;
+ ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
+ ctx.textAlign = 'right';
+ for (let dbm = YMIN; dbm <= YMAX; dbm += 10) {
+ const yy = y(dbm);
+ ctx.strokeStyle = o.grid || '#e0e0e0';
+ ctx.lineWidth = 1;
+ ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(w - padR, yy); ctx.stroke();
+ ctx.fillStyle = '#888';
+ ctx.fillText(String(dbm), padL - 6, yy + 3);
+ }
+ ctx.strokeStyle = o.grid || '#e0e0e0';
+ for (let f = Math.ceil(fMin / 20) * 20; f <= fMax; f += 20) {
+ ctx.beginPath(); ctx.moveTo(x(f), padT); ctx.lineTo(x(f), h - padB); ctx.stroke();
+ }
+ const baseY = h - padB;
+ aps.forEach((a) => {
+ const freq = a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel);
+ if (freq == null || a.signal == null) return;
+ const color = a.color || '#1976d2';
+ const cx = x(freq);
+ const topY = y(a.signal);
+ const peakH = Math.max(2, baseY - topY);
+ const half = Math.max(4, (10 / (fMax - fMin)) * plotW);
+ ctx.beginPath();
+ for (let i = 0; i <= 28; i++) {
+ const t = -1 + i / 14;
+ const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t));
+ const px = cx + t * half;
+ const py = baseY - lift * peakH;
+ if (i === 0) ctx.moveTo(px, py);
+ else ctx.lineTo(px, py);
+ }
+ ctx.closePath();
+ ctx.fillStyle = hexA(color, 0.35);
+ ctx.fill();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ });
+ let lastLabelX = -Infinity;
+ ctx.textAlign = 'center';
+ aps.forEach((a) => {
+ const freq = a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel);
+ if (freq == null || a.channel == null) return;
+ const cx = x(freq);
+ if (cx - lastLabelX < 34) return;
+ lastLabelX = cx;
+ ctx.strokeStyle = '#999';
+ ctx.beginPath(); ctx.moveTo(cx, h - padB); ctx.lineTo(cx, h - padB + 4); ctx.stroke();
+ ctx.fillStyle = '#666';
+ ctx.fillText('CH ' + a.channel, cx, h - 7);
+ });
+ ctx.textAlign = 'left';
+ ctx.fillStyle = '#888';
+ ctx.fillText((aps[0].band || '?') + ' GHz', padL + 4, padT + 2);
+ }
+
+ return { draw, doughnut, bar, channelMap };
})();
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 76f31ca..17f4802 100644
--- a/payload/user/remote_access/pager-webui/www/js/views.js
+++ b/payload/user/remote_access/pager-webui/www/js/views.js
@@ -1061,7 +1061,6 @@ views.pineap_filtering = (root) => {
const RECON_TABS = [
{ label: 'Scanning', hash: '#/recon' },
- { label: 'Survey', hash: '#/recon/survey' },
{ label: 'Reports', hash: '#/recon/reports' },
{ label: 'Handshakes', hash: '#/recon/handshakes' }
];
@@ -1069,6 +1068,8 @@ const RECON_TABS = [
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e'];
const RECON_ENC_BUCKETS = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA3', 'Enterprise'];
+const RECON_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
+const RECON_MAX_HISTORY = 90;
const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91'];
const RECON_AP_COLS = [
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
@@ -1120,7 +1121,7 @@ function reconBandLabel(band) {
function reconDefaultCols() {
return {
- ap: { ssid: true, bssid: true, band: true, channel: true, signal: true, vendor: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
+ ap: { compare: true, ssid: true, bssid: true, band: true, channel: true, signal: true, vendor: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true }
};
}
@@ -1128,7 +1129,10 @@ function reconDefaultCols() {
function reconLoadCols() {
try {
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
- if (v && v.ap && v.client) return v;
+ if (v && v.ap && v.client) {
+ if (typeof v.ap.compare !== 'boolean') v.ap.compare = true;
+ return v;
+ }
} catch (e) {}
return reconDefaultCols();
}
@@ -1177,8 +1181,11 @@ views.recon = (root) => {
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
- detailQueued: false, detailId: null,
- apBand: 'all', apEnc: 'all', gps: null, wigle: null };
+ detailQueued: false, detailId: null, detailArchive: null,
+ apBand: 'all', apEnc: 'all', gps: null, wigle: null,
+ compare: [], history: {}, mapBand: null,
+ archive: null, archives: [], scanRemaining: null,
+ hopperOnline: null, historyReset: false };
const cols = reconLoadCols();
// ---- title cards ----
@@ -1239,32 +1246,47 @@ views.recon = (root) => {
const psContent = titleCard('Previous Scans', false);
const psRow = h('div', { class: 'recon-ps-row' });
psContent.appendChild(psRow);
+ let pickerOptions = [];
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
sel.addEventListener('change', () => {
- state.selected = parseInt(sel.value, 10) || null;
+ const meta = pickerOptions[parseInt(sel.value, 10)];
+ if (!meta) return;
+ state.archive = meta.archive;
+ state.selected = meta.scanId;
state.apPage = 0; state.clientPage = 0;
+ state.detailId = null; state.detailArchive = null;
loadDetail();
});
psRow.appendChild(sel);
+ function dlBase() {
+ if (state.selected == null) return null;
+ return state.archive
+ ? App.apiBase + '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
+ : App.apiBase + '/api/recon/scans/' + state.selected;
+ }
const dlJson = iconBtn('file_download', 'Download scan JSON', () => {
- if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json';
+ const base = dlBase();
+ if (base) window.location = base + '/download/json';
});
const dlCsv = iconBtn('table_chart', 'Download scan CSV', () => {
- if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/csv';
+ const base = dlBase();
+ if (base) window.location = base + '/download/csv';
});
const dlHtml = iconBtn('description', 'Download scan HTML report', () => {
- if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/html';
+ const base = dlBase();
+ if (base) window.location = base + '/download/html';
});
psRow.appendChild(dlJson);
psRow.appendChild(dlCsv);
psRow.appendChild(dlHtml);
- psRow.appendChild(iconBtn('delete', 'Delete scan', () => {
- if (state.selected == null) return;
+ const delBtn = iconBtn('delete', 'Delete scan', () => {
+ if (state.selected == null || state.archive) return;
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
PagerAPI.del('/api/recon/scans/' + state.selected)
.then(() => { App.toast('Scan deleted'); load(); })
.catch(() => App.toast('Delete failed', 'error'));
- }));
+ });
+ psRow.appendChild(delBtn);
// ---- scan bar ----
const scanBar = h('div', { class: 'section recon-scan-bar' });
@@ -1278,6 +1300,8 @@ views.recon = (root) => {
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
scanBar.appendChild(durSel);
+ const scanStatus = h('span', { class: 'recon-scan-status muted', text: '' });
+ scanBar.appendChild(scanStatus);
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
const gpsPill = h('button', { class: 'recon-pill recon-pill-gps', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
const wiglePill = h('button', { class: 'recon-pill recon-pill-wigle', title: 'WiGLE logging (click to toggle)' });
@@ -1321,6 +1345,19 @@ views.recon = (root) => {
.finally(() => { wiglePill.disabled = false; renderPills(); });
});
let pendingScan = false;
+ function renderScanBar() {
+ const scanning = state.scanActive;
+ scanToggle.disabled = scanning || pendingScan;
+ durSel.disabled = scanning;
+ const bits = [];
+ if (scanning) {
+ bits.push('Scanning' + (state.scanRemaining != null ? ' · ' + state.scanRemaining + 's left' : ''));
+ }
+ if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen');
+ if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)');
+ scanStatus.textContent = bits.join(' · ');
+ scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset);
+ }
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
const on = scanToggle.checked;
@@ -1343,10 +1380,22 @@ views.recon = (root) => {
load();
})
.catch((err) => {
- scanToggle.checked = !on;
- App.toast((err && err.message) || 'Recon control failed', 'error');
+ if (err && err.status === 409) {
+ // A native timed scan is already running; keep the toggle on and
+ // show the remaining time instead of pretending the start failed.
+ scanToggle.checked = true;
+ state.scanActive = true;
+ state.scanRemaining = (err.data && err.data.scan_remaining) != null
+ ? err.data.scan_remaining : null;
+ renderScanBar();
+ App.toast('Scan already running — it will finish automatically' +
+ (state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : ''), 'error');
+ } else {
+ scanToggle.checked = !on;
+ App.toast((err && err.message) || 'Recon control failed', 'error');
+ }
})
- .finally(() => { pendingScan = false; scanToggle.disabled = false; });
+ .finally(() => { pendingScan = false; scanToggle.disabled = state.scanActive; });
});
// ---- settings sidebar ----
@@ -1355,7 +1404,7 @@ views.recon = (root) => {
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
const colDefs = {
- ap: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['band', 'Show Band'],
+ ap: [['compare', 'Show Compare'], ['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['band', 'Show Band'],
['channel', 'Show Channel'], ['signal', 'Show Signal'], ['vendor', 'Show Vendor'],
['encryption', 'Show Encryption'], ['first_seen', 'Show First Seen'],
['last_seen', 'Show Last Seen'], ['hidden', 'Show Hidden']],
@@ -1447,12 +1496,148 @@ views.recon = (root) => {
renderTables();
}
+ // ---- compare: select up to 6 APs; the rest of the page focuses on them ----
+ const cmpCard = h('div', { class: 'section recon-compare-card' });
+ root.appendChild(cmpCard);
+ cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
+ const cmpCanvas = h('canvas', { id: 'recon-compare', style: 'width:100%;height:150px' });
+ cmpCard.appendChild(cmpCanvas);
+ const cmpLegend = h('div', { class: 'recon-compare-legend' });
+ cmpCard.appendChild(cmpLegend);
+ const cmpEmpty = h('div', { class: 'empty recon-compare-empty', text: 'Tick the Compare box on an AP below to track its signal here.' });
+ cmpCard.appendChild(cmpEmpty);
+
+ function toggleCompare(a) {
+ if (!a || !a.bssid) return false;
+ const i = state.compare.indexOf(a.bssid);
+ if (i !== -1) {
+ state.compare = state.compare.filter((b) => b !== a.bssid);
+ } else {
+ if (state.compare.length >= 6) {
+ App.toast('Compare up to 6 APs', 'error');
+ return false;
+ }
+ state.compare.push(a.bssid);
+ const name = (a.ssid || '(hidden)');
+ App.toast('Comparing ' + (name.length > 24 ? name.slice(0, 24) + '…' : name));
+ }
+ renderSelection();
+ renderCompare();
+ renderTables();
+ drawCharts(state.detail || {});
+ renderChannelMap();
+ return true;
+ }
+
+ function renderCompare() {
+ const series = [];
+ const legends = [];
+ const d = state.detail || {};
+ const aps = d.aps || [];
+ state.compare.forEach((bssid, i) => {
+ const hist = state.history[bssid] || [];
+ let pts = hist.map((p) => p.sig).filter((v) => v != null);
+ if (pts.length === 1) pts = [pts[0], pts[0]];
+ if (pts.length < 2) {
+ const ap = aps.find((a) => a.bssid === bssid);
+ if (ap && ap.signal != null) pts = [ap.signal, ap.signal];
+ }
+ const color = RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length];
+ if (pts.length >= 2) series.push({ points: pts, color: color });
+ const ap = aps.find((a) => a.bssid === bssid);
+ const last = pts.length ? pts[pts.length - 1] : null;
+ legends.push(h('span', { class: 'recon-compare-legend-item' },
+ h('span', { class: 'recon-compare-swatch', style: 'background:' + color }),
+ h('span', { text: (ap && ap.ssid) || '(hidden) ' + (bssid || '').slice(0, 8) + '…' }),
+ h('span', { class: 'recon-compare-sig', text: last == null ? '--' : last + ' dBm' })));
+ });
+ cmpLegend.innerHTML = '';
+ legends.forEach((l) => cmpLegend.appendChild(l));
+ cmpEmpty.classList.toggle('hidden', state.compare.length > 0);
+ if (typeof MiniChart !== 'undefined' && MiniChart.draw) {
+ MiniChart.draw(cmpCanvas, series, { min: -100, max: -20, grid: '#e0e0e0' });
+ }
+ }
+
// ---- results tables ----
const apCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(apCard);
+
+ // ---- channel map (under Access Points) ----
+ const mapCard = h('div', { class: 'section recon-map-card' });
+ root.appendChild(mapCard);
+ mapCard.appendChild(h('h2', { text: 'Channel Map' }));
+ mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz.' }));
+ const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' });
+ mapCard.appendChild(mapChips);
+ const mapBox = h('div', { class: 'recon-map-box' });
+ mapCard.appendChild(mapBox);
+ const mapCanvas = h('canvas', { id: 'recon-map', style: 'width:100%;height:180px' });
+ mapBox.appendChild(mapCanvas);
+ const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' });
+ mapBox.appendChild(mapEmpty);
+
+ function mapAps() {
+ const d = state.detail || {};
+ const all = d.aps || [];
+ const sel = state.compare.length
+ ? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
+ : all;
+ return sel.map((a) => {
+ const idx = state.compare.length ? state.compare.indexOf(a.bssid) : -1;
+ const color = idx !== -1
+ ? RECON_COMPARE_COLORS[idx % RECON_COMPARE_COLORS.length]
+ : reconSigColor(a.signal);
+ return Object.assign({}, a, { color: color });
+ });
+ }
+
+ function renderChannelMap() {
+ const aps = mapAps().filter((a) => a.band === '2.4' || a.band === '5' || a.band === '6');
+ const bands = {};
+ aps.forEach((a) => { bands[a.band] = (bands[a.band] || 0) + 1; });
+ const order = ['2.4', '5', '6'];
+ const present = order.filter((b) => bands[b]);
+ if (!state.mapBand || !bands[state.mapBand]) {
+ state.mapBand = present.length
+ ? present.slice().sort((a, b) => bands[b] - bands[a])[0]
+ : null;
+ }
+ mapChips.innerHTML = '';
+ mapChips.appendChild(h('span', { class: 'recon-chips-label', text: 'Band' }));
+ order.forEach((b) => {
+ const c = h('button', {
+ class: 'recon-chip' + (state.mapBand === b ? ' active' : '') + (bands[b] ? '' : ' disabled'),
+ text: b + ' GHz'
+ });
+ c.addEventListener('click', () => {
+ if (!bands[b]) return;
+ state.mapBand = b;
+ renderChannelMap();
+ });
+ mapChips.appendChild(c);
+ });
+ const vis = aps.filter((a) => a.band === state.mapBand);
+ const hasChan = vis.some((a) => a.channel != null || a.freq != null);
+ if (!vis.length || !hasChan) {
+ mapCanvas.classList.add('hidden');
+ mapEmpty.classList.remove('hidden');
+ return;
+ }
+ mapEmpty.classList.add('hidden');
+ mapCanvas.classList.remove('hidden');
+ if (typeof MiniChart !== 'undefined' && MiniChart.channelMap) {
+ MiniChart.channelMap(mapCanvas, vis, { grid: '#e0e0e0' });
+ }
+ }
+
const cliCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(cliCard);
+ // ---- selection chips (visible while comparing) ----
+ const selRow = h('div', { class: 'recon-sel-chips hidden' });
+ apCard.appendChild(selRow);
+
// ---- band / encryption filter chips ----
const chipRow = h('div', { class: 'recon-chips-row' });
apCard.appendChild(chipRow);
@@ -1479,6 +1664,32 @@ views.recon = (root) => {
}
renderChips();
+ function renderSelection() {
+ selRow.innerHTML = '';
+ const d = state.detail || {};
+ const aps = d.aps || [];
+ if (!state.compare.length) { selRow.classList.add('hidden'); return; }
+ selRow.classList.remove('hidden');
+ selRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Comparing' }));
+ state.compare.forEach((bssid, i) => {
+ const ap = aps.find((a) => a.bssid === bssid);
+ const chip = h('span', { class: 'recon-sel-chip', style: 'border-color:' + RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length] },
+ h('span', { text: (ap && ap.ssid) || '(hidden)' }),
+ h('span', { class: 'recon-sel-chip-x', text: '×', title: 'Remove from comparison' }));
+ chip.querySelector('.recon-sel-chip-x').addEventListener('click', () => {
+ state.compare = state.compare.filter((b) => b !== bssid);
+ renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
+ });
+ selRow.appendChild(chip);
+ });
+ const clear = h('button', { class: 'btn ghost recon-sel-clear', text: 'Clear selection' });
+ clear.addEventListener('click', () => {
+ state.compare = [];
+ renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
+ });
+ selRow.appendChild(clear);
+ }
+
function buildPaginator(key) {
const mk = (id, icon, title, fn) => {
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title });
@@ -1496,13 +1707,25 @@ views.recon = (root) => {
function filteredRows(key) {
const d = state.detail || {};
- const rows = key === 'ap' ? (d.aps || []) : (d.clients || []);
- let out = reconFiltered(rows, key === 'ap' ? state.apSearch : state.clientSearch,
- key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS);
- if (key === 'ap') {
- if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
- if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
+ if (key === 'client') {
+ if (state.compare.length) return [];
+ return reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
}
+ const all = d.aps || [];
+ if (state.compare.length) {
+ if (state.apSearch) {
+ // Candidate list: search the full AP list so more networks can be
+ // added without clearing the selection. Band/enc chips apply here.
+ let out = reconFiltered(all, state.apSearch, RECON_AP_COLS);
+ if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
+ if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
+ return out;
+ }
+ return all.filter((a) => state.compare.indexOf(a.bssid) !== -1);
+ }
+ let out = reconFiltered(all, state.apSearch, RECON_AP_COLS);
+ if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
+ if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
return out;
}
@@ -1561,6 +1784,7 @@ views.recon = (root) => {
tbl.querySelectorAll('th').forEach((th, i) => {
const col = vis[i];
if (!col) return;
+ if (col.key === 'compare') return;
th.style.cursor = 'pointer';
th.title = 'Sort by ' + col.label;
const arrow = h('span', { class: 'recon-sort-arrow' });
@@ -1601,25 +1825,48 @@ views.recon = (root) => {
return copy;
}
+ const compareCol = { key: 'compare', label: 'Compare', render: (a) => {
+ const cb = h('input', { type: 'checkbox', title: 'Compare this AP' });
+ cb.checked = state.compare.indexOf(a.bssid) !== -1;
+ cb.addEventListener('click', (e) => e.stopPropagation());
+ cb.addEventListener('change', () => {
+ const ok = toggleCompare(a);
+ if (!ok) cb.checked = false;
+ });
+ return h('span', { class: 'recon-cmp-check' }, cb);
+ } };
+ const apCols = [compareCol].concat(RECON_AP_COLS);
+
function renderTables() {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
const apF = filteredRows('ap');
const cliF = filteredRows('client');
- renderTable(apBody, 'ap', sortRows(apF, 'ap', RECON_AP_COLS), RECON_AP_COLS, 'No access points in this scan.');
- renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
+ cliCard.classList.toggle('hidden', state.compare.length > 0);
+ renderTable(apBody, 'ap', sortRows(apF, 'ap', apCols), apCols,
+ state.compare.length && !state.apSearch ? 'No access points selected.' : 'No access points in this scan.');
+ if (!state.compare.length) {
+ renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
+ }
}
function drawCharts(d) {
- const n = (d.aps || []).length;
+ const all = d.aps || [];
+ const aps = state.compare.length
+ ? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
+ : all;
+ const n = aps.length;
const c = (d.clients || []).length;
const land = document.getElementById('recon-landscape');
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
- if (n + c > 0) {
- MiniChart.doughnut(land, [
- { label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
- { label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
- { label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
- ], { legend: true, height: 130 });
+ if (n > 0) {
+ const segs = state.compare.length
+ ? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
+ : [
+ { label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
+ { label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
+ { label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
+ ];
+ MiniChart.doughnut(land, segs, { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
@@ -1628,7 +1875,7 @@ views.recon = (root) => {
}
}
const counts = {};
- (d.aps || []).forEach((a) => {
+ aps.forEach((a) => {
const ch = a.channel == null ? '?' : a.channel;
counts[ch] = (counts[ch] || 0) + 1;
});
@@ -1651,13 +1898,13 @@ views.recon = (root) => {
}
}
const encCounts = {};
- (d.aps || []).forEach((a) => {
+ aps.forEach((a) => {
const b = reconEncBucket(a.encryption);
encCounts[b] = (encCounts[b] || 0) + 1;
});
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
- if ((d.aps || []).length) {
+ if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: true, height: 130 });
@@ -1670,22 +1917,59 @@ views.recon = (root) => {
}
}
+ function detailUrl() {
+ return state.archive
+ ? '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
+ : '/api/recon/scans/' + state.selected;
+ }
+
function loadDetail() {
if (state.selected == null) return;
const scanId = state.selected;
+ const arch = state.archive;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
- if (!state.scanActive && state.detailId === scanId) return;
+ if (!state.scanActive && state.detailId === scanId && state.detailArchive === arch) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
- PagerAPI.get('/api/recon/scans/' + scanId).then((r) => {
- if (state.selected !== scanId) return;
+ PagerAPI.get(detailUrl()).then((r) => {
+ if (state.selected !== scanId || state.archive !== arch) return;
+ const isNewScan = state.detailId !== scanId || state.detailArchive !== arch;
state.detail = r.data;
state.detailId = scanId;
+ state.detailArchive = arch;
+ if (isNewScan) state.history = {};
+ const aps = r.data.aps || [];
+ const seen = {};
+ aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; });
+ if (state.compare.some((b) => !seen[b])) {
+ state.compare = state.compare.filter((b) => seen[b]);
+ }
+ if (!arch) {
+ // Signal-over-time history only makes sense for the live database.
+ const nowT = Date.now() / 1000;
+ aps.forEach((a) => {
+ if (a.bssid == null || a.signal == null) return;
+ const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
+ hist.push({ t: nowT, sig: a.signal });
+ while (hist.length > RECON_MAX_HISTORY) hist.shift();
+ });
+ Object.keys(state.history).forEach((b) => {
+ if (!seen[b]) {
+ const hist = state.history[b];
+ const recent = hist.filter((p) => nowT - p.t < 30);
+ if (!recent.length) delete state.history[b];
+ else state.history[b] = recent;
+ }
+ });
+ }
drawCharts(r.data);
renderTables();
+ renderSelection();
+ renderCompare();
+ renderChannelMap();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {}).finally(() => {
state.detailLoading = false;
@@ -1697,429 +1981,139 @@ views.recon = (root) => {
});
}
+ function pickFollow(scans) {
+ // Prefer the newest scan with real data so a 0-AP restart row does not
+ // blank the table while following a live scan.
+ const nonEmpty = scans.find((s) => s.aps > 0 || s.devices > 0 || s.handshakes > 0);
+ return nonEmpty ? nonEmpty.id : (scans[0] ? scans[0].id : null);
+ }
+
+ function renderPicker() {
+ sel.innerHTML = '';
+ pickerOptions = [];
+ const cur = { archive: state.archive, scanId: state.selected };
+ const liveEmpty = (s) => !(s.aps > 0 || s.devices > 0 || s.handshakes > 0);
+ state.scans.forEach((s) => {
+ pickerOptions.push({ archive: null, scanId: s.id });
+ const opt = document.createElement('option');
+ opt.value = String(pickerOptions.length - 1);
+ opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time) + (liveEmpty(s) ? ' (empty)' : '');
+ if (liveEmpty(s)) opt.style.opacity = '0.55';
+ sel.appendChild(opt);
+ });
+ state.archives.forEach((a) => {
+ const og = document.createElement('optgroup');
+ og.label = 'Archive ·' + (a.max_id ? ' scans 1–' + a.max_id : '') +
+ (a.mtime ? ' · ' + fmtTime(a.mtime) : '');
+ (a.scans || []).forEach((s) => {
+ pickerOptions.push({ archive: a.id, scanId: s.id });
+ const opt = document.createElement('option');
+ opt.value = String(pickerOptions.length - 1);
+ opt.textContent = '#' + s.id + ' — ' + fmtTime(s.time) +
+ ((s.aps > 0 || s.devices > 0 || s.handshakes > 0) ? '' : ' (empty)');
+ og.appendChild(opt);
+ });
+ sel.appendChild(og);
+ });
+ const idx = pickerOptions.findIndex((m) => m.archive === cur.archive && m.scanId === cur.scanId);
+ if (idx !== -1) sel.value = String(idx);
+ delBtn.disabled = state.archive !== null;
+ delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan';
+ }
+
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
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');
- opt.value = s.id;
- opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time);
- sel.appendChild(opt);
- });
+ let keep = null;
+ if (state.autoFollow) {
+ // Following a live run jumps back to the live database.
+ state.archive = null;
+ state.detailArchive = null;
+ keep = pickFollow(state.scans);
+ } else if (!state.archive) {
+ keep = state.selected && state.scans.some((s) => s.id === state.selected)
+ ? state.selected : newest;
+ }
+ renderPicker();
if (keep == null) {
- state.detail = null;
- drawCharts({ aps: [], clients: [], handshakes: [] });
- renderTables();
- hsCount.textContent = '0';
+ if (!state.archive) {
+ state.detail = null;
+ state.detailId = null;
+ state.detailArchive = null;
+ drawCharts({ aps: [], clients: [], handshakes: [] });
+ renderTables();
+ renderSelection();
+ renderCompare();
+ renderChannelMap();
+ hsCount.textContent = '0';
+ }
+ } else {
+ if (keep !== state.selected) {
+ state.selected = keep;
+ state.detailId = null;
+ state.detailArchive = null;
+ }
+ if (state.selected != null) loadDetail();
}
- if (keep != null) sel.value = keep;
- state.selected = keep;
- if (keep != null) loadDetail();
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
+ state.scanRemaining = r.data.scan_remaining != null ? r.data.scan_remaining : null;
+ state.hopperOnline = r.data.hopper_online;
+ state.historyReset = !!r.data.history_reset;
if (!pendingScan) scanToggle.checked = scanning;
+ renderScanBar();
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
+ }
+
+ function loadSlow() {
+ // GPS and archive discovery change rarely; polling them on every 5s tick
+ // piles sqlite/iwinfo/gpsd work onto the same cycles as the scan data.
PagerAPI.get('/api/recon/gps').then((r) => {
state.gps = r.data;
state.wigle = !!(r.data || {}).wigle;
renderPills();
}).catch(() => {});
+ PagerAPI.get('/api/recon/archives').then((r) => {
+ state.archives = (r.data && r.data.archives) || [];
+ renderPicker();
+ }).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load();
+ loadSlow();
let pollIv = null;
const restartPoll = () => {
clearInterval(pollIv);
pollIv = setInterval(load, state.scanActive ? 5000 : 10000);
};
restartPoll();
- return { destroy: () => clearInterval(pollIv) };
-};
-
-const SURVEY_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
-const SURVEY_MAX_HISTORY = 90;
-
-views.recon_survey = (root) => {
- root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
- tabBar(root, RECON_TABS, '#/recon/survey');
-
- const state = {
- live: { scan: null, aps: [], clients: [], handshakes: [], unassociated: 0, gps: {}, recording: null },
- band: 'all', search: '',
- history: {}, compare: [], discover: null,
- gps: {}, wigle: false,
- recording: null, recBusy: false, scanBusy: false, updated: 0
+ let slowIv = null;
+ const restartSlow = () => {
+ clearInterval(slowIv);
+ slowIv = setInterval(loadSlow, 30000);
};
-
- // ---- live header bar ----
- const liveBar = h('div', { class: 'section survey-live-bar' });
- root.appendChild(liveBar);
- const liveStatus = h('span', { class: 'survey-live-status', text: 'Waiting for recon data…' });
- liveBar.appendChild(liveStatus);
- liveBar.appendChild(h('span', { class: 'toolbar-spacer' }));
-
- const durSel = h('select', { class: 'sel survey-dur' });
- [[30, '30s'], [60, '1m'], [120, '2m'], [300, '5m'], [600, '10m']]
- .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));
- const scanBtn = btn('Scan now', () => {
- if (state.scanBusy) return;
- state.scanBusy = true;
- scanBtn.disabled = true;
- PagerAPI.post('/api/recon/start', { scan_time: parseInt(durSel.value, 10) })
- .then(() => App.toast('Timed scan started — live view will refresh'))
- .catch((err) => App.toast((err && err.message) || 'Scan start failed', 'error'))
- .finally(() => { state.scanBusy = false; scanBtn.disabled = false; });
- });
- liveBar.appendChild(durSel);
- liveBar.appendChild(scanBtn);
-
- const gpsPill = h('button', { class: 'survey-pill', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
- gpsPill.addEventListener('click', () => {
- PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
- state.gps = r.data || {};
- if (r.data && r.data.error) App.toast(r.data.error, 'error');
- else if (state.gps.lock) App.toast('GPS locked');
- else App.toast((r.data && r.data.note) || 'GPS bound, waiting for a fix');
- renderPills();
- }).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'));
- });
- liveBar.appendChild(gpsPill);
-
- const wigleCb = h('input', { type: 'checkbox' });
- const wigleSwitch = h('label', { class: 'switch survey-wigle' }, wigleCb, h('span', { class: 'track' }), ' WiGLE');
- wigleCb.addEventListener('change', () => {
- wigleCb.disabled = true;
- PagerAPI.post('/api/recon/wigle', { enable: wigleCb.checked })
- .then((r) => {
- state.wigle = wigleCb.checked;
- App.toast(state.wigle ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
- })
- .catch((err) => { wigleCb.checked = !wigleCb.checked; App.toast((err && err.message) || 'WiGLE toggle failed', 'error'); })
- .finally(() => { wigleCb.disabled = false; });
- });
- liveBar.appendChild(wigleSwitch);
-
- // ---- recording controls ----
- const recCard = h('div', { class: 'section survey-rec-card' });
- root.appendChild(recCard);
- const recName = h('input', { class: 'survey-rec-name', placeholder: 'Survey name (optional)' });
- const recStatus = h('span', { class: 'survey-rec-status', text: 'Not recording' });
- const recBtn = btn('Record', () => toggleRecording(), '');
- recCard.appendChild(recName);
- recCard.appendChild(recBtn);
- recCard.appendChild(recStatus);
-
- function toggleRecording() {
- if (state.recBusy) return;
- state.recBusy = true;
- recBtn.disabled = true;
- const active = !!(state.recording && state.recording.active);
- const req = active
- ? PagerAPI.post('/api/recon/survey/stop', {})
- : PagerAPI.post('/api/recon/survey/start', { name: recName.value });
- req.then((r) => {
- if (active) {
- App.toast('Survey stopped — ' + (r.data.samples || 0) + ' samples saved. See Reports.');
- recName.value = '';
- } else {
- App.toast('Survey recording started');
- }
- return PagerAPI.get('/api/recon/survey/live');
- }).then((r) => {
- state.recording = (r.data || {}).recording || null;
- renderRecording();
- }).catch((err) => App.toast((err && err.message) || 'Survey control failed', 'error'))
- .finally(() => { state.recBusy = false; recBtn.disabled = false; });
- }
-
- function renderRecording() {
- const r = state.recording;
- if (r && r.active) {
- recBtn.textContent = 'Stop';
- recBtn.classList.add('danger');
- const mins = Math.max(1, Math.round((Date.now() / 1000 - r.started) / 60));
- recStatus.textContent = '● Recording "' + r.name + '" — ' + r.samples + ' samples, ~' + mins + ' min';
- recStatus.classList.add('on');
- } else {
- recBtn.textContent = 'Record';
- recBtn.classList.remove('danger');
- recStatus.textContent = 'Not recording';
- recStatus.classList.remove('on');
- }
- }
-
- // ---- band filter chips + network search ----
- const filterRow = h('div', { class: 'recon-chips-row survey-filter-row' });
- root.appendChild(filterRow);
- filterRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Band' }));
- [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']].forEach(([v, t]) => {
- const c = h('button', { class: 'recon-chip' + (state.band === v ? ' active' : ''), text: t });
- c.addEventListener('click', () => { state.band = v; renderChips(); renderTable(); });
- filterRow.appendChild(c);
- });
- filterRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Search' }));
- const searchIn = h('input', { class: 'recon-search survey-search', placeholder: 'SSID or MAC' });
- searchIn.addEventListener('input', () => { state.search = searchIn.value; renderTable(); });
- filterRow.appendChild(searchIn);
-
- function renderChips() {
- filterRow.querySelectorAll('.recon-chip').forEach((c, i) => {
- const vals = ['all', '2.4', '5', '6'];
- if (i < vals.length) c.classList.toggle('active', state.band === vals[i]);
- });
- }
-
- // ---- channel occupancy ----
- const chanCard = h('div', { class: 'section' });
- chanCard.appendChild(h('h2', { text: 'Channel Occupancy' }));
- const chanBox = h('div', { class: 'survey-chan-box' });
- chanCard.appendChild(chanBox);
- root.appendChild(chanCard);
-
- function renderChannels() {
- chanBox.innerHTML = '';
- const counts = {};
- (state.live.aps || []).forEach((a) => {
- const band = a.band || '?';
- const ch = a.channel == null ? '?' : a.channel;
- const key = band + '|' + ch;
- counts[key] = (counts[key] || 0) + 1;
- });
- const bands = {};
- Object.keys(counts).forEach((k) => {
- const [band, ch] = k.split('|');
- (bands[band] = bands[band] || []).push({ ch, n: counts[k] });
- });
- const bandOrder = ['2.4', '5', '6', '?'];
- const maxN = Math.max(1, ...Object.keys(counts).map((k) => counts[k]));
- Object.keys(bands).sort((a, b) => {
- const ia = bandOrder.indexOf(a), ib = bandOrder.indexOf(b);
- return (ia === -1 ? 9 : ia) - (ib === -1 ? 9 : ib);
- }).forEach((band) => {
- chanBox.appendChild(h('div', { class: 'survey-chan-band', text: band === '?' ? 'Unknown' : band + ' GHz' }));
- bands[band].sort((x, y) => {
- if (x.ch === '?') return 1;
- if (y.ch === '?') return -1;
- return Number(x.ch) - Number(y.ch);
- }).forEach(({ ch, n }) => {
- const row = h('div', { class: 'survey-chan-row' },
- h('span', { class: 'survey-chan-label', text: 'CH ' + ch }),
- h('span', { class: 'survey-chan-track' },
- h('span', { class: 'survey-chan-fill', style: 'width:' + Math.round(n / maxN * 100) + '%' })),
- h('span', { class: 'survey-chan-count', text: String(n) }));
- chanBox.appendChild(row);
- });
- });
- if (!Object.keys(counts).length) chanBox.appendChild(h('div', { class: 'empty', text: 'No access points seen yet.' }));
- }
-
- // ---- compare ----
- const cmpCard = h('div', { class: 'section' });
- cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
- const cmpHint = h('div', { class: 'survey-cmp-hint', text: 'Select up to 6 APs in the table below to compare live signal strength.' });
- cmpCard.appendChild(cmpHint);
- const cmpCanvas = h('canvas', { id: 'survey-compare', style: 'width:100%;height:150px' });
- cmpCard.appendChild(cmpCanvas);
- const cmpLegend = h('div', { class: 'survey-cmp-legend' });
- cmpCard.appendChild(cmpLegend);
- root.appendChild(cmpCard);
-
- function renderCompare() {
- const series = [];
- const legends = [];
- state.compare.forEach((bssid, i) => {
- const hist = state.history[bssid] || [];
- const pts = hist.map((p) => p.sig).filter((v) => v != null);
- const color = SURVEY_COMPARE_COLORS[i % SURVEY_COMPARE_COLORS.length];
- if (pts.length >= 2) series.push({ points: pts, color: color });
- const ap = state.live.aps.find((a) => a.bssid === bssid);
- const last = pts.length ? pts[pts.length - 1] : null;
- legends.push(h('span', { class: 'survey-cmp-legend-item' },
- h('span', { class: 'survey-cmp-swatch', style: 'background:' + color }),
- h('span', { text: (ap && ap.ssid) || '(hidden) ' + (bssid || '').slice(0, 8) + '…' }),
- h('span', { class: 'survey-cmp-sig', text: last == null ? '--' : last + ' dBm' })));
- });
- cmpLegend.innerHTML = '';
- legends.forEach((l) => cmpLegend.appendChild(l));
- if (typeof MiniChart !== 'undefined' && MiniChart.draw) {
- MiniChart.draw(cmpCanvas, series, { min: -100, max: -20, grid: '#e0e0e0' });
- }
- }
-
- // ---- discover ----
- const discCard = h('div', { class: 'section survey-discover hidden' });
- root.appendChild(discCard);
- function renderDiscover() {
- const d = state.discover;
- if (!d) { discCard.classList.add('hidden'); return; }
- discCard.classList.remove('hidden');
- discCard.innerHTML = '';
- const head = h('div', { class: 'survey-discover-head' },
- h('span', { class: 'survey-discover-title', text: 'Discover: ' + (d.ssid || '(hidden SSID)') }),
- btn('×', () => { state.discover = null; renderDiscover(); renderTable(); }, 'ghost'));
- discCard.appendChild(head);
- const ap = state.live.aps.find((a) => a.bssid === d.bssid);
- const sig = ap != null ? ap.signal : (d.history.length ? d.history[d.history.length - 1].sig : null);
- const color = reconSigColor(sig);
- const readout = h('div', { class: 'survey-discover-readout', style: 'color:' + color, text: sig == null ? '-- dBm' : sig + ' dBm' });
- discCard.appendChild(readout);
- discCard.appendChild(h('div', { class: 'survey-discover-sub', text: (ap ? (ap.ssid || '(hidden)') + ' · ' : '') + (d.bssid || '') + (ap && ap.channel != null ? ' · CH ' + ap.channel : '') + (ap && ap.band ? ' · ' + ap.band + ' GHz' : '') }));
- const spark = h('canvas', { style: 'width:100%;height:70px' });
- discCard.appendChild(spark);
- const pts = d.history.map((p) => p.sig).filter((v) => v != null);
- if (typeof MiniChart !== 'undefined' && MiniChart.draw && pts.length >= 2) {
- MiniChart.draw(spark, [{ points: pts, color: color }], { min: -100, max: -20, grid: '#e0e0e0' });
- }
- }
-
- // ---- AP table ----
- const apCard = h('div', { class: 'section' });
- apCard.appendChild(h('h2', { text: 'Live Access Points' }));
- const apBody = h('div');
- apCard.appendChild(apBody);
- root.appendChild(apCard);
-
- const surveyCols = [
- { key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
- { key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
- { key: 'band', label: 'Band', render: (a) => a.band || '--' },
- { key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
- { key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
- { key: 'vendor', label: 'Vendor', render: (a) => a.vendor || '--' },
- { key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
- { key: 'actions', label: 'Compare', render: (a) => {
- const cb = h('input', { type: 'checkbox', title: 'Compare this AP' });
- cb.checked = state.compare.indexOf(a.bssid) !== -1;
- cb.addEventListener('change', () => {
- if (cb.checked) {
- if (state.compare.length >= 6) { cb.checked = false; App.toast('Compare up to 6 APs', 'error'); return; }
- state.compare.push(a.bssid);
- App.toast('Comparing ' + ((a.ssid || '(hidden)').length > 24 ? (a.ssid || '(hidden)').slice(0, 24) + '…' : (a.ssid || '(hidden)')));
- } else {
- state.compare = state.compare.filter((b) => b !== a.bssid);
- }
- renderCompare();
- });
- return h('span', { class: 'survey-cmp-check' }, cb);
- } },
- { key: 'discover', label: 'Discover', render: (a) =>
- iconBtn('place', 'Pin this AP for Discover mode', () => {
- state.discover = { bssid: a.bssid, ssid: a.ssid, history: (state.history[a.bssid] || []).slice() };
- renderDiscover();
- }) }
- ];
-
- function renderTable() {
- let rows = state.live.aps || [];
- if (state.band !== 'all') rows = rows.filter((a) => (a.band || '') === state.band);
- const q = state.search.toLowerCase();
- if (q) rows = rows.filter((a) => String(a.ssid || '').toLowerCase().indexOf(q) !== -1 || String(a.bssid || '').toLowerCase().indexOf(q) !== -1);
- rows = rows.slice().sort((a, b) => {
- const sa = a.signal == null ? -Infinity : a.signal;
- const sb = b.signal == null ? -Infinity : b.signal;
- return sb - sa;
- });
- apBody.innerHTML = '';
- if (!rows.length) { apBody.appendChild(h('div', { class: 'empty', text: 'No access points in the latest scan.' })); return; }
- apBody.appendChild(table(surveyCols, rows));
- }
-
- function renderPills() {
- const g = state.gps || {};
- if (g.lock) {
- gpsPill.className = 'survey-pill on';
- gpsPill.textContent = 'GPS ' + (g.lat != null ? Number(g.lat).toFixed(5) : '--') + ', ' + (g.lon != null ? Number(g.lon).toFixed(5) : '--') + (g.satellites ? ' · ' + g.satellites + ' sats' : '');
- } else if (g.present) {
- gpsPill.className = 'survey-pill';
- gpsPill.textContent = 'GPS no fix — tap to bind';
- } else if (g.gpsd_running) {
- gpsPill.className = 'survey-pill';
- gpsPill.textContent = 'GPS no device';
- } else {
- gpsPill.className = 'survey-pill';
- gpsPill.textContent = 'GPS off';
- }
- wigleCb.checked = !!state.wigle;
- }
-
- function tick() {
- PagerAPI.get('/api/recon/survey/live').then((r) => {
- const d = r.data || {};
- state.live = d;
- state.updated = Date.now();
- const scan = d.scan || null;
- liveStatus.textContent = scan
- ? 'Live: Scan #' + scan.id + ' — ' + (d.aps || []).length + ' APs, ' + (d.clients || []).length + ' clients, ' + (d.unassociated || 0) + ' unassociated · updated ' + fmtShortTime(Date.now() / 1000)
- : 'Waiting for a scan…';
- // history
- const nowT = Date.now() / 1000;
- (d.aps || []).forEach((a) => {
- if (a.bssid == null) return;
- const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
- hist.push({ t: nowT, sig: a.signal });
- while (hist.length > SURVEY_MAX_HISTORY) hist.shift();
- });
- // drop history for APs no longer present (prune after grace)
- const seen = {};
- (d.aps || []).forEach((a) => { if (a.bssid != null) seen[a.bssid] = true; });
- Object.keys(state.history).forEach((b) => {
- if (!seen[b]) {
- const hist = state.history[b];
- const recent = hist.filter((p) => nowT - p.t < 30);
- if (!recent.length) delete state.history[b];
- else state.history[b] = recent;
- }
- });
- state.gps = d.gps || {};
- state.wigle = !!(d.gps || {}).wigle;
- state.recording = d.recording || null;
- if (state.discover) {
- const dh = state.history[state.discover.bssid] || [];
- state.discover.history = dh.slice(-SURVEY_MAX_HISTORY);
- const ap = (d.aps || []).find((a) => a.bssid === state.discover.bssid);
- if (ap) { state.discover.ssid = ap.ssid; }
- }
- renderPills();
- renderRecording();
- renderChannels();
- renderCompare();
- renderDiscover();
- renderTable();
- }).catch(() => {});
- }
-
- tick();
- const poll = setInterval(tick, 2000);
- return { destroy: () => clearInterval(poll) };
+ restartSlow();
+ return { destroy: () => { clearInterval(pollIv); clearInterval(slowIv); } };
};
views.recon_reports = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/reports');
- const state = { surveys: [], detail: {} };
const reportCard = h('div', { class: 'section' });
- const surveyCard = h('div', { class: 'section' });
const wigleCard = h('div', { class: 'section' });
function dl(path) { window.location = App.apiBase + path; }
@@ -2148,83 +2142,6 @@ views.recon_reports = (root) => {
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' })));
}
- function renderSurveys() {
- surveyCard.appendChild(h('h2', { text: 'Survey Recordings' }));
- const box = h('div');
- surveyCard.appendChild(box);
- PagerAPI.get('/api/recon/surveys').then((r) => {
- state.surveys = (r.data && r.data.surveys) || [];
- box.innerHTML = '';
- if (!state.surveys.length) { box.appendChild(h('div', { class: 'empty', text: 'No surveys recorded yet. Start one on the Survey tab.' })); return; }
- box.appendChild(table(
- [
- { key: 'name', label: 'Name', render: (s) => s.name },
- { key: 'started', label: 'Started', render: (s) => fmtTime(s.started) },
- { key: 'samples', label: 'Samples', render: (s) => s.samples },
- { key: 'size', label: 'Size', render: (s) => fmtBytes(s.size) },
- { key: 'actions', label: 'Export', render: (s) => h('span', { class: 'hs-actions' },
- iconBtn('file_download', 'JSON', () => dl('/api/recon/surveys/' + s.id + '/download/json')),
- iconBtn('table_chart', 'CSV', () => dl('/api/recon/surveys/' + s.id + '/download/csv')),
- iconBtn('description', 'HTML report', () => dl('/api/recon/surveys/' + s.id + '/download/html')),
- iconBtn('delete', 'Delete survey', () => {
- if (!confirm('Delete survey "' + s.name + '"? This cannot be undone.')) return;
- PagerAPI.del('/api/recon/surveys/' + s.id).then(() => { App.toast('Survey deleted'); renderSurveys(); }).catch((err) => App.toast((err && err.message) || 'Delete failed', 'error'));
- })) }
- ],
- state.surveys));
- box.appendChild(h('div', { class: 'survey-detail-hint', text: 'Click a survey name below to expand its AP signal aggregates.' }));
- const detailBox = h('div');
- box.appendChild(detailBox);
- state.surveys.forEach((s) => {
- const row = h('div', { class: 'survey-detail-row', text: s.name });
- row.addEventListener('click', () => {
- if (state.detail[s.id] && state.detail[s.id].open) {
- state.detail[s.id].open = false;
- row.classList.remove('open');
- const b = detailBox.querySelector('[data-sid="' + s.id + '"]');
- if (b) b.remove();
- return;
- }
- if (!state.detail[s.id]) state.detail[s.id] = { open: false, data: null };
- state.detail[s.id].open = true;
- row.classList.add('open');
- const body = h('div', { class: 'survey-detail-body', 'data-sid': s.id });
- detailBox.appendChild(body);
- body.appendChild(h('div', { class: 'empty', text: 'Loading…' }));
- PagerAPI.get('/api/recon/surveys/' + s.id).then((r2) => {
- const d = r2.data || {};
- state.detail[s.id].data = d;
- body.innerHTML = '';
- const gpsTxt = d.gps_fixes
- ? d.gps_fixes + ' fixes · first ' + (d.first_gps && d.first_gps.lat != null ? Number(d.first_gps.lat).toFixed(5) + ', ' + Number(d.first_gps.lon).toFixed(5) : '--') + ' · last ' + (d.last_gps && d.last_gps.lat != null ? Number(d.last_gps.lat).toFixed(5) + ', ' + Number(d.last_gps.lon).toFixed(5) : '--')
- : 'No GPS fixes during this survey';
- body.appendChild(h('div', { class: 'survey-detail-gps', text: 'GPS: ' + gpsTxt }));
- const aps = (d.aps || []).slice().sort((a, b) => {
- const av = a.avg == null ? -Infinity : a.avg, bv = b.avg == null ? -Infinity : b.avg;
- return av - bv;
- });
- if (!aps.length) { body.appendChild(h('div', { class: 'empty', text: 'No AP samples in this survey.' })); return; }
- body.appendChild(table(
- [
- { key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
- { key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
- { key: 'band', label: 'Band', render: (a) => a.band || '--' },
- { key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
- { key: 'min', label: 'Min', render: (a) => a.min == null ? '--' : a.min + ' dBm' },
- { key: 'avg', label: 'Avg', render: (a) => a.avg == null ? '--' : a.avg + ' dBm' },
- { key: 'max', label: 'Max', render: (a) => a.max == null ? '--' : a.max + ' dBm' },
- { key: 'samples', label: 'Samples' },
- { key: 'first_seen', label: 'First', render: (a) => fmtShortTime(a.first_seen) },
- { key: 'last_seen', label: 'Last', render: (a) => fmtShortTime(a.last_seen) }
- ],
- aps));
- }).catch(() => { body.innerHTML = ''; body.appendChild(h('div', { class: 'empty', text: 'Failed to load survey detail.' })); });
- });
- detailBox.appendChild(row);
- });
- }).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load surveys.' })));
- }
-
function renderWigle() {
wigleCard.appendChild(h('h2', { text: 'WiGLE Uploads' }));
const box = h('div');
@@ -2239,7 +2156,7 @@ views.recon_reports = (root) => {
{ key: 'mtime', label: 'Modified', render: (f) => fmtTime(f.mtime) },
{ key: 'size', label: 'Size', render: (f) => fmtBytes(f.size) },
{ key: 'rows', label: 'AP rows', render: (f) => f.rows == null ? '--' : f.rows },
- { key: 'warn', label: '', render: (f) => f.rows === 0 ? h('span', { class: 'survey-wigle-warn', text: 'Header only — no data yet (needs a GPS fix)' }) : h('span', {}) },
+ { key: 'warn', label: '', render: (f) => f.rows === 0 ? h('span', { class: 'wigle-warn', text: 'Header only — no data yet (needs a GPS fix)' }) : h('span', {}) },
{ key: 'dl', label: 'Download', render: (f) => iconBtn('file_download', 'Download ' + f.name, () => dl('/api/recon/wigle/files/' + encodeURIComponent(f.name))) }
],
files));
@@ -2247,10 +2164,8 @@ views.recon_reports = (root) => {
}
root.appendChild(reportCard);
- root.appendChild(surveyCard);
root.appendChild(wigleCard);
renderScans();
- renderSurveys();
renderWigle();
return { destroy: () => {} };
};
diff --git a/tests/test_recon.py b/tests/test_recon.py
index 90d1f01..5d06a76 100644
--- a/tests/test_recon.py
+++ b/tests/test_recon.py
@@ -158,6 +158,10 @@ class FakeSock:
class DaemonSockTest(unittest.TestCase):
+ def setUp(self):
+ # h_recon_start now reads shared scan state; keep these isolated.
+ server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
+
def test_socket_call_posts_json_to_sock(self):
server.DAEMON_SOCK = '/tmp/api.sock'
fake = FakeSock(b'HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\n{"success":true}')
@@ -299,6 +303,19 @@ class ReconScanStateTest(unittest.TestCase):
status, data = self._status()
self.assertFalse(data['scanning'])
+ def test_start_while_scanning_returns_409_without_restart(self):
+ server.time.time = lambda: 1000.0
+ self._start(scan_time=30)
+ calls = []
+ server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True})
+ status, data = server.h_recon_start(
+ type('C', (), {'args': (), 'body': {'scan_time': 30}})())
+ self.assertEqual(status, 409)
+ self.assertEqual(data['scan_remaining'], 30)
+ self.assertEqual(calls, [])
+ status, data = self._status()
+ self.assertTrue(data['scanning'])
+
def test_watchdog_stops_expired_timed_scan(self):
server.time.time = lambda: 1000.0
self._start(scan_time=10)
@@ -339,6 +356,25 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_status(type('C', (), {'args': ()})())
self.assertFalse(data['active'])
+ def test_status_includes_hopper_and_history_flags(self):
+ with mock.patch.object(server, '_hopper_online', return_value=False), \
+ mock.patch.object(server, '_recon_history_reset', return_value=True):
+ status, data = server.h_recon_status(type('C', (), {'args': ()})())
+ self.assertEqual(status, 200)
+ self.assertFalse(data['hopper_online'])
+ self.assertTrue(data['history_reset'])
+
+ def test_hopper_online_cached(self):
+ server._hopper_cache.update({'updated': 0, 'online': None})
+ with mock.patch.object(server, 'wifi_ifaces',
+ return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
+ self.assertTrue(server._hopper_online())
+ # Second call within the cache window must not re-run iwinfo.
+ with mock.patch.object(server, 'wifi_ifaces',
+ side_effect=AssertionError('cached, must not call iwinfo')):
+ self.assertTrue(server._hopper_online())
+ server._hopper_cache['updated'] = 0 # let other tests start fresh
+
def test_delete_cascades(self):
server.recon_delete_scan(1)
rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM wifi_device')
@@ -637,27 +673,6 @@ class HandshakeRoutesTest(unittest.TestCase):
self.assertEqual(os.listdir(self.dir), ['.hidden'])
-def make_survey_db():
- """Single-scan recon DB so the newest scan carries the AP data."""
- fd, db = tempfile.mkstemp(suffix='.db')
- os.close(fd)
- conn = sqlite3.connect(db)
- conn.executescript(SCHEMA)
- conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1786466531, 'pager')")
- conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (1, 1, 'AE77C0EB3141', 1786466531, -71, 2412, 5)")
- conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (2, 1, 'C89E43648080', 1786466532, -76, 5745, 9)")
- conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
- "VALUES (10, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466532, -76, 5745, 149, 0x400400108)")
- conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
- "VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)")
- conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
- "VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)")
- conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)")
- conn.commit()
- conn.close()
- return db
-
-
class OuiVendorTest(unittest.TestCase):
def test_oui_prefix_forms(self):
self.assertEqual(server._oui_prefix('C8:9E:43:64:80:80'), 'C89E43')
@@ -746,6 +761,30 @@ class ReconEnrichmentTest(unittest.TestCase):
self.assertEqual(a['band'], '2.4')
self.assertEqual(a['vendor'], 'Google')
+ def test_scan_detail_handler_is_bounded_and_retried(self):
+ seen = {}
+ orig = server.recon_scan_data
+ def fake(scan_id, _timeout=20, _limit=None, db=None):
+ seen['limit'] = _limit
+ seen['db'] = db
+ return orig(scan_id, _timeout=_timeout, _limit=_limit, db=db)
+ with mock.patch.object(server, 'recon_scan_data', side_effect=fake), \
+ mock.patch.object(server.time, 'sleep'):
+ status, data = server.h_recon_scan_detail(type('C', (), {'args': ('1',)})())
+ self.assertEqual(status, 200)
+ self.assertEqual(seen['limit'], 300)
+ # The live handler relies on the default, which resolves to RECON_DB.
+ self.assertIsNone(seen['db'])
+ self.assertEqual(data['scan']['id'], 1)
+
+ def test_scan_detail_handler_503_on_lock(self):
+ with mock.patch.object(server, 'recon_scan_data',
+ side_effect=RuntimeError('sqlite read failed: locked')), \
+ mock.patch.object(server.time, 'sleep'):
+ status, data = server.h_recon_scan_detail(type('C', (), {'args': ('1',)})())
+ self.assertEqual(status, 503)
+ self.assertIn('temporarily unavailable', data['error'])
+
class ReconReportTest(unittest.TestCase):
def setUp(self):
@@ -769,7 +808,8 @@ class ReconReportTest(unittest.TestCase):
self.assertIn('C8:9E:43:64:80:80', text)
def test_html_download_contains_stats(self):
- status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
+ with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
+ status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/html')
self.assertEqual(payload.filename, 'scan-1.html')
@@ -777,7 +817,32 @@ class ReconReportTest(unittest.TestCase):
self.assertIn('Scan #1', text)
self.assertIn('Anderson-5', text)
self.assertIn('WPA3 WPA2', text)
+ # stat cards
+ self.assertIn('stat-card', text)
self.assertIn('Unassociated', text)
+ # band + encryption breakdowns
+ self.assertIn('Band Breakdown', text)
+ self.assertIn('Encryption Breakdown', text)
+ self.assertIn('WPA2', text)
+ # channel occupancy table
+ self.assertIn('Channel Occupancy', text)
+ self.assertIn('5 GHz', text)
+ self.assertIn('>149<', text)
+ # color-coded signal cells
+ self.assertIn('sig-weak', text)
+ self.assertIn('sig-good', text)
+ self.assertIn('-76 dBm', text)
+ # no GPS line without a fix
+ self.assertNotIn('GPS:', text)
+
+ def test_html_report_includes_gps_when_locked(self):
+ with mock.patch.object(server, '_gps_status_data',
+ return_value={'lock': True, 'lat': 37.7,
+ 'lon': -122.4, 'satellites': 8}):
+ status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
+ text = payload.data.decode('utf-8')
+ self.assertIn('GPS: 37.70000, -122.40000', text)
+ self.assertIn('8 sats', text)
def test_download_404_for_missing_scan(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('999',)))
@@ -795,6 +860,135 @@ class ReconReportTest(unittest.TestCase):
self.assertEqual(status, 503)
+class ReconArchivesTest(unittest.TestCase):
+ """Read-only history from pineapd-rotated databases (error-*-recon.db)."""
+
+ def setUp(self):
+ self.dir = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.dir)
+ self.archive_name = 'error-2026-08-18-19:21:42Z-recon.db'
+ tmp = make_db()
+ self.addCleanup(os.unlink, tmp)
+ server.RECON_DB = os.path.join(self.dir, 'recon.db')
+ shutil.copy(tmp, server.RECON_DB)
+ archive = os.path.join(self.dir, self.archive_name)
+ shutil.copy(tmp, archive)
+ conn = sqlite3.connect(archive)
+ conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u3', 1786467000, 'pager')")
+ conn.commit()
+ conn.close()
+
+ def tearDown(self):
+ server._recon_archives_cache.update({'dir': None, 'updated': 0, 'data': None})
+
+ def _ctx(self, args=()):
+ return type('C', (), {'args': args, 'body': {}})()
+
+ def test_archives_list_finds_rotated_db(self):
+ data = server.recon_archives_data()
+ self.assertEqual(len(data['archives']), 1)
+ a = data['archives'][0]
+ self.assertEqual(a['id'], self.archive_name)
+ self.assertEqual(a['min_id'], 1)
+ self.assertEqual(a['max_id'], 3)
+ self.assertEqual(a['scans_count'], 3)
+ self.assertEqual([s['id'] for s in a['scans']], [3, 2, 1])
+
+ def test_archives_list_is_cached_per_directory(self):
+ orig = server.recon_scans_data
+ calls = []
+ def fake(limit=50, _timeout=20, db=None):
+ calls.append(db)
+ return orig(limit=limit, _timeout=_timeout, db=db)
+ with mock.patch.object(server, 'recon_scans_data', side_effect=fake):
+ server.recon_archives_data()
+ server.recon_archives_data()
+ # Second call within the cache window must not re-scan the archives.
+ self.assertEqual(len(calls), 1)
+ self.assertTrue(calls[0].endswith(self.archive_name))
+
+ def test_archive_path_rejects_traversal(self):
+ self.assertIsNone(server._recon_archive_path('..%2F..%2Fetc%2Fpasswd'))
+ self.assertIsNone(server._recon_archive_path('../etc/passwd'))
+ self.assertIsNone(server._recon_archive_path('error-x-recon.db/../evil'))
+ self.assertIsNone(server._recon_archive_path('random.db'))
+ self.assertIsNone(server._recon_archive_path(''))
+ self.assertIsNotNone(server._recon_archive_path(self.archive_name))
+
+ def test_archive_scan_detail_reads_archive_not_live(self):
+ status, data = server.h_recon_archive_scan_detail(
+ self._ctx((self.archive_name, '3')))
+ self.assertEqual(status, 200)
+ self.assertEqual(data['scan']['id'], 3)
+ # The live db has no scan 3; the archive must be the source.
+ self.assertIsNone(server.recon_scan_data(3))
+
+ def test_archive_scan_detail_is_bounded(self):
+ seen = {}
+ orig = server.recon_scan_data
+ def fake(scan_id, _timeout=20, _limit=None, db=None):
+ seen.update({'limit': _limit, 'db': db})
+ return orig(scan_id, _timeout=_timeout, _limit=_limit, db=db)
+ with mock.patch.object(server, 'recon_scan_data', side_effect=fake), \
+ mock.patch.object(server.time, 'sleep'):
+ status, data = server.h_recon_archive_scan_detail(
+ self._ctx((self.archive_name, '1')))
+ self.assertEqual(status, 200)
+ self.assertEqual(seen['limit'], 300)
+ self.assertTrue(seen['db'].endswith(self.archive_name))
+
+ def test_archive_scans_list(self):
+ status, data = server.h_recon_archive_scans(self._ctx((self.archive_name,)))
+ self.assertEqual(status, 200)
+ self.assertEqual([s['id'] for s in data['scans']], [3, 2, 1])
+
+ def test_archive_handlers_404_for_missing(self):
+ status, data = server.h_recon_archive_scans(self._ctx(('nope.db',)))
+ self.assertEqual(status, 404)
+ status, data = server.h_recon_archive_scan_detail(self._ctx(('nope.db', '1')))
+ self.assertEqual(status, 404)
+ status, data = server.h_recon_archive_scan_detail(
+ self._ctx((self.archive_name, '999')))
+ self.assertEqual(status, 404)
+
+ def test_archive_downloads(self):
+ status, payload = server.h_recon_archive_scan_download(
+ self._ctx((self.archive_name, '1')))
+ self.assertEqual(status, 200)
+ self.assertEqual(payload.filename, 'scan-1.json')
+ self.assertIn(b'Anderson-5', payload.data)
+ status, payload = server.h_recon_archive_scan_download_csv(
+ self._ctx((self.archive_name, '1')))
+ self.assertEqual(status, 200)
+ self.assertEqual(payload.ctype, 'text/csv')
+ self.assertIn(b'C8:9E:43:64:80:80', payload.data)
+ status, payload = server.h_recon_archive_scan_download_html(
+ self._ctx((self.archive_name, '1')))
+ self.assertEqual(status, 200)
+ self.assertEqual(payload.ctype, 'text/html')
+ text = payload.data.decode('utf-8')
+ self.assertIn('archived history', text)
+ self.assertIn('Anderson-5', text)
+
+ def test_archive_html_omits_current_gps(self):
+ # An archive is historical; attaching a current fix would mislead.
+ with mock.patch.object(server, '_gps_status_data',
+ return_value={'lock': True, 'lat': 37.7, 'lon': -122.4}):
+ status, payload = server.h_recon_archive_scan_download_html(
+ self._ctx((self.archive_name, '1')))
+ self.assertEqual(status, 200)
+ self.assertNotIn('GPS:', payload.data.decode('utf-8'))
+
+ def test_archive_detail_503_on_lock(self):
+ with mock.patch.object(server, 'recon_scan_data',
+ side_effect=RuntimeError('sqlite read failed: locked')), \
+ mock.patch.object(server.time, 'sleep'):
+ status, data = server.h_recon_archive_scan_detail(
+ self._ctx((self.archive_name, '1')))
+ self.assertEqual(status, 503)
+ self.assertIn('temporarily unavailable', data['error'])
+
+
class GpsTest(unittest.TestCase):
def setUp(self):
server._gps_cache.update({'updated': 0, 'data': None})
@@ -821,6 +1015,31 @@ class GpsTest(unittest.TestCase):
self.assertTrue(data['present'])
self.assertTrue(data['wigle'])
+ def test_gps_status_skips_hak5cmd_when_gpsd_down(self):
+ # GPS_GET can block for seconds when gpsd is down; it must not run.
+ with mock.patch.object(server, '_uci_gps_get', return_value=None), \
+ mock.patch.object(server, '_gps_serial_candidates', return_value=[]), \
+ mock.patch.object(server, '_wigle_config', return_value={'logwigle': False}), \
+ mock.patch.object(server, '_gpsd_running', return_value=False), \
+ mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
+ mock.patch.object(server, '_gps_from_hak5cmd',
+ side_effect=AssertionError('GPS_GET must not run when gpsd is down')) as hak5:
+ data = server._gps_status_data_nocache()
+ self.assertFalse(data['gpsd_running'])
+ hak5.assert_not_called()
+
+ def test_gps_status_falls_back_to_hak5cmd_when_gpsd_up(self):
+ with mock.patch.object(server, '_uci_gps_get', return_value=None), \
+ mock.patch.object(server, '_gps_serial_candidates', return_value=[]), \
+ mock.patch.object(server, '_wigle_config', return_value={'logwigle': False}), \
+ mock.patch.object(server, '_gpsd_running', return_value=True), \
+ mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
+ mock.patch.object(server, '_gps_from_hak5cmd',
+ return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}):
+ data = server._gps_status_data_nocache()
+ self.assertEqual(data['lat'], 37.7)
+ self.assertEqual(data['satellites'], 8)
+
def test_configure_binds_preferred_device_and_locks(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
@@ -937,152 +1156,6 @@ class WigleTest(unittest.TestCase):
hak5.assert_called_with('WIGLE_STOP', timeout=10)
-class SurveyTest(unittest.TestCase):
- def setUp(self):
- self.db = make_survey_db()
- server.RECON_DB = self.db
- self.dir = tempfile.mkdtemp()
- self._orig = {
- 'SURVEY_DIR': server.SURVEY_DIR,
- 'SURVEY_MAX_SAMPLES': server.SURVEY_MAX_SAMPLES,
- 'SURVEY_SAMPLE_INTERVAL': server.SURVEY_SAMPLE_INTERVAL,
- }
- server.SURVEY_DIR = self.dir
- server.SURVEY_MAX_SAMPLES = 3
- server.SURVEY_SAMPLE_INTERVAL = 0.0
- server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
- 'started': 0, 'samples': 0, 'last_sample': 0}
- server._gps_cache.update({'updated': 0, 'data': None})
-
- def tearDown(self):
- server.SURVEY_DIR = self._orig['SURVEY_DIR']
- server.SURVEY_MAX_SAMPLES = self._orig['SURVEY_MAX_SAMPLES']
- server.SURVEY_SAMPLE_INTERVAL = self._orig['SURVEY_SAMPLE_INTERVAL']
- server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
- 'started': 0, 'samples': 0, 'last_sample': 0}
- server._gps_cache.update({'updated': 0, 'data': None})
- shutil.rmtree(self.dir)
- os.unlink(self.db)
-
- def _ctx(self, args=(), body=None):
- return type('C', (), {'args': args, 'body': body or {}})()
-
- def test_start_creates_meta_file(self):
- status, data = server.h_recon_survey_start(self._ctx(body={'name': 'Kitchen Walk'}))
- self.assertEqual(status, 200)
- self.assertTrue(data['ok'])
- path = os.path.join(self.dir, data['id'] + '.jsonl')
- self.assertTrue(os.path.isfile(path))
- with open(path) as f:
- first = f.readline()
- self.assertIn('"meta"', first)
- self.assertIn('Kitchen Walk', first)
-
- def test_start_rejects_duplicate(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- status, data = server.h_recon_survey_start(self._ctx(body={'name': 'B'}))
- self.assertEqual(status, 409)
-
- def test_sample_via_watchdog_and_cap(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- server._recon_watchdog_tick()
- server._recon_watchdog_tick()
- self.assertEqual(server._survey_state['samples'], 2)
- status, data = server.h_recon_survey_stop(self._ctx())
- self.assertEqual(status, 200)
- self.assertEqual(data['samples'], 2)
- self.assertFalse(server._survey_state['active'])
-
- def test_sample_cap_stops_recording(self):
- server.SURVEY_MAX_SAMPLES = 2
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- for _ in range(4):
- server._recon_watchdog_tick()
- self.assertFalse(server._survey_state['active'])
- self.assertEqual(server._survey_state['samples'], 2)
-
- def test_live_reports_scan_unassociated_and_recording(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- server._recon_watchdog_tick()
- status, data = server.h_recon_survey_live(self._ctx())
- self.assertEqual(status, 200)
- self.assertEqual(data['scan']['id'], 1)
- self.assertEqual(len(data['aps']), 2)
- self.assertEqual(data['unassociated'], 1)
- self.assertTrue(data['recording']['active'])
- self.assertEqual(data['recording']['samples'], 1)
- self.assertIn('wigle', data['gps'])
-
- def test_live_recording_none_when_stopped(self):
- status, data = server.h_recon_survey_live(self._ctx())
- self.assertEqual(status, 200)
- self.assertIsNone(data['recording'])
-
- def test_surveys_list_counts_samples(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- server._recon_watchdog_tick()
- server.h_recon_survey_stop(self._ctx())
- status, data = server.h_recon_surveys(self._ctx())
- self.assertEqual(status, 200)
- self.assertEqual(len(data['surveys']), 1)
- survey = data['surveys'][0]
- self.assertEqual(survey['name'], 'A')
- self.assertEqual(survey['samples'], 1)
- self.assertGreater(survey['size'], 0)
-
- def test_detail_aggregates_signal(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- server._recon_watchdog_tick()
- server._recon_watchdog_tick()
- sid = server._survey_state['id']
- server.h_recon_survey_stop(self._ctx())
- status, data = server.h_recon_survey_detail(self._ctx((sid,)))
- self.assertEqual(status, 200)
- aps = {a['bssid']: a for a in data['aps']}
- a = aps['C8:9E:43:64:80:80']
- self.assertEqual(a['min'], -76)
- self.assertEqual(a['max'], -76)
- self.assertEqual(a['avg'], -76)
- self.assertEqual(a['samples'], 2)
- self.assertEqual(a['band'], '5')
- self.assertEqual(a['channel'], 149)
- self.assertEqual(a['first_seen'], a['last_seen'])
- self.assertEqual(data['gps_fixes'], 0)
-
- def test_downloads_all_formats(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- server._recon_watchdog_tick()
- sid = server._survey_state['id']
- server.h_recon_survey_stop(self._ctx())
- for fmt, ctype in [('json', 'application/json'), ('csv', 'text/csv'), ('html', 'text/html')]:
- status, payload = server.h_recon_survey_download(self._ctx((sid, fmt)))
- self.assertEqual(status, 200)
- self.assertEqual(payload.ctype, ctype)
- self.assertEqual(payload.filename, 'survey-%s.%s' % (sid, fmt))
- status, payload = server.h_recon_survey_download(self._ctx((sid, 'csv')))
- self.assertIn('C8:9E:43:64:80:80', payload.data.decode('utf-8'))
-
- def test_delete_removes_file_then_404(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- sid = server._survey_state['id']
- server.h_recon_survey_stop(self._ctx())
- status, data = server.h_recon_survey_delete(self._ctx((sid,)))
- self.assertEqual(status, 200)
- self.assertFalse(os.listdir(self.dir))
- status, data = server.h_recon_survey_delete(self._ctx((sid,)))
- self.assertEqual(status, 404)
-
- def test_delete_blocks_active_survey(self):
- server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
- sid = server._survey_state['id']
- status, data = server.h_recon_survey_delete(self._ctx((sid,)))
- self.assertEqual(status, 409)
-
- def test_detail_404_missing(self):
- status, data = server.h_recon_survey_detail(self._ctx(('nope',)))
- self.assertEqual(status, 404)
-
-
class ReconRoutesTest(unittest.TestCase):
def test_new_routes_registered(self):
expected = [
@@ -1093,24 +1166,26 @@ class ReconRoutesTest(unittest.TestCase):
('POST', '/api/recon/wigle', 'h_recon_wigle'),
('GET', '/api/recon/wigle/files', 'h_recon_wigle_files'),
('GET', '/api/recon/wigle/files/x.csv', 'h_recon_wigle_file'),
- ('GET', '/api/recon/survey/live', 'h_recon_survey_live'),
- ('POST', '/api/recon/survey/start', 'h_recon_survey_start'),
- ('POST', '/api/recon/survey/stop', 'h_recon_survey_stop'),
- ('GET', '/api/recon/surveys', 'h_recon_surveys'),
- ('GET', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_detail'),
- ('GET', '/api/recon/surveys/20260818-120000-A/download/csv', 'h_recon_survey_download'),
- ('GET', '/api/recon/surveys/20260818-120000-A/download/json', 'h_recon_survey_download'),
- ('GET', '/api/recon/surveys/20260818-120000-A/download/html', 'h_recon_survey_download'),
- ('DELETE', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_delete'),
+ ('GET', '/api/recon/archives', 'h_recon_archives'),
+ ('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans', 'h_recon_archive_scans'),
+ ('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1', 'h_recon_archive_scan_detail'),
+ ('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/json', 'h_recon_archive_scan_download'),
+ ('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/csv', 'h_recon_archive_scan_download_csv'),
+ ('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/html', 'h_recon_archive_scan_download_html'),
]
for method, path, handler in expected:
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, '%s %s' % (method, path))
self.assertEqual(h.__name__, handler, '%s %s' % (method, path))
- def test_survey_download_route_captures_format(self):
- h, args = server.ROUTER.dispatch('GET', '/api/recon/surveys/abc/download/csv')
- self.assertEqual(args, ('abc', 'csv'))
+ def test_survey_routes_are_gone(self):
+ for method, path in [('GET', '/api/recon/survey/live'),
+ ('POST', '/api/recon/survey/start'),
+ ('GET', '/api/recon/surveys'),
+ ('GET', '/api/recon/surveys/abc'),
+ ('DELETE', '/api/recon/surveys/abc')]:
+ h, args = server.ROUTER.dispatch(method, path)
+ self.assertIsNone(h, '%s %s should not be registered' % (method, path))
def test_original_recon_routes_unchanged(self):
for path in ['/api/recon/start', '/api/recon/status', '/api/recon/scans',