feat: recon surveys with OUI/vendor lookup and report views
Reconnaissance surveys (GPSD-tethered scan recording), OUI vendor lookup for client/AP tables, survey/report pages, and the matching test_recon.py suite. Co-authored with the recon-feature agent whose work was finished in this checkout.
This commit is contained in:
@@ -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 = ['<table><thead><tr>']
|
||||
for header in headers:
|
||||
out.append('<th>%s</th>' % _esc_html(header))
|
||||
out.append('</tr></thead><tbody>')
|
||||
for row in rows:
|
||||
out.append('<tr>')
|
||||
for cell in row:
|
||||
out.append('<td>%s</td>' % _esc_html(cell))
|
||||
out.append('</tr>')
|
||||
out.append('</tbody></table>')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def _html_doc(title, subtitle, body_html, stats=None):
|
||||
parts = ['<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title>'
|
||||
'<style>%s</style></head><body>' % (_esc_html(title), REPORT_CSS)]
|
||||
parts.append('<h1>%s</h1>' % _esc_html(title))
|
||||
parts.append('<div class="sub">%s</div>' % _esc_html(subtitle))
|
||||
for label, value in (stats or []):
|
||||
parts.append('<div class="stats"><b>%s:</b> %s</div>' % (_esc_html(label), value))
|
||||
parts.append(body_html)
|
||||
parts.append('</body></html>')
|
||||
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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -30,5 +30,11 @@ window.PineappleIcons = {
|
||||
help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>',
|
||||
update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>',
|
||||
logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>',
|
||||
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>'
|
||||
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>',
|
||||
table_chart: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,20H9V4H12V20M19,20H16V10H19V20M5,20H2V14H5V20Z"/></svg>',
|
||||
description: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,2H6C4.9,2 4,2.9 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2M18,20H6V4H13V9H18V20M16,18H8V16H16V18M16,14H8V12H16V14Z"/></svg>',
|
||||
record: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>',
|
||||
place: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5Z"/></svg>',
|
||||
play_arrow: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8,5.14V19.14L19,12.14L8,5.14Z"/></svg>',
|
||||
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,18H6V6H18V18Z"/></svg>'
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user