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('' % _esc_html(header)) out.append('') for row in rows: out.append('') - for cell in row: - out.append('' % _esc_html(cell)) + for i, cell in enumerate(row): + out.append('' % (cell if i in raw else _esc_html(cell))) out.append('') out.append('
%s
%s%s
') 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('
%s
' + '
%s
' + % (_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',