diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py
index 91ccd58..f79ce2d 100644
--- a/payload/user/remote_access/pager-webui/server.py
+++ b/payload/user/remote_access/pager-webui/server.py
@@ -44,6 +44,21 @@ DEFAULT_RECON_DURATION = 30
_recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
_recon_status_cache = {
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
+
+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 = {}
_payload_runs_lock = threading.Lock()
PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs')
@@ -868,7 +883,7 @@ def h_deauth_client(ctx):
SQLITE_BUSY_MSGS = ('database is locked', 'database is busy')
-def _db_rows(db, sql, _retries=1):
+def _db_rows(db, sql, _retries=1, timeout=20):
if sqlite3 is not None:
try:
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
@@ -880,11 +895,13 @@ def _db_rows(db, sql, _retries=1):
conn.close()
except sqlite3.Error as exc:
raise RuntimeError('sqlite read failed: %s' % exc)
- rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql])
+ rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql],
+ timeout=timeout)
attempt = 1
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries:
time.sleep(0.3)
- rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql])
+ rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql],
+ timeout=timeout)
attempt += 1
st = _recon_scan_state
elapsed = time.time() - st['started'] if st['started'] else 0
@@ -895,7 +912,7 @@ def _db_rows(db, sql, _retries=1):
# The file is stable by this point, so bypass that stale lock read-only.
immutable_db = 'file:%s?immutable=1' % db
rc, out, err = device_run(
- [SQLITE_CLI, '-json', immutable_db, sql])
+ [SQLITE_CLI, '-json', immutable_db, sql], timeout=timeout)
if rc != 0:
raise RuntimeError('sqlite read failed: %s' % (err or out).strip())
if out.strip():
@@ -961,7 +978,96 @@ def decode_encryption(v):
return ' '.join(parts) if parts else 'Open'
-def recon_scans_data(limit=50):
+# Compact OUI -> vendor table (24-bit prefix, hex without colons). Covers the
+# vendors most commonly seen in the field; everything else resolves to
+# 'Unknown'. Locally administered MACs resolve to 'Local'.
+OUI_VENDORS = {
+ '00000C': 'Cisco', '000393': 'Apple', '000625': 'Linksys', '000C42': 'MikroTik',
+ '000DB9': 'Intel', '001376': 'MikroTik', '0016CB': 'Apple', '0017F2': 'Apple',
+ '001BFC': 'ASUS', '001E10': 'Huawei', '0025C7': 'Apple', '00500B': 'HP',
+ '0050F2': 'Micro-Star', '00606E': 'Xerox', '00A0C9': 'Intel', '00C0CA': 'Xerox',
+ '040CCE': 'Apple', '041854': 'Ubiquiti', '04ED33': 'Apple', '04F13E': 'Apple',
+ '04F938': 'Apple', '080007': 'Apple', '080028': 'Texas Instruments',
+ '080046': 'Sony', '083E8E': 'Apple', '089E01': 'Apple', '0C74C2': 'Apple',
+ '0C96BF': 'Huawei', '107BEF': 'Huawei', '10A2DC': 'Apple', '10BF48': 'Apple',
+ '1425BE': 'Huawei', '147A19': 'Apple', '1499E2': 'Apple', '14AC3C': 'Apple',
+ '14CC20': 'TP-Link', '181D86': 'Apple', '1C5C55': 'Apple', '1CE1A7': 'TP-Link',
+ '20010F': 'Apple', '20620B': 'Apple', '240AC4': 'Espressif', '241F4A': 'Apple',
+ '24A0DF': 'Apple', '24A43C': 'Ubiquiti', '24ABC0': 'Apple', '24B6FD': 'Espressif',
+ '247189': 'Espressif', '28CDC1': 'Raspberry Pi', '28CFE9': 'Apple',
+ '2C6E85': 'Apple', '2C7E81': 'Apple', '2CCF67': 'Raspberry Pi', '300C23': 'Apple',
+ '30720B': 'Apple', '3402E5': 'Apple', '3478D7': 'Apple', '381020': 'Apple',
+ '3C0754': 'Apple', '3C2177': 'TP-Link', '3C71BF': 'Espressif', '3C99F7': 'ASUS',
+ '3CD16E': 'Apple', '4006A0': 'Apple', '4083DE': 'Apple', '40B076': 'ASUS',
+ '40D3AE': 'Apple', '443212': 'Apple', '44C9A2': 'Apple', '44D9E7': 'Ubiquiti',
+ '48BF6B': 'Apple', '48C04E': 'Apple', '4C5E0C': 'MikroTik', '502D21': 'Apple',
+ '50C7BF': 'TP-Link', '54843B': 'Apple', '549392': 'ASUS', '54E43A': 'Apple',
+ '58B0D4': 'Apple', '586A97': 'TP-Link', '5C961D': 'Apple', '5CE1A1': 'Apple',
+ '603C07': 'Apple', '6083B2': 'Apple', '60D9C7': 'TP-Link', '640094': 'Apple',
+ '640980': 'TP-Link', '64167F': 'MikroTik', '649EF3': 'Apple', '6C0486': 'TP-Link',
+ '6C3B6B': 'MikroTik', '6C4008': 'Apple', '6CD68A': 'Apple', '70A2B3': 'Apple',
+ '742AF0': 'Apple', '748898': 'MikroTik', '74C46B': 'Apple', '782B1D': 'Apple',
+ '7847A6': 'Apple', '78CA39': 'Apple', '78E3B5': 'Ubiquiti', '7C0191': 'Apple',
+ '7CD1C3': 'Apple', '802AA8': 'Ubiquiti', '80BE05': 'Apple', '847C9B': 'Apple',
+ '84F3EB': 'Espressif', '88D42A': 'Apple', '8C3AF4': 'Apple', '8C7B9D': 'Apple',
+ '8CDEF9': 'Xiaomi', '90039F': 'Apple', '90B21F': 'Apple', '90F652': 'TP-Link',
+ '94099B': 'Apple', '98D6BB': 'Apple', '9CD24B': 'Ubiquiti', 'A01828': 'Apple',
+ 'A020A6': 'Xiaomi', 'A05E6B': 'Apple', 'A07591': 'TP-Link', 'A41F72': 'Apple',
+ 'A4B197': 'Apple', 'A4CF12': 'Espressif', 'A86484': 'Apple', 'ACBC32': 'Apple',
+ 'B0DA00': 'Apple', 'B4E1EB': 'Apple', 'B827EB': 'Raspberry Pi',
+ 'B8E45B': 'Raspberry Pi', 'BCA834': 'Apple', 'C03F0E': 'TP-Link',
+ 'C04A00': 'Apple', 'C05E06': 'Apple', 'C08C60': 'Apple', 'C0E422': 'Apple',
+ 'C45F5E': 'Espressif', 'C46516': 'Apple', 'C47D4F': 'Apple', 'C85B76': 'Apple',
+ 'C8B5B7': 'Apple', 'C89A00': 'Apple', 'CC08E0': 'Apple', 'CC25EF': 'Apple',
+ 'CC3D82': 'Apple', 'CCC73B': 'Apple', 'D0E140': 'Apple', 'D4154F': 'TP-Link',
+ 'D4CA6D': 'Apple', 'D8A25E': 'Apple', 'DCA632': 'Raspberry Pi',
+ 'DC6DCD': 'Apple', 'E03005': 'Apple', 'E45F01': 'Raspberry Pi',
+ 'E45610': 'Apple', 'E4E4AB': 'Apple', 'E8802E': 'TP-Link', 'E8988F': 'Espressif',
+ 'E8F2E2': 'Apple', 'EC8EB5': 'TP-Link', 'F0175E': 'Apple', 'F0D5BF': 'Apple',
+ 'F4044C': 'Apple', 'F49BA0': 'Xiaomi', 'F4E97D': 'Apple', 'F8FFC2': 'Apple',
+ 'FC633E': 'Google', 'FCF080': 'Apple',
+}
+
+
+def _oui_prefix(mac):
+ """'C8:9E:43:64:80:80' / 'C89E43648080' -> 'C89E43' (uppercase, no colons)."""
+ mac = (mac or '').strip().upper().replace(':', '').replace('-', '').replace('.', '')
+ if len(mac) >= 6 and all(c in '0123456789ABCDEF' for c in mac[:6]):
+ return mac[:6]
+ return None
+
+
+def oui_vendor(mac):
+ """Best-effort vendor name for a MAC. Locally administered -> 'Local'."""
+ if not mac or mac == '--':
+ return 'Unknown'
+ prefix = _oui_prefix(mac)
+ if prefix is None:
+ return 'Unknown'
+ if int(prefix[1], 16) & 2: # locally administered (second hex digit bit 1)
+ return 'Local'
+ return OUI_VENDORS.get(prefix, 'Unknown')
+
+
+def band_of(freq):
+ """Channel frequency (MHz) -> '2.4' | '5' | '6' | '--'."""
+ if freq is None:
+ return '--'
+ try:
+ freq = int(freq)
+ except (TypeError, ValueError):
+ return '--'
+ if freq <= 0:
+ return '--'
+ if freq < 2500:
+ return '2.4'
+ if freq < 6000:
+ return '5'
+ return '6'
+
+
+
+def recon_scans_data(limit=50, _timeout=20):
rows = _db_rows(RECON_DB,
'WITH recent AS (SELECT id, time, name FROM scan ORDER BY id DESC LIMIT %d), '
'devices AS (SELECT scan, count(*) AS devices FROM wifi_device '
@@ -978,42 +1084,90 @@ def recon_scans_data(limit=50):
'LEFT JOIN devices w ON w.scan = s.id '
'LEFT JOIN aps a ON a.scan = s.id '
'LEFT JOIN captures h ON h.scan = s.id '
- 'ORDER BY s.id DESC' % limit)
+ 'ORDER BY s.id DESC' % limit, timeout=_timeout)
return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'),
'devices': r['devices'], 'aps': r['aps'],
'handshakes': r['handshakes']} for r in rows]}
-def recon_scan_data(scan_id):
- rows = _db_rows(RECON_DB,
- "SELECT 'scan' AS kind, id AS row_id, time, name, "
- "NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
- "NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
- "NULL AS packets, NULL AS stahash, NULL AS aphash "
- "FROM scan WHERE id = %d "
- "UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
- "channel, encryption, signal, freq, NULL, NULL, NULL "
- "FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
- "UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
- "NULL, NULL, signal, freq, packets, NULL, NULL "
- "FROM wifi_device WHERE scan = %d "
- "UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
- "NULL, NULL, NULL, NULL, NULL, stahash, aphash "
- "FROM handshake WHERE scan = %d" % (scan_id, scan_id, scan_id, scan_id))
+def recon_scan_data(scan_id, _timeout=20, _limit=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
+ unassociated/device branches dominate query time on slow recon dbs, so the
+ bounded mode keeps the 2s survey poll cheap.
+ """
+ if _limit:
+ sql = ("WITH dev AS (SELECT hash, time, mac, signal, freq, packets "
+ "FROM wifi_device WHERE scan = %d LIMIT %d) "
+ "SELECT 'scan' AS kind, id AS row_id, time, name, "
+ "NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
+ "NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
+ "NULL AS packets, NULL AS stahash, NULL AS aphash "
+ "FROM scan WHERE id = %d "
+ "UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
+ "channel, encryption, signal, freq, NULL, NULL, NULL "
+ "FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
+ "UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
+ "NULL, NULL, signal, freq, packets, NULL, NULL "
+ "FROM dev "
+ "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'
+ % scan_id, timeout=_timeout)
+ unassociated = cnt[0]['c'] if cnt else 0
+ else:
+ rows = _db_rows(RECON_DB,
+ "SELECT 'scan' AS kind, id AS row_id, time, name, "
+ "NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
+ "NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
+ "NULL AS packets, NULL AS stahash, NULL AS aphash "
+ "FROM scan WHERE id = %d "
+ "UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
+ "channel, encryption, signal, freq, NULL, NULL, NULL "
+ "FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
+ "UNION ALL SELECT 'unassociated', hash, time, NULL, NULL, NULL, ssid, "
+ "hidden, channel, encryption, signal, freq, NULL, NULL, NULL "
+ "FROM ssid WHERE scan = %d AND type = 4 "
+ "UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
+ "NULL, NULL, signal, freq, packets, NULL, NULL "
+ "FROM wifi_device WHERE scan = %d "
+ "UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
+ "NULL, NULL, NULL, NULL, NULL, stahash, aphash "
+ "FROM handshake WHERE scan = %d" % (scan_id, scan_id, scan_id, scan_id, scan_id),
+ timeout=_timeout)
+ unassociated = sum(1 for r in rows if r.get('kind') == 'unassociated')
scans = [r for r in rows if r.get('kind') == 'scan']
if not scans:
return None
+ # Per-bssid first/last sighting across the scan's ssid rows.
+ seen = {}
+ for r in (row for row in rows if row.get('kind') == 'ap'):
+ mac = (r.get('bssid') or '').strip().upper()
+ t = r.get('time') or 0
+ lo, hi = seen.get(mac, (None, None))
+ seen[mac] = (t if lo is None else min(lo, t), t if hi is None else max(hi, t))
aps = []
ap_macs = set()
for r in (row for row in rows if row.get('kind') == 'ap'):
- ap_macs.add((r.get('bssid') or '').strip().upper())
+ mac = (r.get('bssid') or '').strip().upper()
+ ap_macs.add(mac)
+ lo, hi = seen.get(mac, (None, None))
aps.append({'bssid': fmt_mac(r.get('bssid')),
'ssid': decode_ssid(r.get('ssid')),
'hidden': bool(r.get('hidden')),
'channel': r.get('channel'),
'signal': r.get('signal'),
'freq': r.get('freq'),
- 'encryption': decode_encryption(r.get('encryption'))})
+ 'encryption': decode_encryption(r.get('encryption')),
+ 'band': band_of(r.get('freq')),
+ 'vendor': oui_vendor(fmt_mac(r.get('bssid'))),
+ 'first_seen': lo,
+ 'last_seen': hi})
aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0)
devices = [r for r in rows if r.get('kind') == 'device']
clients = []
@@ -1030,7 +1184,8 @@ def recon_scan_data(scan_id):
'time': r.get('time')})
return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'],
'name': scans[0].get('name')},
- 'aps': aps, 'clients': clients, 'handshakes': handshakes}
+ 'aps': aps, 'clients': clients, 'handshakes': handshakes,
+ 'unassociated': unassociated}
def h_recon_start(ctx):
@@ -1082,10 +1237,15 @@ 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
+ try:
+ _survey_sample()
+ except Exception:
+ pass
def h_recon_status(ctx):
@@ -1214,6 +1374,680 @@ def h_recon_scan_detail(ctx):
return 404, {'error': 'scan not found'}
return 200, data
+# ---------------------------------------------------------------------------
+# Recon report helpers (CSV / HTML) for scan and survey downloads.
+# ---------------------------------------------------------------------------
+
+def _fmt_ts(ts):
+ if not ts:
+ return '--'
+ try:
+ return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(ts)))
+ except (ValueError, OSError, TypeError):
+ return str(ts)
+
+
+def _csv_escape(v):
+ v = '' if v is None else str(v)
+ if any(c in v for c in ',"\n\r'):
+ return '"' + v.replace('"', '""') + '"'
+ return v
+
+
+def _aps_csv(data):
+ out = ['bssid,ssid,hidden,band,channel,freq,encryption,signal,vendor,first_seen,last_seen']
+ for a in (data or {}).get('aps') or []:
+ out.append(','.join(_csv_escape(x) for x in [
+ a.get('bssid'), a.get('ssid'), int(bool(a.get('hidden'))),
+ a.get('band'), a.get('channel'), a.get('freq'),
+ a.get('encryption'), a.get('signal'), a.get('vendor'),
+ _fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))]))
+ out.append('unassociated,%d' % ((data or {}).get('unassociated') or 0))
+ 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 ''
+ return (str(v).replace('&', '&').replace('<', '<')
+ .replace('>', '>').replace('"', '"'))
+
+
+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; }
+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; }
+"""
+
+
+def _html_table(headers, rows):
+ out = ['
']
+ for header in headers:
+ out.append('| %s | ' % _esc_html(header))
+ out.append('
')
+ for row in rows:
+ out.append('')
+ for cell in row:
+ out.append('| %s | ' % _esc_html(cell))
+ out.append('
')
+ out.append('
')
+ return ''.join(out)
+
+
+def _html_doc(title, subtitle, body_html, stats=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))
+ parts.append(body_html)
+ parts.append('')
+ return ''.join(parts)
+
+
+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."""
+ last = None
+ for _ in range(attempts):
+ try:
+ return fn()
+ except RuntimeError as exc:
+ last = exc
+ time.sleep(pause)
+ raise last
+
+
+def _scan_client_count(scan_id, _timeout=12):
+ rows = _db_rows(RECON_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)"
+ % (scan_id, scan_id), timeout=_timeout)
+ return rows[0]['c'] if rows else 0
+
+
+def h_recon_scan_download_csv(ctx):
+ scan_id = int(ctx.args[0])
+ 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, Download(_aps_csv(data).encode('utf-8'), 'text/csv',
+ 'scan-%d.csv' % scan_id)
+
+
+def h_recon_scan_download_html(ctx):
+ scan_id = int(ctx.args[0])
+ try:
+ data = _recon_read_retry(lambda: recon_scan_data(scan_id, _timeout=15, _limit=300))
+ client_count = _scan_client_count(scan_id, _timeout=12)
+ except RuntimeError:
+ 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)
+
+
+# ---------------------------------------------------------------------------
+# GPS: serial device discovery, gpsd control, TPV/SKY parsing, status cache.
+# Tied to the Glytch GPS mod (gpsd + uci 'gpsd' config section).
+# ---------------------------------------------------------------------------
+
+def _gps_serial_candidates():
+ candidates = []
+ if os.path.isdir(SERIAL_DIR):
+ try:
+ names = sorted(os.listdir(SERIAL_DIR))
+ except OSError:
+ names = []
+ for name in names:
+ path = os.path.join(SERIAL_DIR, name)
+ # by-path entries are named like '1.3_1-1.3:1.0' and only reveal
+ # their ttyACM/ttyUSB target through the resolved symlink.
+ target = os.path.realpath(path)
+ if ('ttyACM' in name or 'ttyUSB' in name
+ or 'ttyACM' in target or 'ttyUSB' in target):
+ candidates.append((name, path))
+ return candidates
+
+
+def _uci_gps_get():
+ rc, out, err = device_run(['uci', 'get', 'gpsd.core.device'])
+ return out.strip() or None
+
+
+def _uci_gps_set(device):
+ device_run(['uci', 'set', 'gpsd.core.device=%s' % device])
+ device_run(['uci', 'commit', 'gpsd'])
+
+
+def _gpsd_running():
+ rc, out, err = device_run(['pgrep', '-f', 'gpsd'])
+ return rc == 0
+
+
+def _gpsd_restart():
+ device_run([GPSD_INIT, 'restart'], timeout=15)
+
+
+def _gps_from_gpspipe():
+ try:
+ p = subprocess.Popen(['gpspipe', '-w', '-n', '3'],
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ out, _ = p.communicate(timeout=8)
+ except Exception:
+ return None
+ tpv = None
+ sky = None
+ for line in out.decode('utf-8', 'replace').splitlines():
+ obj = _json_or(line)
+ if not isinstance(obj, dict):
+ continue
+ cls = obj.get('class')
+ if cls == 'TPV' and tpv is None:
+ tpv = obj
+ elif cls == 'SKY' and sky is None:
+ sky = obj
+ if tpv is None:
+ return None
+ return {'fix': tpv.get('mode') or 0,
+ 'lat': tpv.get('lat'),
+ 'lon': tpv.get('lon'),
+ 'alt': tpv.get('alt'),
+ 'speed': tpv.get('speed'),
+ 'satellites': (sky or {}).get('satellites') or None}
+
+
+def _gps_from_hak5cmd():
+ out = hak5('GPS_GET', timeout=10)
+ obj = _json_or(out)
+ if not isinstance(obj, dict):
+ return None
+ return {'fix': obj.get('fix') or obj.get('mode') or 0,
+ 'lat': obj.get('lat') or obj.get('latitude'),
+ 'lon': obj.get('lon') or obj.get('longitude'),
+ 'alt': obj.get('alt'),
+ 'speed': obj.get('speed'),
+ 'satellites': obj.get('satellites') or obj.get('satellites_used')}
+
+
+def _wigle_config():
+ _, cur = daemon_sock_call('GET', '/api/pineap/get_config')
+ base = dict(PINEAP_CONFIG_DEFAULTS)
+ if isinstance(cur, dict) and 'reconpath' in cur:
+ base.update(cur)
+ return base
+
+
+def _wigle_set(enabled):
+ base = _wigle_config()
+ base['logwigle'] = bool(enabled)
+ return _daemon_proxy('PUT', 'set_config', base)
+
+
+def _gps_status_data_nocache():
+ try:
+ device = _uci_gps_get()
+ except Exception:
+ device = None
+ try:
+ present = any(os.path.exists(path)
+ for _, path in _gps_serial_candidates())
+ except Exception:
+ present = False
+ try:
+ running = _gpsd_running()
+ except Exception:
+ running = False
+ data = {'device': device, 'present': present, 'fix': 0,
+ 'lat': None, 'lon': None, 'alt': None, 'speed': None,
+ '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:
+ fix = _gps_from_hak5cmd()
+ if fix:
+ data.update(fix)
+ data['updated'] = int(time.time())
+ return data
+
+
+def _gps_status_data():
+ with _gps_lock:
+ if (_gps_cache['data'] is not None
+ and time.time() - _gps_cache['updated'] < GPS_CACHE_SECONDS):
+ return _gps_cache['data']
+ data = _gps_status_data_nocache()
+ _gps_cache.update({'updated': time.time(), 'data': data})
+ return data
+
+
+def h_recon_gps(ctx):
+ return 200, _gps_status_data()
+
+
+def h_recon_gps_configure(ctx):
+ try:
+ candidates = _gps_serial_candidates()
+ except Exception:
+ candidates = []
+ if not candidates:
+ return 200, {'present': False, 'error': 'No GPS serial device found'}
+ try:
+ current = _uci_gps_get()
+ except Exception:
+ current = None
+ ordered = sorted(candidates, key=lambda c: (c[0] != current, c[0]))
+ tried = []
+ for name, path in ordered[:3]:
+ tried.append(name)
+ try:
+ _uci_gps_set(name)
+ _gpsd_restart()
+ time.sleep(1.5)
+ fix = _gps_from_gpspipe()
+ except Exception:
+ fix = None
+ if fix is not None and fix.get('fix'):
+ data = _gps_status_data_nocache()
+ data.update({'configured': True, 'device': name, 'tried': tried,
+ 'lock': True})
+ return 200, data
+ data = _gps_status_data_nocache()
+ data.update({'configured': True, 'device': ordered[0][0], 'tried': tried,
+ 'note': 'GPS bound, waiting for a fix'})
+ return 200, data
+
+
+# ---------------------------------------------------------------------------
+# WiGLE logging: toggle the daemon 'logwigle' setting, list and download the
+# CSV files the daemon writes under WIGLE_DIR.
+# ---------------------------------------------------------------------------
+
+def wigle_files_data():
+ files = []
+ if os.path.isdir(WIGLE_DIR):
+ try:
+ names = sorted(os.listdir(WIGLE_DIR))
+ except OSError:
+ names = []
+ for name in names:
+ path = os.path.join(WIGLE_DIR, name)
+ if not os.path.isfile(path):
+ continue
+ try:
+ size = os.path.getsize(path)
+ mtime = int(os.path.getmtime(path))
+ except OSError:
+ continue
+ rows = None
+ if size <= 2 * 1024 * 1024:
+ try:
+ with open(path, 'r', errors='replace') as fh:
+ lines = [line for line in fh if line.strip()]
+ # WiGLE CSVs lead with a meta line ('WigleWifi-1.6,...')
+ # followed by the column header; only count data rows.
+ header_offset = 2 if lines and lines[0].startswith('WigleWifi') else 1
+ rows = max(0, len(lines) - header_offset)
+ except OSError:
+ rows = None
+ files.append({'name': name, 'size': size, 'mtime': mtime, 'rows': rows})
+ return {'files': files}
+
+
+def h_recon_wigle_files(ctx):
+ return 200, wigle_files_data()
+
+
+def h_recon_wigle_file(ctx):
+ name = _unquote_plus(ctx.args[0])
+ path = _safe_join(WIGLE_DIR, name)
+ if path is None or not os.path.isfile(path):
+ return 404, {'error': 'file not found'}
+ try:
+ with open(path, 'rb') as fh:
+ body = fh.read()
+ except OSError:
+ return 404, {'error': 'file not found'}
+ return 200, Download(body, 'text/csv', os.path.basename(path))
+
+
+def h_recon_wigle(ctx):
+ enable = bool((getattr(ctx, 'body', None) or {}).get('enable'))
+ status, data = _wigle_set(enable)
+ if status != 200:
+ return status, data
+ if enable:
+ hak5('WIGLE_START', timeout=10)
+ else:
+ hak5('WIGLE_STOP', timeout=10)
+ resp = {'ok': True, 'wigle': enable}
+ if enable:
+ files = wigle_files_data().get('files') or []
+ if files:
+ resp['filename'] = files[-1]['name']
+ 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})_'
@@ -2916,6 +3750,20 @@ ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail)
ROUTER.add('DELETE', r'/api/recon/scans/(\d+)', h_recon_delete)
ROUTER.add('GET', r'/api/recon/events', h_recon_events)
ROUTER.add('POST', r'/api/recon/examine', h_recon_examine)
+ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/csv', h_recon_scan_download_csv)
+ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/html', h_recon_scan_download_html)
+ROUTER.add('GET', r'/api/recon/gps', h_recon_gps)
+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/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 2f7f639..7b8490e 100644
--- a/payload/user/remote_access/pager-webui/www/css/app.css
+++ b/payload/user/remote_access/pager-webui/www/css/app.css
@@ -354,6 +354,69 @@ html.dark .recon-row-selected td { background: #565656; }
th.recon-sorted { color: var(--primary); }
.recon-per { width: auto; }
+/* ---- Recon supercharge: dBm bars, chips, pills ---- */
+.recon-dbm-cell { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
+.recon-dbm-bar { display: inline-block; width: 46px; height: 6px; border-radius: 3px; background: var(--surface-alt); overflow: hidden; vertical-align: middle; }
+html.dark .recon-dbm-bar { background: #333; }
+.recon-dbm-fill { display: block; height: 100%; border-radius: 3px; }
+.recon-dbm-val { font-variant-numeric: tabular-nums; }
+.recon-chips-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0 10px; }
+.recon-chips-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 2px 0 8px; }
+.recon-chips-label:first-child { margin-left: 0; }
+.recon-chip { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; }
+.recon-chip:hover { color: var(--text); border-color: var(--primary); }
+.recon-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
+.recon-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.recon-pill:hover { color: var(--text); border-color: var(--primary); }
+.recon-pill:disabled { opacity: .5; cursor: default; }
+.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; }
+
+/* ---- 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; }
+
/* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; }
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
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 2089b2b..d1437fb 100644
--- a/payload/user/remote_access/pager-webui/www/js/app.js
+++ b/payload/user/remote_access/pager-webui/www/js/app.js
@@ -396,6 +396,8 @@ 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',
'#/logging/system': 'logging_system',
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 d05d675..395c4d4 100644
--- a/payload/user/remote_access/pager-webui/www/js/chart.js
+++ b/payload/user/remote_access/pager-webui/www/js/chart.js
@@ -10,7 +10,10 @@ const MiniChart = (() => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = 140;
ctx.clearRect(0, 0, w, h);
- const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
+ const oMin = o.min == null ? 0 : o.min;
+ const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, oMin)), oMin + 1);
+ const min = Math.min(oMin, ...series.map((s) => Math.min(...s.points, oMin)));
+ const span = Math.max(max - min, 1);
const pad = 8;
ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1;
@@ -28,14 +31,14 @@ const MiniChart = (() => {
pts.forEach((v, i) => {
if (v == null) { started = false; return; }
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
- const y = h - pad - (h - pad * 2) * (v / max);
+ const y = h - pad - (h - pad * 2) * ((v - min) / span);
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
});
ctx.stroke();
const last = pts[pts.length - 1];
if (last != null) {
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
- const y = h - pad - (h - pad * 2) * (last / max);
+ const y = h - pad - (h - pad * 2) * ((last - min) / span);
ctx.fillStyle = s.color || '#1976d2';
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
}
diff --git a/payload/user/remote_access/pager-webui/www/js/icons.js b/payload/user/remote_access/pager-webui/www/js/icons.js
index 2847598..abd2539 100644
--- a/payload/user/remote_access/pager-webui/www/js/icons.js
+++ b/payload/user/remote_access/pager-webui/www/js/icons.js
@@ -30,5 +30,11 @@ window.PineappleIcons = {
help: '',
update: '',
logout: '',
- reboot: ''
+ reboot: '',
+ table_chart: '',
+ description: '',
+ record: '',
+ place: '',
+ play_arrow: '',
+ stop: ''
};
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 b47734e..76f31ca 100644
--- a/payload/user/remote_access/pager-webui/www/js/views.js
+++ b/payload/user/remote_access/pager-webui/www/js/views.js
@@ -28,7 +28,14 @@ const table = (columns, rows, rowAttrs) => {
const tb = h('tbody');
(rows || []).forEach((r) => {
const trr = h('tr', rowAttrs ? rowAttrs(r) : {});
- columns.forEach((c) => trr.appendChild(h('td', { text: c.render ? c.render(r) : r[c.key] })));
+ columns.forEach((c) => {
+ const v = c.render ? c.render(r) : r[c.key];
+ const td = h('td');
+ if (v == null) td.textContent = '';
+ else if (typeof v === 'string' || typeof v === 'number') td.textContent = String(v);
+ else td.appendChild(v);
+ trr.appendChild(td);
+ });
tb.appendChild(trr);
});
t.appendChild(tb);
@@ -41,6 +48,16 @@ const fmtTime = (ts) => {
return d.toLocaleString();
};
+const fmtShortTime = (ts) => {
+ if (!ts) return '--';
+ const d = new Date(ts * 1000);
+ const now = new Date();
+ const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
+ const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ if (sameDay) return hm;
+ return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
+};
+
const fmtDur = (secs) => {
if (secs == null) return '--';
const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600),
@@ -1044,6 +1061,8 @@ 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' }
];
@@ -1054,9 +1073,13 @@ const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB',
const RECON_AP_COLS = [
{ 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 ? '--' : a.signal + ' dBm' },
+ { 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: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
+ { key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_seen) },
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
];
const RECON_CLIENT_COLS = [
@@ -1066,9 +1089,38 @@ const RECON_CLIENT_COLS = [
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 }
];
+function reconBandOf(freq) {
+ if (freq == null) return null;
+ if (freq >= 2400 && freq < 2500) return '2.4';
+ if (freq >= 4900 && freq < 5900) return '5';
+ if (freq >= 5900 && freq < 7125) return '6';
+ return null;
+}
+
+function reconSigColor(dbm) {
+ if (dbm == null) return '#9e9e9e';
+ if (dbm >= -50) return '#2e7d32';
+ if (dbm >= -67) return '#f9a825';
+ if (dbm >= -80) return '#ef6c00';
+ return '#c62828';
+}
+
+function dbmCell(dbm) {
+ const pct = dbm == null ? 0 : Math.max(0, Math.min(100, ((dbm + 100) / 60) * 100));
+ const color = reconSigColor(dbm);
+ const bar = h('span', { class: 'recon-dbm-bar' },
+ h('span', { class: 'recon-dbm-fill', style: 'width:' + pct.toFixed(0) + '%;background:' + color }));
+ return h('span', { class: 'recon-dbm-cell' },
+ bar, h('span', { class: 'recon-dbm-val', style: 'color:' + color, text: (dbm == null ? '--' : dbm + ' dBm') }));
+}
+
+function reconBandLabel(band) {
+ return band == null ? '--' : band + ' GHz';
+}
+
function reconDefaultCols() {
return {
- ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: true, hidden: true },
+ ap: { 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 }
};
}
@@ -1104,7 +1156,8 @@ function reconPer(key, def) {
}
function reconCmp(a, b, col, dir) {
- const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets';
+ const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' ||
+ col.key === 'first_seen' || col.key === 'last_seen';
if (numeric) {
const x = a[col.key] == null ? -Infinity : Number(a[col.key]);
const y = b[col.key] == null ? -Infinity : Number(b[col.key]);
@@ -1124,7 +1177,8 @@ 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 };
+ detailQueued: false, detailId: null,
+ apBand: 'all', apEnc: 'all', gps: null, wigle: null };
const cols = reconLoadCols();
// ---- title cards ----
@@ -1192,9 +1246,18 @@ views.recon = (root) => {
loadDetail();
});
psRow.appendChild(sel);
- psRow.appendChild(iconBtn('file_download', 'Download scan JSON', () => {
+ const dlJson = iconBtn('file_download', 'Download scan JSON', () => {
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/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 dlHtml = iconBtn('description', 'Download scan HTML report', () => {
+ if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/html';
+ });
+ psRow.appendChild(dlJson);
+ psRow.appendChild(dlCsv);
+ psRow.appendChild(dlHtml);
psRow.appendChild(iconBtn('delete', 'Delete scan', () => {
if (state.selected == null) return;
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
@@ -1216,7 +1279,47 @@ views.recon = (root) => {
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
scanBar.appendChild(durSel);
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)' });
+ scanBar.appendChild(gpsPill);
+ scanBar.appendChild(wiglePill);
scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
+ function renderPills() {
+ const g = state.gps || {};
+ if (g.lock) {
+ gpsPill.className = 'recon-pill recon-pill-gps 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 = 'recon-pill recon-pill-gps';
+ gpsPill.textContent = 'GPS no fix';
+ } else if (g.gpsd_running) {
+ gpsPill.className = 'recon-pill recon-pill-gps';
+ gpsPill.textContent = 'GPS no device';
+ } else {
+ gpsPill.className = 'recon-pill recon-pill-gps';
+ gpsPill.textContent = 'GPS off';
+ }
+ wiglePill.className = 'recon-pill recon-pill-wigle ' + (state.wigle ? 'on' : '');
+ wiglePill.textContent = state.wigle ? 'WiGLE on' : 'WiGLE off';
+ }
+ gpsPill.addEventListener('click', () => {
+ PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
+ state.gps = r.data;
+ renderPills();
+ if (r.data.error) App.toast(r.data.error, 'error');
+ else if (r.data.lock) App.toast('GPS locked: ' + Number(r.data.lat).toFixed(5) + ', ' + Number(r.data.lon).toFixed(5));
+ else App.toast((r.data.note || 'GPS bound, waiting for a fix'));
+ }).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'));
+ });
+ wiglePill.addEventListener('click', () => {
+ const next = !state.wigle;
+ wiglePill.disabled = true;
+ PagerAPI.post('/api/recon/wigle', { enable: next }).then((r) => {
+ state.wigle = next;
+ App.toast(next ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
+ }).catch((err) => App.toast((err && err.message) || 'WiGLE toggle failed', 'error'))
+ .finally(() => { wiglePill.disabled = false; renderPills(); });
+ });
let pendingScan = false;
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
@@ -1252,8 +1355,10 @@ 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'], ['channel', 'Show Channel'],
- ['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['hidden', 'Show Hidden']],
+ ap: [['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']],
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
};
Object.keys(colDefs).forEach((grp) => {
@@ -1348,6 +1453,32 @@ views.recon = (root) => {
const cliCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(cliCard);
+ // ---- band / encryption filter chips ----
+ const chipRow = h('div', { class: 'recon-chips-row' });
+ apCard.appendChild(chipRow);
+ function renderChips() {
+ chipRow.innerHTML = '';
+ const groups = [
+ ['Band', 'apBand', [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']]],
+ ['Encryption', 'apEnc', [['all', 'All'], ['Open', 'Open'], ['WEP', 'WEP'], ['WPA', 'WPA'],
+ ['WPA2', 'WPA2'], ['WPA3', 'WPA3'], ['Enterprise', 'Enterprise']]]
+ ];
+ groups.forEach(([label, key, opts]) => {
+ chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label }));
+ opts.forEach(([v, t]) => {
+ const c = h('button', { class: 'recon-chip' + (state[key] === v ? ' active' : ''), text: t });
+ c.addEventListener('click', () => {
+ state[key] = v;
+ state.apPage = 0;
+ renderChips();
+ renderTables();
+ });
+ chipRow.appendChild(c);
+ });
+ });
+ }
+ renderChips();
+
function buildPaginator(key) {
const mk = (id, icon, title, fn) => {
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title });
@@ -1363,12 +1494,20 @@ views.recon = (root) => {
mk('last', 'last_page', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
}
- function reconPageCount(key) {
+ function filteredRows(key) {
const d = state.detail || {};
const rows = key === 'ap' ? (d.aps || []) : (d.clients || []);
- const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS;
- const q = key === 'ap' ? state.apSearch : state.clientSearch;
- return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / state[key + 'Per']));
+ 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);
+ }
+ return out;
+ }
+
+ function reconPageCount(key) {
+ return Math.max(1, Math.ceil(filteredRows(key).length / state[key + 'Per']));
}
function perSelect(key) {
@@ -1464,8 +1603,8 @@ views.recon = (root) => {
function renderTables() {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
- const apF = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS);
- const cliF = reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
+ 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.');
}
@@ -1479,7 +1618,7 @@ views.recon = (root) => {
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: 0, color: RECON_LANDSCAPE_COLORS[2] }
+ { label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
], { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
@@ -1595,6 +1734,11 @@ views.recon = (root) => {
App.toast('Scan complete');
}
}).catch(() => {});
+ PagerAPI.get('/api/recon/gps').then((r) => {
+ state.gps = r.data;
+ state.wigle = !!(r.data || {}).wigle;
+ renderPills();
+ }).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
@@ -1610,6 +1754,506 @@ views.recon = (root) => {
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
+ };
+
+ // ---- 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) };
+};
+
+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; }
+
+ function renderScans() {
+ reportCard.appendChild(h('h2', { text: 'Scan Reports' }));
+ const box = h('div');
+ reportCard.appendChild(box);
+ PagerAPI.get('/api/recon/scans').then((r) => {
+ const scans = (r.data && r.data.scans) || [];
+ box.innerHTML = '';
+ if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; }
+ box.appendChild(table(
+ [
+ { key: 'id', label: 'Scan', render: (s) => '#' + s.id },
+ { key: 'time', label: 'Started', render: (s) => fmtTime(s.time) },
+ { key: 'aps', label: 'APs' },
+ { key: 'devices', label: 'Clients' },
+ { key: 'handshakes', label: 'Handshakes' },
+ { key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' },
+ iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')),
+ iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')),
+ iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) }
+ ],
+ scans));
+ }).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');
+ wigleCard.appendChild(box);
+ PagerAPI.get('/api/recon/wigle/files').then((r) => {
+ const files = (r.data && r.data.files) || [];
+ box.innerHTML = '';
+ if (!files.length) { box.appendChild(h('div', { class: 'empty', text: 'No WiGLE files yet. WiGLE logging writes a CSV per capture session.' })); return; }
+ box.appendChild(table(
+ [
+ { key: 'name', label: 'File', render: (f) => f.name },
+ { 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: 'dl', label: 'Download', render: (f) => iconBtn('file_download', 'Download ' + f.name, () => dl('/api/recon/wigle/files/' + encodeURIComponent(f.name))) }
+ ],
+ files));
+ }).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load WiGLE files.' })));
+ }
+
+ root.appendChild(reportCard);
+ root.appendChild(surveyCard);
+ root.appendChild(wigleCard);
+ renderScans();
+ renderSurveys();
+ renderWigle();
+ return { destroy: () => {} };
+};
const LOGGING_TABS = [
{ label: 'PineAP', hash: '#/logging' },
diff --git a/tests/test_recon.py b/tests/test_recon.py
index 32db236..90d1f01 100644
--- a/tests/test_recon.py
+++ b/tests/test_recon.py
@@ -635,3 +635,487 @@ class HandshakeRoutesTest(unittest.TestCase):
self.assertEqual(data['files'], [])
self.assertEqual(data['handshakes'], [])
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')
+ self.assertEqual(server._oui_prefix('C89E43648080'), 'C89E43')
+ self.assertEqual(server._oui_prefix('c8:9e:43:64:80:80'), 'C89E43')
+ self.assertIsNone(server._oui_prefix(None))
+ self.assertIsNone(server._oui_prefix(''))
+ self.assertIsNone(server._oui_prefix('XX:YY:ZZ:00:00:00'))
+
+ def test_oui_vendor_lookup(self):
+ self.assertEqual(server.oui_vendor('B8:27:EB:00:00:00'), 'Raspberry Pi')
+ self.assertEqual(server.oui_vendor('10:BF:48:00:00:00'), 'Apple')
+ self.assertEqual(server.oui_vendor('14:CC:20:00:00:00'), 'TP-Link')
+ self.assertEqual(server.oui_vendor('FC:63:3E:00:00:00'), 'Google')
+
+ def test_oui_vendor_unknown_and_local(self):
+ self.assertEqual(server.oui_vendor('C8:9E:43:64:80:80'), 'Unknown')
+ self.assertEqual(server.oui_vendor('AE:77:C0:EB:31:41'), 'Local')
+ self.assertEqual(server.oui_vendor(None), 'Unknown')
+ self.assertEqual(server.oui_vendor('--'), 'Unknown')
+
+ def test_band_of_frequencies(self):
+ self.assertEqual(server.band_of(2412), '2.4')
+ self.assertEqual(server.band_of(5200), '5')
+ self.assertEqual(server.band_of(6180), '6')
+ self.assertEqual(server.band_of(0), '--')
+ self.assertEqual(server.band_of(None), '--')
+
+ def test_curated_table_has_no_garbage_keys(self):
+ for key in server.OUI_VENDORS:
+ self.assertRegex(key, r'^[0-9A-F]{6}$')
+ self.assertNotIn('349A...', server.OUI_VENDORS)
+
+
+class ReconEnrichmentTest(unittest.TestCase):
+ def setUp(self):
+ self.db = make_db()
+ server.RECON_DB = self.db
+
+ def tearDown(self):
+ os.unlink(self.db)
+
+ def test_scan_detail_enriches_aps(self):
+ data = server.recon_scan_data(1)
+ aps = {a['bssid']: a for a in data['aps']}
+ a = aps['C8:9E:43:64:80:80']
+ self.assertEqual(a['band'], '5')
+ self.assertEqual(a['vendor'], 'Unknown')
+ self.assertEqual(a['first_seen'], 1786466532)
+ self.assertEqual(a['last_seen'], 1786466532)
+ hidden = aps['50:6F:9A:01:00:00']
+ self.assertEqual(hidden['band'], '5')
+ self.assertEqual(hidden['vendor'], 'Unknown')
+
+ def test_scan_detail_unassociated_count(self):
+ data = server.recon_scan_data(1)
+ self.assertEqual(data['unassociated'], 1)
+
+ def test_scan_detail_bounded_mode_counts_unassociated(self):
+ data = server.recon_scan_data(1, _limit=1)
+ self.assertEqual(data['unassociated'], 1)
+ self.assertEqual(len(data['aps']), 2)
+ self.assertLessEqual(len(data['clients']), 1)
+ self.assertEqual(data['scan']['id'], 1)
+
+ def test_first_last_seen_span_multiple_rows(self):
+ conn = sqlite3.connect(self.db)
+ conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
+ "VALUES (30, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466540, -80, 5745, 149, 0x400400108)")
+ conn.commit()
+ conn.close()
+ data = server.recon_scan_data(1)
+ a = [a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80'][0]
+ self.assertEqual(a['first_seen'], 1786466532)
+ self.assertEqual(a['last_seen'], 1786466540)
+
+ def test_band_for_24ghz_ap(self):
+ conn = sqlite3.connect(self.db)
+ conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (3, 1, 'FC633E000001', 1786466533, -60, 2412, 4)")
+ conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
+ "VALUES (31, 3, 1, 8, 'FC633E000001', X'4E6574776F726B', 0, 1786466533, -60, 2412, 6, 0x08)")
+ conn.commit()
+ conn.close()
+ data = server.recon_scan_data(1)
+ a = [a for a in data['aps'] if a['bssid'] == 'FC:63:3E:00:00:01'][0]
+ self.assertEqual(a['band'], '2.4')
+ self.assertEqual(a['vendor'], 'Google')
+
+
+class ReconReportTest(unittest.TestCase):
+ def setUp(self):
+ self.db = make_db()
+ server.RECON_DB = self.db
+
+ def tearDown(self):
+ os.unlink(self.db)
+
+ def _ctx(self, args=()):
+ return type('C', (), {'args': args, 'body': {}})()
+
+ def test_csv_download_contains_aps_and_unassociated(self):
+ status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
+ self.assertEqual(status, 200)
+ self.assertEqual(payload.ctype, 'text/csv')
+ self.assertEqual(payload.filename, 'scan-1.csv')
+ text = payload.data.decode('utf-8')
+ self.assertIn('Anderson-5', text)
+ self.assertIn('unassociated,1', text)
+ 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',)))
+ self.assertEqual(status, 200)
+ self.assertEqual(payload.ctype, 'text/html')
+ self.assertEqual(payload.filename, 'scan-1.html')
+ text = payload.data.decode('utf-8')
+ self.assertIn('Scan #1', text)
+ self.assertIn('Anderson-5', text)
+ self.assertIn('WPA3 WPA2', text)
+ self.assertIn('Unassociated', text)
+
+ def test_download_404_for_missing_scan(self):
+ status, payload = server.h_recon_scan_download_csv(self._ctx(('999',)))
+ self.assertEqual(status, 404)
+ status, payload = server.h_recon_scan_download_html(self._ctx(('999',)))
+ self.assertEqual(status, 404)
+
+ def test_download_503_when_db_unavailable(self):
+ with mock.patch.object(server, 'recon_scan_data',
+ side_effect=RuntimeError('sqlite read failed: locked')), \
+ mock.patch.object(server.time, 'sleep'):
+ status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
+ self.assertEqual(status, 503)
+ status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
+ self.assertEqual(status, 503)
+
+
+class GpsTest(unittest.TestCase):
+ def setUp(self):
+ server._gps_cache.update({'updated': 0, 'data': None})
+
+ @unittest.skipIf(os.name == 'nt', 'symlinks are not reliably available on Windows')
+ def test_serial_candidates_detect_bypath_targets(self):
+ d = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, d)
+ self.addCleanup(setattr, server, 'SERIAL_DIR', server.SERIAL_DIR)
+ server.SERIAL_DIR = d
+ os.symlink('/dev/ttyACM0', os.path.join(d, '1.3_1-1.3:1.0'))
+ os.symlink('/dev/ttyACM1', os.path.join(d, '1.3_1-1.3:1.2'))
+ with open(os.path.join(d, 'not-a-serial'), 'w') as f:
+ f.write('x')
+ candidates = server._gps_serial_candidates()
+ names = [name for name, _ in candidates]
+ self.assertEqual(names, ['1.3_1-1.3:1.0', '1.3_1-1.3:1.2'])
+
+ def test_gps_status_passthrough(self):
+ with mock.patch.object(server, '_gps_status_data_nocache',
+ return_value={'present': True, 'wigle': True}):
+ status, data = server.h_recon_gps(type('C', (), {'args': ()})())
+ self.assertEqual(status, 200)
+ self.assertTrue(data['present'])
+ self.assertTrue(data['wigle'])
+
+ 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), \
+ mock.patch.object(server, '_uci_gps_get', return_value='1.3_2-1.3:1.0'), \
+ mock.patch.object(server, '_uci_gps_set') as uci_set, \
+ mock.patch.object(server, '_gpsd_restart'), \
+ mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
+ mock.patch.object(server, '_gps_from_gpspipe',
+ return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}), \
+ mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
+ status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
+ self.assertEqual(status, 200)
+ self.assertTrue(data['lock'])
+ self.assertEqual(data['tried'], ['1.3_2-1.3:1.0'])
+ uci_set.assert_called_once_with('1.3_2-1.3:1.0')
+
+ def test_configure_no_candidates_errors(self):
+ with mock.patch.object(server, '_gps_serial_candidates', return_value=[]):
+ status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
+ self.assertEqual(status, 200)
+ self.assertIn('error', data)
+
+ def test_configure_fallback_binds_first_with_note(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), \
+ mock.patch.object(server, '_uci_gps_get', return_value=None), \
+ mock.patch.object(server, '_uci_gps_set'), \
+ mock.patch.object(server, '_gpsd_restart'), \
+ mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
+ mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
+ mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
+ status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
+ self.assertEqual(status, 200)
+ self.assertEqual(data['device'], '1.2_1-1.2:1.0')
+ self.assertIn('waiting for a fix', data['note'])
+
+ def test_configure_tries_at_most_three_candidates(self):
+ candidates = [(str(i), '/dev/%d' % i) for i in range(5)]
+ with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
+ mock.patch.object(server, '_uci_gps_get', return_value=None), \
+ mock.patch.object(server, '_uci_gps_set'), \
+ mock.patch.object(server, '_gpsd_restart'), \
+ mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
+ mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
+ mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
+ status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
+ self.assertEqual(status, 200)
+ self.assertEqual(data['tried'], ['0', '1', '2'])
+
+
+class WigleTest(unittest.TestCase):
+ def setUp(self):
+ self.dir = tempfile.mkdtemp()
+ self._orig = server.WIGLE_DIR
+ server.WIGLE_DIR = self.dir
+
+ def tearDown(self):
+ server.WIGLE_DIR = self._orig
+ shutil.rmtree(self.dir)
+
+ def _ctx(self, args=(), body=None):
+ return type('C', (), {'args': args, 'body': body or {}})()
+
+ def _write(self, name, content):
+ with open(os.path.join(self.dir, name), 'w') as f:
+ f.write(content)
+
+ def test_file_rows_count_excludes_header(self):
+ self._write('a.csv', 'header\nr1\nr2\n')
+ self._write('b.csv', 'onlyheader\n')
+ status, data = server.h_recon_wigle_files(self._ctx())
+ self.assertEqual(status, 200)
+ files = {f['name']: f for f in data['files']}
+ self.assertEqual(files['a.csv']['rows'], 2)
+ self.assertEqual(files['b.csv']['rows'], 0)
+ self.assertEqual(files['a.csv']['size'], len('header\nr1\nr2\n'))
+
+ def test_file_rows_count_ignores_wigle_meta_and_header(self):
+ meta = 'WigleWifi-1.6,appRelease=0.0.0,model=pineapplepager,release=0.0.0\n'
+ header = 'MAC,SSID,AuthMode,FirstSeen,Channel,Frequency,RSSI,CurrentLatitude,CurrentLongitude\n'
+ self._write('empty.csv', meta + header)
+ self._write('full.csv', meta + header + 'AA:BB:CC:DD:EE:FF,test,0,,1,2412,-60,37.7,-122.4\n')
+ status, data = server.h_recon_wigle_files(self._ctx())
+ files = {f['name']: f for f in data['files']}
+ self.assertEqual(files['empty.csv']['rows'], 0)
+ self.assertEqual(files['full.csv']['rows'], 1)
+
+ def test_file_download(self):
+ self._write('wigle-1.csv', 'lat,lon\n37.7,-122.4\n')
+ status, payload = server.h_recon_wigle_file(self._ctx(('wigle-1.csv',)))
+ self.assertEqual(status, 200)
+ self.assertEqual(payload.filename, 'wigle-1.csv')
+ self.assertIn(b'37.7', payload.data)
+
+ def test_file_download_404_and_traversal(self):
+ status, payload = server.h_recon_wigle_file(self._ctx(('missing.csv',)))
+ self.assertEqual(status, 404)
+ status, payload = server.h_recon_wigle_file(self._ctx(('..%2F..%2Fetc%2Fpasswd',)))
+ self.assertEqual(status, 404)
+
+ def test_toggle_enable_and_disable(self):
+ with mock.patch.object(server, '_wigle_set', return_value=(200, {'ok': True})), \
+ mock.patch.object(server, 'hak5') as hak5, \
+ mock.patch.object(server, 'wigle_files_data',
+ return_value={'files': [{'name': 'w.csv'}]}):
+ status, data = server.h_recon_wigle(self._ctx(body={'enable': True}))
+ self.assertEqual(status, 200)
+ self.assertTrue(data['wigle'])
+ self.assertEqual(data['filename'], 'w.csv')
+ hak5.assert_called_once_with('WIGLE_START', timeout=10)
+ status, data = server.h_recon_wigle(self._ctx(body={'enable': False}))
+ self.assertEqual(status, 200)
+ self.assertFalse(data['wigle'])
+ 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 = [
+ ('GET', '/api/recon/scans/1/download/csv', 'h_recon_scan_download_csv'),
+ ('GET', '/api/recon/scans/1/download/html', 'h_recon_scan_download_html'),
+ ('GET', '/api/recon/gps', 'h_recon_gps'),
+ ('POST', '/api/recon/gps/configure', 'h_recon_gps_configure'),
+ ('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'),
+ ]
+ 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_original_recon_routes_unchanged(self):
+ for path in ['/api/recon/start', '/api/recon/status', '/api/recon/scans',
+ '/api/recon/events']:
+ method = 'GET' if path.endswith(('status', 'scans', 'events')) else 'POST'
+ h, args = server.ROUTER.dispatch(method, path)
+ self.assertIsNotNone(h, path)
+