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('' % _esc_html(header)) + out.append('') + for row in rows: + out.append('') + for cell in row: + out.append('' % _esc_html(cell)) + out.append('') + out.append('
%s
%s
') + 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) +