feat: merge Survey into Scanning and harden live recon
- Delete the Survey stack (sampler, handlers, routes, view, recordings); Scanning gains AP compare (cap 6), whole-page selection filter, channel map (20 MHz lobes, the radio does not report width), and auto-follow that prefers the newest non-empty scan - Richer HTML scan reports: stat cards, band/encryption breakdowns, channel occupancy, color-coded signal cells, GPS line only on a fix - Harden live recon: bounded + retried scan-detail reads (503 on lock), serialize recon starts (409 + scan_remaining while running), GPS and archive discovery move to a 30s poll, GPS_GET skipped when gpsd is down - Read-only history for pineapd-rotated databases (error-*/diagnostic-*): archives list/detail/download endpoints, Previous Scans optgroup, delete disabled, traversal-guarded - Status flags: hopper-radio-offline and history-reset banners; empty scans greyed in the picker - Tests: recon suite grows to 99 cases; all 14 modules green Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
251b1f6261
commit
d8a7074e1f
@@ -45,18 +45,12 @@ _recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
|
||||
_recon_status_cache = {
|
||||
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
|
||||
|
||||
SURVEY_DIR = os.environ.get('PAGER_SURVEY_DIR', '/root/loot/recon-surveys')
|
||||
WIGLE_DIR = os.environ.get('PAGER_WIGLE_DIR', '/root/loot/wigle')
|
||||
GPSD_CONFIG = os.environ.get('PAGER_GPSD_CONFIG', '/etc/config/gpsd')
|
||||
GPSD_INIT = os.environ.get('PAGER_GPSD_INIT', '/etc/init.d/gpsd')
|
||||
SERIAL_DIR = os.environ.get('PAGER_SERIAL_DIR', '/dev/serial/by-path')
|
||||
SURVEY_MAX_SAMPLES = int(os.environ.get('PAGER_SURVEY_MAX_SAMPLES', '1800'))
|
||||
SURVEY_SAMPLE_INTERVAL = float(os.environ.get('PAGER_SURVEY_SAMPLE_INTERVAL', '2.0'))
|
||||
GPS_CACHE_SECONDS = 5.0
|
||||
|
||||
_survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
|
||||
'started': 0, 'samples': 0, 'last_sample': 0}
|
||||
_survey_lock = threading.Lock()
|
||||
_gps_cache = {'updated': 0, 'data': None}
|
||||
_gps_lock = threading.Lock()
|
||||
_payload_runs = {}
|
||||
@@ -1067,8 +1061,10 @@ def band_of(freq):
|
||||
|
||||
|
||||
|
||||
def recon_scans_data(limit=50, _timeout=20):
|
||||
rows = _db_rows(RECON_DB,
|
||||
def recon_scans_data(limit=50, _timeout=20, db=None):
|
||||
if db is None:
|
||||
db = RECON_DB
|
||||
rows = _db_rows(db,
|
||||
'WITH recent AS (SELECT id, time, name FROM scan ORDER BY id DESC LIMIT %d), '
|
||||
'devices AS (SELECT scan, count(*) AS devices FROM wifi_device '
|
||||
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan), '
|
||||
@@ -1090,15 +1086,17 @@ def recon_scans_data(limit=50, _timeout=20):
|
||||
'handshakes': r['handshakes']} for r in rows]}
|
||||
|
||||
|
||||
def recon_scan_data(scan_id, _timeout=20, _limit=None):
|
||||
def recon_scan_data(scan_id, _timeout=20, _limit=None, db=None):
|
||||
"""Scan detail with per-AP enrichment (band/vendor/first_seen/last_seen)
|
||||
plus an unassociated count.
|
||||
|
||||
With `_limit`, client rows are capped and the unassociated rows collapse to
|
||||
a count. The live survey view never renders the full client list, and the
|
||||
a count. The live Scanning view never renders the full client list, and the
|
||||
unassociated/device branches dominate query time on slow recon dbs, so the
|
||||
bounded mode keeps the 2s survey poll cheap.
|
||||
bounded mode keeps the poll and the report downloads cheap.
|
||||
"""
|
||||
if db is None:
|
||||
db = RECON_DB
|
||||
if _limit:
|
||||
sql = ("WITH dev AS (SELECT hash, time, mac, signal, freq, packets "
|
||||
"FROM wifi_device WHERE scan = %d LIMIT %d) "
|
||||
@@ -1116,12 +1114,12 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None):
|
||||
"UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
|
||||
"NULL, NULL, NULL, NULL, NULL, stahash, aphash "
|
||||
"FROM handshake WHERE scan = %d" % (scan_id, _limit, scan_id, scan_id, scan_id))
|
||||
rows = _db_rows(RECON_DB, sql, timeout=_timeout)
|
||||
cnt = _db_rows(RECON_DB, 'SELECT count(*) AS c FROM ssid WHERE scan = %d AND type = 4'
|
||||
rows = _db_rows(db, sql, timeout=_timeout)
|
||||
cnt = _db_rows(db, 'SELECT count(*) AS c FROM ssid WHERE scan = %d AND type = 4'
|
||||
% scan_id, timeout=_timeout)
|
||||
unassociated = cnt[0]['c'] if cnt else 0
|
||||
else:
|
||||
rows = _db_rows(RECON_DB,
|
||||
rows = _db_rows(db,
|
||||
"SELECT 'scan' AS kind, id AS row_id, time, name, "
|
||||
"NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
|
||||
"NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
|
||||
@@ -1189,6 +1187,16 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None):
|
||||
|
||||
|
||||
def h_recon_start(ctx):
|
||||
# Serialize starts: the firmware has no abort for a timed scan, so a
|
||||
# second /recon/new while one is running just stacks another empty scan
|
||||
# (the 3s-apart rows we saw). Refuse instead of piling on.
|
||||
scanning, remaining = _recon_scan_snapshot()
|
||||
if scanning:
|
||||
return 409, {
|
||||
'error': 'A recon scan is already running and cannot be '
|
||||
'interrupted; it will finish automatically.',
|
||||
'scan_remaining': remaining,
|
||||
}
|
||||
scan_time = (getattr(ctx, 'body', None) or {}).get(
|
||||
'scan_time', DEFAULT_RECON_DURATION)
|
||||
try:
|
||||
@@ -1237,15 +1245,42 @@ def _recon_watchdog_tick():
|
||||
|
||||
The Pager has no recon-stop operation. log/recon/stop controls the storage
|
||||
service and leaves recon.db locked, so timed scans must end natively.
|
||||
Also records a survey sample when a survey is active.
|
||||
"""
|
||||
st = _recon_scan_state
|
||||
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']:
|
||||
st['active'] = False
|
||||
|
||||
|
||||
_hopper_cache = {'updated': 0, 'online': None}
|
||||
HOPPER_CACHE_SECONDS = 10.0
|
||||
HOPPER_IFACE = 'wlan2mon'
|
||||
|
||||
|
||||
def _hopper_online():
|
||||
"""Whether the fast-hopping radio (wlan2mon per pineapd config) exists.
|
||||
|
||||
iwinfo is a subprocess, so the answer is cached for a few seconds; the
|
||||
UI polls status every 5s.
|
||||
"""
|
||||
now = time.time()
|
||||
if now - _hopper_cache['updated'] < HOPPER_CACHE_SECONDS:
|
||||
return _hopper_cache['online']
|
||||
try:
|
||||
_survey_sample()
|
||||
online = HOPPER_IFACE in wifi_ifaces()
|
||||
except Exception:
|
||||
pass
|
||||
online = None
|
||||
_hopper_cache.update({'updated': now, 'online': online})
|
||||
return online
|
||||
|
||||
|
||||
def _recon_history_reset():
|
||||
"""True when pineapd rotated the live db (error-*-recon.db exists)."""
|
||||
directory = os.path.dirname(RECON_DB)
|
||||
try:
|
||||
names = os.listdir(directory)
|
||||
except OSError:
|
||||
return False
|
||||
return any(n.startswith('error-') and n.endswith('recon.db') for n in names)
|
||||
|
||||
|
||||
def h_recon_status(ctx):
|
||||
@@ -1271,7 +1306,9 @@ def h_recon_status(ctx):
|
||||
last_activity = last
|
||||
return 200, {'last_scan': last, 'last_activity': last_activity,
|
||||
'active': last_activity is not None and int(time.time()) - last_activity < 300,
|
||||
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale}
|
||||
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale,
|
||||
'hopper_online': _hopper_online(),
|
||||
'history_reset': _recon_history_reset()}
|
||||
|
||||
|
||||
def _db_write(db, sql):
|
||||
@@ -1369,13 +1406,20 @@ def h_recon_scans(ctx):
|
||||
|
||||
def h_recon_scan_detail(ctx):
|
||||
scan_id = int(ctx.args[0])
|
||||
data = recon_scan_data(scan_id)
|
||||
# Bounded + retried: the live view polls this every few seconds, and an
|
||||
# unbounded read across ssid/wifi_device can hold the DB long enough to
|
||||
# make pineapd rotate it (SQLITE_BUSY -> error-*-recon.db).
|
||||
try:
|
||||
data = _recon_read_retry(lambda: recon_scan_data(
|
||||
scan_id, _timeout=15, _limit=300))
|
||||
except RuntimeError:
|
||||
return 503, {'error': 'recon database is temporarily unavailable'}
|
||||
if data is None:
|
||||
return 404, {'error': 'scan not found'}
|
||||
return 200, data
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recon report helpers (CSV / HTML) for scan and survey downloads.
|
||||
# Recon report helpers (CSV / HTML) for scan downloads.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fmt_ts(ts):
|
||||
@@ -1406,16 +1450,6 @@ def _aps_csv(data):
|
||||
return '\r\n'.join(out) + '\r\n'
|
||||
|
||||
|
||||
def _survey_aps_csv(detail):
|
||||
out = ['bssid,ssid,band,channel,min_dbm,avg_dbm,max_dbm,samples,first_seen,last_seen']
|
||||
for a in (detail or {}).get('aps') or []:
|
||||
out.append(','.join(_csv_escape(x) for x in [
|
||||
a.get('bssid'), a.get('ssid'), a.get('band'), a.get('channel'),
|
||||
a.get('min'), a.get('avg'), a.get('max'), a.get('samples'),
|
||||
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))]))
|
||||
return '\r\n'.join(out) + '\r\n'
|
||||
|
||||
|
||||
def _esc_html(v):
|
||||
if v is None:
|
||||
return ''
|
||||
@@ -1425,42 +1459,97 @@ def _esc_html(v):
|
||||
|
||||
REPORT_CSS = """
|
||||
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; margin: 24px; color: #222; background: #fff; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.sub { color: #666; margin-bottom: 16px; }
|
||||
h1 { font-size: 22px; margin: 0 0 2px; }
|
||||
.sub { color: #666; margin-bottom: 2px; }
|
||||
.meta { color: #888; font-size: 13px; margin: 4px 0 12px; }
|
||||
.stat-cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0 4px; }
|
||||
.stat-card { border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 16px; min-width: 108px; background: #fafafa; }
|
||||
.stat-value { font-size: 26px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #888; }
|
||||
h2 { font-size: 15px; margin: 20px 0 8px; border-bottom: 2px solid #1a237e; padding-bottom: 4px; color: #1a237e; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
|
||||
th { background: #f4f4f4; }
|
||||
tr:nth-child(even) td { background: #fafafa; }
|
||||
.stats { margin: 12px 0; font-size: 13px; color: #333; }
|
||||
.sig-strong { color: #2e7d32; font-weight: 600; white-space: nowrap; }
|
||||
.sig-good { color: #f9a825; font-weight: 600; white-space: nowrap; }
|
||||
.sig-weak { color: #ef6c00; font-weight: 600; white-space: nowrap; }
|
||||
.sig-dead { color: #c62828; font-weight: 600; white-space: nowrap; }
|
||||
.empty { color: #888; font-style: italic; }
|
||||
@media print { body { margin: 12px; } .stat-card { border-color: #ccc; } }
|
||||
"""
|
||||
|
||||
|
||||
def _html_table(headers, rows):
|
||||
def _html_table(headers, rows, raw_columns=None):
|
||||
"""Render an HTML table. Cells are HTML-escaped unless their column index
|
||||
is listed in raw_columns (caller-provided safe markup, e.g. colored
|
||||
signal spans)."""
|
||||
raw = set(raw_columns or ())
|
||||
out = ['<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))
|
||||
for i, cell in enumerate(row):
|
||||
out.append('<td>%s</td>' % (cell if i in raw else _esc_html(cell)))
|
||||
out.append('</tr>')
|
||||
out.append('</tbody></table>')
|
||||
return ''.join(out)
|
||||
|
||||
|
||||
def _html_doc(title, subtitle, body_html, stats=None):
|
||||
def _html_doc(title, subtitle, body_html, stats=None, meta=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))
|
||||
for line in (meta or []):
|
||||
parts.append('<div class="meta">%s</div>' % _esc_html(line))
|
||||
if stats:
|
||||
parts.append('<div class="stat-cards">')
|
||||
for label, value in stats:
|
||||
parts.append('<div class="stat-card"><div class="stat-value">%s</div>'
|
||||
'<div class="stat-label">%s</div></div>'
|
||||
% (_esc_html(value), _esc_html(label)))
|
||||
parts.append('</div>')
|
||||
parts.append(body_html)
|
||||
parts.append('</body></html>')
|
||||
return ''.join(parts)
|
||||
|
||||
|
||||
def _signal_html(dbm):
|
||||
"""Color-coded dBm cell matching the UI thresholds."""
|
||||
if dbm is None:
|
||||
return '<span class="sig-dead">--</span>'
|
||||
if dbm >= -50:
|
||||
cls = 'sig-strong'
|
||||
elif dbm >= -67:
|
||||
cls = 'sig-good'
|
||||
elif dbm >= -80:
|
||||
cls = 'sig-weak'
|
||||
else:
|
||||
cls = 'sig-dead'
|
||||
return '<span class="%s">%d dBm</span>' % (cls, dbm)
|
||||
|
||||
|
||||
def _enc_bucket(enc):
|
||||
"""Encryption display string -> coarse bucket (mirrors the UI chips)."""
|
||||
s = (enc or '').strip()
|
||||
if not s or s == 'Open':
|
||||
return 'Open'
|
||||
if 'Enterprise' in s:
|
||||
return 'Enterprise'
|
||||
if 'WEP' in s:
|
||||
return 'WEP'
|
||||
if 'WPA2' in s:
|
||||
return 'WPA2'
|
||||
if 'WPA3' in s:
|
||||
return 'WPA3'
|
||||
if 'WPA' in s:
|
||||
return 'WPA'
|
||||
return 'Unknown'
|
||||
|
||||
|
||||
def _recon_read_retry(fn, attempts=3, pause=1.0):
|
||||
"""Retry a recon.db read briefly; pineapd's write bursts hold the DB
|
||||
exclusive lock and reads can time out mid-burst."""
|
||||
@@ -1474,8 +1563,10 @@ def _recon_read_retry(fn, attempts=3, pause=1.0):
|
||||
raise last
|
||||
|
||||
|
||||
def _scan_client_count(scan_id, _timeout=12):
|
||||
rows = _db_rows(RECON_DB,
|
||||
def _scan_client_count(scan_id, _timeout=12, db=None):
|
||||
if db is None:
|
||||
db = RECON_DB
|
||||
rows = _db_rows(db,
|
||||
"SELECT count(*) AS c FROM wifi_device w WHERE w.scan = %d "
|
||||
"AND w.mac NOT IN (SELECT DISTINCT bssid FROM ssid "
|
||||
"WHERE scan = %d AND type = 8 AND bssid IS NOT NULL)"
|
||||
@@ -1495,6 +1586,79 @@ def h_recon_scan_download_csv(ctx):
|
||||
'scan-%d.csv' % scan_id)
|
||||
|
||||
|
||||
def _recon_html_download(scan_id, data, client_count, archive=None):
|
||||
"""Shared HTML report body for live and archived scans.
|
||||
|
||||
GPS metadata is only attached for live scans (an archive's data is
|
||||
historical, so a current fix would be misleading).
|
||||
"""
|
||||
aps = data.get('aps') or []
|
||||
scan = data.get('scan') or {}
|
||||
meta = []
|
||||
if archive is None:
|
||||
try:
|
||||
gps = _gps_status_data()
|
||||
if gps.get('lock'):
|
||||
meta.append('GPS: %.5f, %.5f%s' % (
|
||||
gps.get('lat') or 0, gps.get('lon') or 0,
|
||||
(' \u00b7 %d sats' % gps.get('satellites'))
|
||||
if gps.get('satellites') else ''))
|
||||
except Exception:
|
||||
pass
|
||||
stats = [('Access Points', len(aps)),
|
||||
('Clients', client_count),
|
||||
('Handshakes', len(data.get('handshakes') or [])),
|
||||
('Unassociated', data.get('unassociated') or 0)]
|
||||
body_parts = []
|
||||
# Band and encryption breakdowns.
|
||||
band_counts = {}
|
||||
enc_counts = {}
|
||||
for a in aps:
|
||||
band_counts[a.get('band') or '--'] = band_counts.get(a.get('band') or '--', 0) + 1
|
||||
b = _enc_bucket(a.get('encryption'))
|
||||
enc_counts[b] = enc_counts.get(b, 0) + 1
|
||||
body_parts.append('<h2>Band Breakdown</h2>')
|
||||
body_parts.append(_html_table(['Band', 'Access Points'], sorted(
|
||||
band_counts.items(), key=lambda kv: kv[1], reverse=True)))
|
||||
body_parts.append('<h2>Encryption Breakdown</h2>')
|
||||
body_parts.append(_html_table(['Encryption', 'Access Points'], sorted(
|
||||
enc_counts.items(), key=lambda kv: kv[1], reverse=True)))
|
||||
# Channel occupancy (count per channel, split by band).
|
||||
chan_counts = {}
|
||||
for a in aps:
|
||||
if a.get('channel') is None:
|
||||
continue
|
||||
key = (a.get('band') or '--', a.get('channel'))
|
||||
chan_counts[key] = chan_counts.get(key, 0) + 1
|
||||
body_parts.append('<h2>Channel Occupancy</h2>')
|
||||
if chan_counts:
|
||||
chan_rows = [[band + ' GHz', ch, n] for (band, ch), n in sorted(
|
||||
chan_counts.items(), key=lambda kv: (kv[0][0], kv[0][1]))]
|
||||
body_parts.append(_html_table(['Band', 'Channel', 'Access Points'], chan_rows))
|
||||
else:
|
||||
body_parts.append('<p class="empty">No access points with a known channel.</p>')
|
||||
# AP table with color-coded signal.
|
||||
ap_rows = []
|
||||
for a in aps:
|
||||
ap_rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
||||
a.get('band') or '--',
|
||||
a.get('channel') if a.get('channel') is not None else '--',
|
||||
_signal_html(a.get('signal')),
|
||||
a.get('encryption') or '--',
|
||||
a.get('vendor') or '--',
|
||||
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
|
||||
body_parts.append('<h2>Access Points</h2>')
|
||||
body_parts.append(_html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal',
|
||||
'Encryption', 'Vendor', 'First seen', 'Last seen'],
|
||||
ap_rows, raw_columns=(4,)))
|
||||
subtitle = ('Pager recon capture report (archived history)'
|
||||
if archive else 'Pager recon capture report')
|
||||
return Download(_html_doc('Scan #%d' % scan.get('id'),
|
||||
subtitle, '\n'.join(body_parts),
|
||||
stats=stats, meta=meta).encode('utf-8'),
|
||||
'text/html', 'scan-%d.html' % scan_id)
|
||||
|
||||
|
||||
def h_recon_scan_download_html(ctx):
|
||||
scan_id = int(ctx.args[0])
|
||||
try:
|
||||
@@ -1504,24 +1668,153 @@ def h_recon_scan_download_html(ctx):
|
||||
return 503, {'error': 'recon database is temporarily unavailable'}
|
||||
if data is None:
|
||||
return 404, {'error': 'scan not found'}
|
||||
rows = []
|
||||
for a in data.get('aps') or []:
|
||||
rows.append([a.get('ssid') or '(hidden)', a.get('bssid'), a.get('band'),
|
||||
a.get('channel'), a.get('signal'), a.get('encryption'),
|
||||
a.get('vendor'), _fmt_ts(a.get('first_seen')),
|
||||
_fmt_ts(a.get('last_seen'))])
|
||||
scan = data.get('scan') or {}
|
||||
stats = [('Started', _fmt_ts(scan.get('time'))),
|
||||
('Access points', len(data.get('aps') or [])),
|
||||
('Clients', client_count),
|
||||
('Handshakes', len(data.get('handshakes') or [])),
|
||||
('Unassociated', data.get('unassociated') or 0)]
|
||||
body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal', 'Encryption',
|
||||
'Vendor', 'First seen', 'Last seen'], rows)
|
||||
return 200, Download(_html_doc('Scan #%d' % scan.get('id'),
|
||||
'Pager recon capture report', body,
|
||||
stats=stats).encode('utf-8'),
|
||||
'text/html', 'scan-%d.html' % scan_id)
|
||||
return 200, _recon_html_download(scan_id, data, client_count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recon archives: read-only history from pineapd-rotated databases.
|
||||
# pineapd renames recon.db to error-<ts>-recon.db when an INSERT hits
|
||||
# SQLITE_BUSY, then starts a fresh database at scan 1. These endpoints expose
|
||||
# the rotated files as read-only history. They never write and never change
|
||||
# the live RECON_DB.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _recon_archive_path(archive_id):
|
||||
"""Resolve a URL-encoded archive id (a rotated db filename) to an absolute
|
||||
path, or None if it is not a real archive in the recon directory."""
|
||||
if not archive_id:
|
||||
return None
|
||||
base = os.path.basename(archive_id)
|
||||
if base != archive_id or '/' in archive_id or '\\' in archive_id:
|
||||
return None
|
||||
if not (base.startswith('error-') or base.startswith('diagnostic-')):
|
||||
return None
|
||||
if not base.endswith('recon.db'):
|
||||
return None
|
||||
path = os.path.join(os.path.dirname(RECON_DB), base)
|
||||
return path if os.path.isfile(path) else None
|
||||
|
||||
|
||||
_recon_archives_cache = {'dir': None, 'updated': 0, 'data': None}
|
||||
RECON_ARCHIVES_CACHE_SECONDS = 15.0
|
||||
|
||||
|
||||
def recon_archives_data():
|
||||
now = time.time()
|
||||
directory = os.path.dirname(RECON_DB)
|
||||
cache = _recon_archives_cache
|
||||
if (cache['dir'] == directory and cache['data'] is not None
|
||||
and now - cache['updated'] < RECON_ARCHIVES_CACHE_SECONDS):
|
||||
return cache['data']
|
||||
archives = []
|
||||
try:
|
||||
names = sorted(os.listdir(directory))
|
||||
except OSError:
|
||||
return {'archives': []}
|
||||
for name in names:
|
||||
if not (name.startswith('error-') or name.startswith('diagnostic-')):
|
||||
continue
|
||||
if not name.endswith('recon.db'):
|
||||
continue
|
||||
path = os.path.join(directory, name)
|
||||
try:
|
||||
mtime = int(os.path.getmtime(path))
|
||||
except OSError:
|
||||
mtime = None
|
||||
scans = []
|
||||
try:
|
||||
scans = recon_scans_data(limit=500, _timeout=15, db=path).get('scans') or []
|
||||
except RuntimeError:
|
||||
pass
|
||||
lo = hi = n = None
|
||||
if scans:
|
||||
lo = min(s['id'] for s in scans)
|
||||
hi = max(s['id'] for s in scans)
|
||||
n = len(scans)
|
||||
archives.append({'id': name, 'label': name, 'mtime': mtime,
|
||||
'min_id': lo, 'max_id': hi, 'scans_count': n,
|
||||
'scans': scans})
|
||||
data = {'archives': archives}
|
||||
cache.update({'dir': directory, 'updated': time.time(), 'data': data})
|
||||
return data
|
||||
|
||||
|
||||
def h_recon_archives(ctx):
|
||||
return 200, recon_archives_data()
|
||||
|
||||
|
||||
def h_recon_archive_scans(ctx):
|
||||
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
||||
if path is None:
|
||||
return 404, {'error': 'archive not found'}
|
||||
try:
|
||||
data = recon_scans_data(limit=500, db=path)
|
||||
except RuntimeError:
|
||||
return 503, {'error': 'archive database is temporarily unavailable'}
|
||||
return 200, dict(data, archive=ctx.args[0])
|
||||
|
||||
|
||||
def h_recon_archive_scan_detail(ctx):
|
||||
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
||||
if path is None:
|
||||
return 404, {'error': 'archive not found'}
|
||||
scan_id = int(ctx.args[1])
|
||||
try:
|
||||
data = _recon_read_retry(lambda: recon_scan_data(
|
||||
scan_id, _timeout=15, _limit=300, db=path))
|
||||
except RuntimeError:
|
||||
return 503, {'error': 'archive database is temporarily unavailable'}
|
||||
if data is None:
|
||||
return 404, {'error': 'scan not found in archive'}
|
||||
return 200, data
|
||||
|
||||
|
||||
def h_recon_archive_scan_download(ctx):
|
||||
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
||||
if path is None:
|
||||
return 404, {'error': 'archive not found'}
|
||||
scan_id = int(ctx.args[1])
|
||||
try:
|
||||
data = _recon_read_retry(lambda: recon_scan_data(
|
||||
scan_id, _timeout=15, _limit=300, db=path))
|
||||
except RuntimeError:
|
||||
return 503, {'error': 'archive database is temporarily unavailable'}
|
||||
if data is None:
|
||||
return 404, {'error': 'scan not found in archive'}
|
||||
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
|
||||
'application/json', 'scan-%d.json' % scan_id)
|
||||
|
||||
|
||||
def h_recon_archive_scan_download_csv(ctx):
|
||||
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
||||
if path is None:
|
||||
return 404, {'error': 'archive not found'}
|
||||
scan_id = int(ctx.args[1])
|
||||
try:
|
||||
data = _recon_read_retry(lambda: recon_scan_data(
|
||||
scan_id, _timeout=15, _limit=300, db=path))
|
||||
except RuntimeError:
|
||||
return 503, {'error': 'archive database is temporarily unavailable'}
|
||||
if data is None:
|
||||
return 404, {'error': 'scan not found in archive'}
|
||||
return 200, Download(_aps_csv(data).encode('utf-8'), 'text/csv',
|
||||
'scan-%d.csv' % scan_id)
|
||||
|
||||
|
||||
def h_recon_archive_scan_download_html(ctx):
|
||||
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
||||
if path is None:
|
||||
return 404, {'error': 'archive not found'}
|
||||
scan_id = int(ctx.args[1])
|
||||
try:
|
||||
data = _recon_read_retry(lambda: recon_scan_data(
|
||||
scan_id, _timeout=15, _limit=300, db=path))
|
||||
client_count = _scan_client_count(scan_id, _timeout=12, db=path)
|
||||
except RuntimeError:
|
||||
return 503, {'error': 'archive database is temporarily unavailable'}
|
||||
if data is None:
|
||||
return 404, {'error': 'scan not found in archive'}
|
||||
return 200, _recon_html_download(scan_id, data, client_count, archive=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1640,7 +1933,9 @@ def _gps_status_data_nocache():
|
||||
'satellites': None, 'gpsd_running': running,
|
||||
'updated': None, 'wigle': _wigle_config().get('logwigle', False)}
|
||||
fix = _gps_from_gpspipe() if running else None
|
||||
if fix is None:
|
||||
# GPS_GET through the daemon can block for seconds when gpsd is down;
|
||||
# only fall back to it while gpsd is actually running.
|
||||
if fix is None and running:
|
||||
fix = _gps_from_hak5cmd()
|
||||
if fix:
|
||||
data.update(fix)
|
||||
@@ -1765,290 +2060,6 @@ def h_recon_wigle(ctx):
|
||||
return 200, resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Surveys: JSONL overlay on the device (meta line + one 'sample' line per
|
||||
# tick of the always-on recon). The recon.db itself is never modified.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _survey_path(sid):
|
||||
return _safe_join(SURVEY_DIR, '%s.jsonl' % sid)
|
||||
|
||||
|
||||
def _survey_slug(name):
|
||||
return re.sub(r'[^A-Za-z0-9]+', '-', (name or '').strip()).strip('-')
|
||||
|
||||
|
||||
def _survey_recording_state():
|
||||
st = _survey_state
|
||||
if not st['active']:
|
||||
return None
|
||||
return {'active': True, 'id': st['id'], 'name': st['name'],
|
||||
'started': st['started'], 'samples': st['samples']}
|
||||
|
||||
|
||||
def _survey_sample():
|
||||
with _survey_lock:
|
||||
st = _survey_state
|
||||
if not st['active'] or not st['path']:
|
||||
return
|
||||
now = time.time()
|
||||
if now - st['last_sample'] < SURVEY_SAMPLE_INTERVAL:
|
||||
return
|
||||
if st['samples'] >= SURVEY_MAX_SAMPLES:
|
||||
st['active'] = False
|
||||
return
|
||||
try:
|
||||
scans = recon_scans_data(1, _timeout=6).get('scans') or []
|
||||
detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300) if scans else None
|
||||
gps = _gps_status_data()
|
||||
sample = {'t': int(now),
|
||||
'scan': (detail or {}).get('scan'),
|
||||
'aps': (detail or {}).get('aps') or [],
|
||||
'clients': (detail or {}).get('clients') or [],
|
||||
'handshakes': (detail or {}).get('handshakes') or [],
|
||||
'unassociated': (detail or {}).get('unassociated') or 0,
|
||||
'gps': {'fix': gps.get('fix'), 'lat': gps.get('lat'),
|
||||
'lon': gps.get('lon'),
|
||||
'satellites': gps.get('satellites')}}
|
||||
with open(st['path'], 'a') as fh:
|
||||
fh.write(json.dumps({'sample': sample}) + '\n')
|
||||
st['samples'] += 1
|
||||
st['last_sample'] = now
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def h_recon_survey_start(ctx):
|
||||
with _survey_lock:
|
||||
if _survey_state['active']:
|
||||
return 409, {'error': 'a survey is already recording'}
|
||||
name = ((getattr(ctx, 'body', None) or {}).get('name') or '').strip()
|
||||
sid = time.strftime('%Y%m%d-%H%M%S')
|
||||
slug = _survey_slug(name)
|
||||
if slug:
|
||||
sid += '-' + slug
|
||||
path = _survey_path(sid)
|
||||
if path is None:
|
||||
return 500, {'error': 'invalid survey id'}
|
||||
try:
|
||||
os.makedirs(SURVEY_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
return 500, {'error': 'cannot create survey directory'}
|
||||
meta = {'id': sid, 'name': name, 'started': int(time.time()),
|
||||
'interval': SURVEY_SAMPLE_INTERVAL, 'max_samples': SURVEY_MAX_SAMPLES}
|
||||
try:
|
||||
with open(path, 'w') as fh:
|
||||
fh.write(json.dumps({'meta': meta}) + '\n')
|
||||
except OSError:
|
||||
return 500, {'error': 'cannot write survey file'}
|
||||
_survey_state.update({'active': True, 'id': sid, 'name': name, 'path': path,
|
||||
'started': meta['started'], 'samples': 0,
|
||||
'last_sample': 0})
|
||||
return 200, {'ok': True, 'id': sid, 'started': meta['started']}
|
||||
|
||||
|
||||
def h_recon_survey_stop(ctx):
|
||||
with _survey_lock:
|
||||
st = dict(_survey_state)
|
||||
if st['active']:
|
||||
_survey_state['active'] = False
|
||||
return 200, {'ok': True, 'samples': st['samples'], 'id': st['id']}
|
||||
|
||||
|
||||
def h_recon_survey_live(ctx):
|
||||
detail = None
|
||||
try:
|
||||
scans = recon_scans_data(1, _timeout=6).get('scans') or []
|
||||
if scans:
|
||||
detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300)
|
||||
except RuntimeError:
|
||||
pass
|
||||
try:
|
||||
gps = _gps_status_data()
|
||||
except Exception:
|
||||
gps = {}
|
||||
return 200, {'scan': (detail or {}).get('scan'),
|
||||
'aps': (detail or {}).get('aps') or [],
|
||||
'clients': (detail or {}).get('clients') or [],
|
||||
'handshakes': (detail or {}).get('handshakes') or [],
|
||||
'unassociated': (detail or {}).get('unassociated') or 0,
|
||||
'gps': gps,
|
||||
'recording': _survey_recording_state()}
|
||||
|
||||
|
||||
def recon_surveys_data():
|
||||
surveys = []
|
||||
if os.path.isdir(SURVEY_DIR):
|
||||
try:
|
||||
names = sorted(os.listdir(SURVEY_DIR), reverse=True)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
if not name.endswith('.jsonl'):
|
||||
continue
|
||||
path = os.path.join(SURVEY_DIR, name)
|
||||
sid = name[:-6]
|
||||
meta = None
|
||||
samples = 0
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
with open(path, 'r', errors='replace') as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if 'meta' in obj:
|
||||
meta = obj['meta']
|
||||
elif 'sample' in obj:
|
||||
samples += 1
|
||||
except OSError:
|
||||
continue
|
||||
if meta is None:
|
||||
continue
|
||||
surveys.append({'id': sid, 'name': meta.get('name') or sid,
|
||||
'started': meta.get('started'),
|
||||
'samples': samples, 'size': size})
|
||||
return {'surveys': surveys}
|
||||
|
||||
|
||||
def h_recon_surveys(ctx):
|
||||
try:
|
||||
return 200, recon_surveys_data()
|
||||
except Exception:
|
||||
return 200, {'surveys': []}
|
||||
|
||||
|
||||
def recon_survey_data(sid):
|
||||
path = _survey_path(sid)
|
||||
if path is None or not os.path.isfile(path):
|
||||
return None
|
||||
agg = {}
|
||||
order = []
|
||||
gps_fixes = []
|
||||
try:
|
||||
with open(path, 'r', errors='replace') as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
s = obj.get('sample')
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
g = s.get('gps') or {}
|
||||
if g.get('lat') is not None and g.get('lon') is not None:
|
||||
gps_fixes.append({'t': s.get('t'), 'lat': g['lat'],
|
||||
'lon': g['lon']})
|
||||
for a in s.get('aps') or []:
|
||||
bssid = a.get('bssid')
|
||||
if not bssid:
|
||||
continue
|
||||
entry = agg.get(bssid)
|
||||
if entry is None:
|
||||
entry = {'ssid': a.get('ssid'), 'bssid': bssid,
|
||||
'band': a.get('band'), 'channel': a.get('channel'),
|
||||
'min': None, 'max': None, 'sum': 0, 'count': 0,
|
||||
'first_seen': None, 'last_seen': None}
|
||||
agg[bssid] = entry
|
||||
order.append(bssid)
|
||||
sig = a.get('signal')
|
||||
if sig is not None:
|
||||
entry['min'] = sig if entry['min'] is None else min(entry['min'], sig)
|
||||
entry['max'] = sig if entry['max'] is None else max(entry['max'], sig)
|
||||
entry['sum'] += sig
|
||||
entry['count'] += 1
|
||||
t = s.get('t')
|
||||
if t:
|
||||
entry['first_seen'] = (t if entry['first_seen'] is None
|
||||
else min(entry['first_seen'], t))
|
||||
entry['last_seen'] = (t if entry['last_seen'] is None
|
||||
else max(entry['last_seen'], t))
|
||||
except OSError:
|
||||
return None
|
||||
aps = []
|
||||
for bssid in order:
|
||||
e = agg[bssid]
|
||||
aps.append({'ssid': e['ssid'], 'bssid': e['bssid'], 'band': e['band'],
|
||||
'channel': e['channel'], 'min': e['min'],
|
||||
'avg': round(e['sum'] / e['count']) if e['count'] else None,
|
||||
'max': e['max'], 'samples': e['count'],
|
||||
'first_seen': e['first_seen'], 'last_seen': e['last_seen']})
|
||||
aps.sort(key=lambda a: a['avg'] if a['avg'] is not None else 0)
|
||||
return {'id': sid, 'aps': aps, 'gps_fixes': len(gps_fixes),
|
||||
'first_gps': gps_fixes[0] if gps_fixes else None,
|
||||
'last_gps': gps_fixes[-1] if gps_fixes else None}
|
||||
|
||||
|
||||
def h_recon_survey_detail(ctx):
|
||||
sid = ctx.args[0]
|
||||
data = recon_survey_data(sid)
|
||||
if data is None:
|
||||
return 404, {'error': 'survey not found'}
|
||||
return 200, data
|
||||
|
||||
|
||||
def h_recon_survey_download(ctx):
|
||||
sid = ctx.args[0]
|
||||
fmt = ctx.args[1]
|
||||
if fmt == 'json':
|
||||
data = recon_survey_data(sid)
|
||||
if data is None:
|
||||
return 404, {'error': 'survey not found'}
|
||||
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
|
||||
'application/json', 'survey-%s.json' % sid)
|
||||
data = recon_survey_data(sid)
|
||||
if data is None:
|
||||
return 404, {'error': 'survey not found'}
|
||||
if fmt == 'csv':
|
||||
return 200, Download(_survey_aps_csv(data).encode('utf-8'), 'text/csv',
|
||||
'survey-%s.csv' % sid)
|
||||
if fmt == 'html':
|
||||
rows = []
|
||||
for a in data.get('aps') or []:
|
||||
rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
||||
a.get('band'), a.get('channel'), a.get('min'),
|
||||
a.get('avg'), a.get('max'), a.get('samples'),
|
||||
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
|
||||
fixes = data.get('gps_fixes') or 0
|
||||
fg = data.get('first_gps') or {}
|
||||
lg = data.get('last_gps') or {}
|
||||
if fixes:
|
||||
gps = ('%d fixes · first %.5f, %.5f · last %.5f, %.5f'
|
||||
% (fixes, fg.get('lat') or 0, fg.get('lon') or 0,
|
||||
lg.get('lat') or 0, lg.get('lon') or 0))
|
||||
else:
|
||||
gps = 'No GPS fixes during this survey'
|
||||
body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Min', 'Avg', 'Max',
|
||||
'Samples', 'First', 'Last'], rows)
|
||||
return 200, Download(_html_doc('Survey %s' % sid,
|
||||
'AP signal aggregates', body,
|
||||
stats=[('GPS', gps)]).encode('utf-8'),
|
||||
'text/html', 'survey-%s.html' % sid)
|
||||
return 404, {'error': 'unknown format'}
|
||||
|
||||
|
||||
def h_recon_survey_delete(ctx):
|
||||
sid = ctx.args[0]
|
||||
path = _survey_path(sid)
|
||||
if path is None or not os.path.isfile(path):
|
||||
return 404, {'error': 'survey not found'}
|
||||
with _survey_lock:
|
||||
if _survey_state.get('id') == sid and _survey_state['active']:
|
||||
return 409, {'error': 'cannot delete the survey that is recording'}
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
return 500, {'error': 'delete failed'}
|
||||
return 200, {'ok': True}
|
||||
|
||||
|
||||
HS_FILENAME_RE = re.compile(
|
||||
r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
|
||||
r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
|
||||
@@ -3757,13 +3768,12 @@ ROUTER.add('POST', r'/api/recon/gps/configure', h_recon_gps_configure)
|
||||
ROUTER.add('POST', r'/api/recon/wigle', h_recon_wigle)
|
||||
ROUTER.add('GET', r'/api/recon/wigle/files', h_recon_wigle_files)
|
||||
ROUTER.add('GET', r'/api/recon/wigle/files/([^/]+)', h_recon_wigle_file)
|
||||
ROUTER.add('GET', r'/api/recon/survey/live', h_recon_survey_live)
|
||||
ROUTER.add('POST', r'/api/recon/survey/start', h_recon_survey_start)
|
||||
ROUTER.add('POST', r'/api/recon/survey/stop', h_recon_survey_stop)
|
||||
ROUTER.add('GET', r'/api/recon/surveys', h_recon_surveys)
|
||||
ROUTER.add('GET', r'/api/recon/surveys/([^/]+)', h_recon_survey_detail)
|
||||
ROUTER.add('GET', r'/api/recon/surveys/([^/]+)/download/(csv|json|html)', h_recon_survey_download)
|
||||
ROUTER.add('DELETE', r'/api/recon/surveys/([^/]+)', h_recon_survey_delete)
|
||||
ROUTER.add('GET', r'/api/recon/archives', h_recon_archives)
|
||||
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans', h_recon_archive_scans)
|
||||
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)', h_recon_archive_scan_detail)
|
||||
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/json', h_recon_archive_scan_download)
|
||||
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/csv', h_recon_archive_scan_download_csv)
|
||||
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/html', h_recon_archive_scan_download_html)
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
|
||||
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
|
||||
|
||||
@@ -316,6 +316,9 @@ html.dark .muted { color: #bdbdbd; }
|
||||
.icon-btn svg { width: 22px; height: 22px; }
|
||||
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.recon-scan-bar .sel { width: auto; }
|
||||
.recon-scan-status { font-size: 12px; }
|
||||
.recon-scan-status.warn { color: #b26a00; }
|
||||
html.dark .recon-scan-status.warn { color: #ffb74d; }
|
||||
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.recon-table-head h2 { margin: 0; }
|
||||
.recon-search { max-width: 180px; }
|
||||
@@ -372,50 +375,33 @@ html.dark .recon-dbm-bar { background: #333; }
|
||||
.recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
|
||||
|
||||
/* ---- Survey view ---- */
|
||||
.survey-live-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.survey-live-status { font-size: 13px; color: var(--text); }
|
||||
.survey-dur { width: auto; }
|
||||
.survey-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 4px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; }
|
||||
.survey-pill:hover { color: var(--text); border-color: var(--primary); }
|
||||
.survey-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||
html.dark .survey-pill.on { background: #1b3a23; color: #81c784; }
|
||||
.survey-wigle { font-size: 13px; color: var(--text); margin: 0; }
|
||||
.survey-rec-card { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.survey-rec-name { flex: 1 1 220px; max-width: 340px; }
|
||||
.survey-rec-status { font-size: 12px; color: var(--muted); }
|
||||
.survey-rec-status.on { color: #e53935; font-weight: 600; }
|
||||
.survey-filter-row { margin-top: 10px; }
|
||||
.survey-search { width: auto; }
|
||||
.survey-chan-box { margin-top: 4px; }
|
||||
.survey-chan-band { font-size: 12px; font-weight: 600; color: var(--primary); margin: 10px 0 4px; }
|
||||
.survey-chan-row { display: flex; align-items: center; gap: 10px; margin: 3px 0; }
|
||||
.survey-chan-label { width: 46px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.survey-chan-track { flex: 1; height: 14px; border-radius: 3px; background: var(--surface-alt); overflow: hidden; }
|
||||
html.dark .survey-chan-track { background: #333; }
|
||||
.survey-chan-fill { display: block; height: 100%; background: var(--primary); border-radius: 3px; }
|
||||
.survey-chan-count { width: 30px; font-size: 12px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.survey-cmp-hint { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
|
||||
.survey-cmp-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
|
||||
.survey-cmp-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
.survey-cmp-swatch { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||
.survey-cmp-sig { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.survey-cmp-check { display: inline-flex; }
|
||||
.survey-cmp-check input { width: auto; }
|
||||
.survey-discover { margin-top: 10px; }
|
||||
.survey-discover-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.survey-discover-title { font-size: 20px; font-weight: 500; }
|
||||
.survey-discover-readout { font-size: 44px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.survey-discover-sub { color: var(--muted); font-size: 13px; margin: 2px 0 8px; word-break: break-all; }
|
||||
/* ---- Recon compare + selection ---- */
|
||||
.recon-compare-hint { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
|
||||
.recon-compare-empty { margin: 6px 0; }
|
||||
.recon-compare-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
|
||||
.recon-compare-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
|
||||
.recon-compare-swatch { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||
.recon-compare-sig { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.recon-cmp-check { display: inline-flex; }
|
||||
.recon-cmp-check input { width: auto; }
|
||||
.recon-sel-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 2px 0 6px; }
|
||||
.recon-sel-chips.hidden { display: none; }
|
||||
.recon-sel-chip { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--primary); border-radius: 12px; padding: 2px 8px 2px 11px; font-size: 12px; color: var(--text); background: var(--surface-alt); }
|
||||
.recon-sel-chip-x { cursor: pointer; color: var(--muted); font-weight: 700; padding: 0 2px; }
|
||||
.recon-sel-chip-x:hover { color: #e53935; }
|
||||
.recon-sel-clear { padding: 2px 10px; font-size: 12px; }
|
||||
|
||||
/* ---- Recon channel map ---- */
|
||||
.recon-map-sub { font-size: 12px; color: var(--muted); margin: -4px 0 6px; }
|
||||
.recon-map-chips { margin: 0 0 6px; }
|
||||
.recon-chip.disabled { opacity: .45; cursor: default; }
|
||||
.recon-chip.disabled:hover { color: var(--muted); border-color: var(--border); }
|
||||
.recon-map-box { position: relative; }
|
||||
.recon-map-box canvas { display: block; }
|
||||
.recon-map-box .recon-no-data { min-height: 60px; }
|
||||
|
||||
/* ---- Reports view ---- */
|
||||
.survey-detail-hint { font-size: 12px; color: var(--muted); margin-top: 10px; }
|
||||
.survey-detail-row { padding: 8px 10px; border: 1px solid var(--border); border-radius: 3px; margin-top: 6px; cursor: pointer; font-size: 13px; }
|
||||
.survey-detail-row:hover { border-color: var(--primary); }
|
||||
.survey-detail-row.open { border-color: var(--primary); background: var(--surface-alt); }
|
||||
.survey-detail-body { padding: 8px 4px; }
|
||||
.survey-detail-gps { font-size: 12px; color: var(--muted); margin: 6px 0; }
|
||||
.survey-wigle-warn { color: #ef6c00; font-size: 12px; }
|
||||
.wigle-warn { color: #ef6c00; font-size: 12px; }
|
||||
|
||||
/* ---- Mark VII handshakes table + settings dialog ---- */
|
||||
.hs-cell-center { text-align: center; }
|
||||
|
||||
@@ -84,6 +84,10 @@ const App = (() => {
|
||||
function route() {
|
||||
closeToolbarMenus();
|
||||
const hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
|
||||
if (hash === '#/recon/survey') {
|
||||
location.hash = '#/recon';
|
||||
return;
|
||||
}
|
||||
const name = routes[hash];
|
||||
if (currentView && currentView.destroy) currentView.destroy();
|
||||
els.content.innerHTML = '';
|
||||
@@ -396,7 +400,6 @@ const App = (() => {
|
||||
'#/pineap/clients': 'pineap_clients',
|
||||
'#/pineap/filtering': 'pineap_filtering',
|
||||
'#/recon': 'recon',
|
||||
'#/recon/survey': 'recon_survey',
|
||||
'#/recon/reports': 'recon_reports',
|
||||
'#/recon/handshakes': 'recon_handshakes',
|
||||
'#/logging': 'logging',
|
||||
|
||||
@@ -136,5 +136,114 @@ const MiniChart = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
return { draw, doughnut, bar };
|
||||
function chanToMhz(band, ch) {
|
||||
const n = Number(ch);
|
||||
if (band === '2.4') return 2407 + 5 * n;
|
||||
if (band === '5') return 5000 + 5 * n;
|
||||
if (band === '6') return 5950 + 5 * n;
|
||||
return null;
|
||||
}
|
||||
|
||||
function bandRange(band) {
|
||||
if (band === '2.4') return [2400, 2500];
|
||||
if (band === '5') return [5150, 5900];
|
||||
if (band === '6') return [5925, 7125];
|
||||
return null;
|
||||
}
|
||||
|
||||
function hexA(hex, alpha) {
|
||||
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
||||
if (!m) return hex;
|
||||
const n = parseInt(m[1], 16);
|
||||
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
|
||||
}
|
||||
|
||||
// Channel map: each AP is a raised-cosine lobe at its reported center
|
||||
// frequency with the peak at its signal strength. The radio does not report
|
||||
// channel width, so every lobe assumes 20 MHz (half-width +-10 MHz).
|
||||
function channelMap(canvas, aps, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const H = o.height || 180;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = H;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!aps || !aps.length) return;
|
||||
const pts = aps
|
||||
.map((a) => ({
|
||||
freq: a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel),
|
||||
sig: a.signal
|
||||
}))
|
||||
.filter((p) => p.freq != null && p.sig != null);
|
||||
if (!pts.length) return;
|
||||
const def = bandRange(aps[0].band);
|
||||
const lo = Math.min(def ? def[0] : Infinity, ...pts.map((p) => p.freq));
|
||||
const hi = Math.max(def ? def[1] : -Infinity, ...pts.map((p) => p.freq));
|
||||
const fMin = lo - 10, fMax = hi + 10;
|
||||
const padL = 42, padR = 10, padT = 14, padB = 24;
|
||||
const plotW = w - padL - padR, plotH = h - padT - padB;
|
||||
const YMIN = -100, YMAX = -30;
|
||||
const x = (f) => padL + (f - fMin) / (fMax - fMin) * plotW;
|
||||
const y = (dbm) => padT + (1 - (dbm - YMIN) / (YMAX - YMIN)) * plotH;
|
||||
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
for (let dbm = YMIN; dbm <= YMAX; dbm += 10) {
|
||||
const yy = y(dbm);
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(w - padR, yy); ctx.stroke();
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.fillText(String(dbm), padL - 6, yy + 3);
|
||||
}
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
for (let f = Math.ceil(fMin / 20) * 20; f <= fMax; f += 20) {
|
||||
ctx.beginPath(); ctx.moveTo(x(f), padT); ctx.lineTo(x(f), h - padB); ctx.stroke();
|
||||
}
|
||||
const baseY = h - padB;
|
||||
aps.forEach((a) => {
|
||||
const freq = a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel);
|
||||
if (freq == null || a.signal == null) return;
|
||||
const color = a.color || '#1976d2';
|
||||
const cx = x(freq);
|
||||
const topY = y(a.signal);
|
||||
const peakH = Math.max(2, baseY - topY);
|
||||
const half = Math.max(4, (10 / (fMax - fMin)) * plotW);
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i <= 28; i++) {
|
||||
const t = -1 + i / 14;
|
||||
const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t));
|
||||
const px = cx + t * half;
|
||||
const py = baseY - lift * peakH;
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = hexA(color, 0.35);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
let lastLabelX = -Infinity;
|
||||
ctx.textAlign = 'center';
|
||||
aps.forEach((a) => {
|
||||
const freq = a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel);
|
||||
if (freq == null || a.channel == null) return;
|
||||
const cx = x(freq);
|
||||
if (cx - lastLabelX < 34) return;
|
||||
lastLabelX = cx;
|
||||
ctx.strokeStyle = '#999';
|
||||
ctx.beginPath(); ctx.moveTo(cx, h - padB); ctx.lineTo(cx, h - padB + 4); ctx.stroke();
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.fillText('CH ' + a.channel, cx, h - 7);
|
||||
});
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.fillText((aps[0].band || '?') + ' GHz', padL + 4, padT + 2);
|
||||
}
|
||||
|
||||
return { draw, doughnut, bar, channelMap };
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+255
-180
@@ -158,6 +158,10 @@ class FakeSock:
|
||||
|
||||
|
||||
class DaemonSockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# h_recon_start now reads shared scan state; keep these isolated.
|
||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
||||
|
||||
def test_socket_call_posts_json_to_sock(self):
|
||||
server.DAEMON_SOCK = '/tmp/api.sock'
|
||||
fake = FakeSock(b'HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\n{"success":true}')
|
||||
@@ -299,6 +303,19 @@ class ReconScanStateTest(unittest.TestCase):
|
||||
status, data = self._status()
|
||||
self.assertFalse(data['scanning'])
|
||||
|
||||
def test_start_while_scanning_returns_409_without_restart(self):
|
||||
server.time.time = lambda: 1000.0
|
||||
self._start(scan_time=30)
|
||||
calls = []
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True})
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 409)
|
||||
self.assertEqual(data['scan_remaining'], 30)
|
||||
self.assertEqual(calls, [])
|
||||
status, data = self._status()
|
||||
self.assertTrue(data['scanning'])
|
||||
|
||||
def test_watchdog_stops_expired_timed_scan(self):
|
||||
server.time.time = lambda: 1000.0
|
||||
self._start(scan_time=10)
|
||||
@@ -339,6 +356,25 @@ class ReconExtrasTest(unittest.TestCase):
|
||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||
self.assertFalse(data['active'])
|
||||
|
||||
def test_status_includes_hopper_and_history_flags(self):
|
||||
with mock.patch.object(server, '_hopper_online', return_value=False), \
|
||||
mock.patch.object(server, '_recon_history_reset', return_value=True):
|
||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(data['hopper_online'])
|
||||
self.assertTrue(data['history_reset'])
|
||||
|
||||
def test_hopper_online_cached(self):
|
||||
server._hopper_cache.update({'updated': 0, 'online': None})
|
||||
with mock.patch.object(server, 'wifi_ifaces',
|
||||
return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
|
||||
self.assertTrue(server._hopper_online())
|
||||
# Second call within the cache window must not re-run iwinfo.
|
||||
with mock.patch.object(server, 'wifi_ifaces',
|
||||
side_effect=AssertionError('cached, must not call iwinfo')):
|
||||
self.assertTrue(server._hopper_online())
|
||||
server._hopper_cache['updated'] = 0 # let other tests start fresh
|
||||
|
||||
def test_delete_cascades(self):
|
||||
server.recon_delete_scan(1)
|
||||
rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM wifi_device')
|
||||
@@ -637,27 +673,6 @@ class HandshakeRoutesTest(unittest.TestCase):
|
||||
self.assertEqual(os.listdir(self.dir), ['.hidden'])
|
||||
|
||||
|
||||
def make_survey_db():
|
||||
"""Single-scan recon DB so the newest scan carries the AP data."""
|
||||
fd, db = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(db)
|
||||
conn.executescript(SCHEMA)
|
||||
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1786466531, 'pager')")
|
||||
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (1, 1, 'AE77C0EB3141', 1786466531, -71, 2412, 5)")
|
||||
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (2, 1, 'C89E43648080', 1786466532, -76, 5745, 9)")
|
||||
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
|
||||
"VALUES (10, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466532, -76, 5745, 149, 0x400400108)")
|
||||
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
|
||||
"VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)")
|
||||
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
|
||||
"VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)")
|
||||
conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db
|
||||
|
||||
|
||||
class OuiVendorTest(unittest.TestCase):
|
||||
def test_oui_prefix_forms(self):
|
||||
self.assertEqual(server._oui_prefix('C8:9E:43:64:80:80'), 'C89E43')
|
||||
@@ -746,6 +761,30 @@ class ReconEnrichmentTest(unittest.TestCase):
|
||||
self.assertEqual(a['band'], '2.4')
|
||||
self.assertEqual(a['vendor'], 'Google')
|
||||
|
||||
def test_scan_detail_handler_is_bounded_and_retried(self):
|
||||
seen = {}
|
||||
orig = server.recon_scan_data
|
||||
def fake(scan_id, _timeout=20, _limit=None, db=None):
|
||||
seen['limit'] = _limit
|
||||
seen['db'] = db
|
||||
return orig(scan_id, _timeout=_timeout, _limit=_limit, db=db)
|
||||
with mock.patch.object(server, 'recon_scan_data', side_effect=fake), \
|
||||
mock.patch.object(server.time, 'sleep'):
|
||||
status, data = server.h_recon_scan_detail(type('C', (), {'args': ('1',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(seen['limit'], 300)
|
||||
# The live handler relies on the default, which resolves to RECON_DB.
|
||||
self.assertIsNone(seen['db'])
|
||||
self.assertEqual(data['scan']['id'], 1)
|
||||
|
||||
def test_scan_detail_handler_503_on_lock(self):
|
||||
with mock.patch.object(server, 'recon_scan_data',
|
||||
side_effect=RuntimeError('sqlite read failed: locked')), \
|
||||
mock.patch.object(server.time, 'sleep'):
|
||||
status, data = server.h_recon_scan_detail(type('C', (), {'args': ('1',)})())
|
||||
self.assertEqual(status, 503)
|
||||
self.assertIn('temporarily unavailable', data['error'])
|
||||
|
||||
|
||||
class ReconReportTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -769,7 +808,8 @@ class ReconReportTest(unittest.TestCase):
|
||||
self.assertIn('C8:9E:43:64:80:80', text)
|
||||
|
||||
def test_html_download_contains_stats(self):
|
||||
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
|
||||
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload.ctype, 'text/html')
|
||||
self.assertEqual(payload.filename, 'scan-1.html')
|
||||
@@ -777,7 +817,32 @@ class ReconReportTest(unittest.TestCase):
|
||||
self.assertIn('Scan #1', text)
|
||||
self.assertIn('Anderson-5', text)
|
||||
self.assertIn('WPA3 WPA2', text)
|
||||
# stat cards
|
||||
self.assertIn('stat-card', text)
|
||||
self.assertIn('Unassociated', text)
|
||||
# band + encryption breakdowns
|
||||
self.assertIn('Band Breakdown', text)
|
||||
self.assertIn('Encryption Breakdown', text)
|
||||
self.assertIn('WPA2', text)
|
||||
# channel occupancy table
|
||||
self.assertIn('Channel Occupancy', text)
|
||||
self.assertIn('5 GHz', text)
|
||||
self.assertIn('>149<', text)
|
||||
# color-coded signal cells
|
||||
self.assertIn('sig-weak', text)
|
||||
self.assertIn('sig-good', text)
|
||||
self.assertIn('-76 dBm', text)
|
||||
# no GPS line without a fix
|
||||
self.assertNotIn('GPS:', text)
|
||||
|
||||
def test_html_report_includes_gps_when_locked(self):
|
||||
with mock.patch.object(server, '_gps_status_data',
|
||||
return_value={'lock': True, 'lat': 37.7,
|
||||
'lon': -122.4, 'satellites': 8}):
|
||||
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||
text = payload.data.decode('utf-8')
|
||||
self.assertIn('GPS: 37.70000, -122.40000', text)
|
||||
self.assertIn('8 sats', text)
|
||||
|
||||
def test_download_404_for_missing_scan(self):
|
||||
status, payload = server.h_recon_scan_download_csv(self._ctx(('999',)))
|
||||
@@ -795,6 +860,135 @@ class ReconReportTest(unittest.TestCase):
|
||||
self.assertEqual(status, 503)
|
||||
|
||||
|
||||
class ReconArchivesTest(unittest.TestCase):
|
||||
"""Read-only history from pineapd-rotated databases (error-*-recon.db)."""
|
||||
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.dir)
|
||||
self.archive_name = 'error-2026-08-18-19:21:42Z-recon.db'
|
||||
tmp = make_db()
|
||||
self.addCleanup(os.unlink, tmp)
|
||||
server.RECON_DB = os.path.join(self.dir, 'recon.db')
|
||||
shutil.copy(tmp, server.RECON_DB)
|
||||
archive = os.path.join(self.dir, self.archive_name)
|
||||
shutil.copy(tmp, archive)
|
||||
conn = sqlite3.connect(archive)
|
||||
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u3', 1786467000, 'pager')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def tearDown(self):
|
||||
server._recon_archives_cache.update({'dir': None, 'updated': 0, 'data': None})
|
||||
|
||||
def _ctx(self, args=()):
|
||||
return type('C', (), {'args': args, 'body': {}})()
|
||||
|
||||
def test_archives_list_finds_rotated_db(self):
|
||||
data = server.recon_archives_data()
|
||||
self.assertEqual(len(data['archives']), 1)
|
||||
a = data['archives'][0]
|
||||
self.assertEqual(a['id'], self.archive_name)
|
||||
self.assertEqual(a['min_id'], 1)
|
||||
self.assertEqual(a['max_id'], 3)
|
||||
self.assertEqual(a['scans_count'], 3)
|
||||
self.assertEqual([s['id'] for s in a['scans']], [3, 2, 1])
|
||||
|
||||
def test_archives_list_is_cached_per_directory(self):
|
||||
orig = server.recon_scans_data
|
||||
calls = []
|
||||
def fake(limit=50, _timeout=20, db=None):
|
||||
calls.append(db)
|
||||
return orig(limit=limit, _timeout=_timeout, db=db)
|
||||
with mock.patch.object(server, 'recon_scans_data', side_effect=fake):
|
||||
server.recon_archives_data()
|
||||
server.recon_archives_data()
|
||||
# Second call within the cache window must not re-scan the archives.
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertTrue(calls[0].endswith(self.archive_name))
|
||||
|
||||
def test_archive_path_rejects_traversal(self):
|
||||
self.assertIsNone(server._recon_archive_path('..%2F..%2Fetc%2Fpasswd'))
|
||||
self.assertIsNone(server._recon_archive_path('../etc/passwd'))
|
||||
self.assertIsNone(server._recon_archive_path('error-x-recon.db/../evil'))
|
||||
self.assertIsNone(server._recon_archive_path('random.db'))
|
||||
self.assertIsNone(server._recon_archive_path(''))
|
||||
self.assertIsNotNone(server._recon_archive_path(self.archive_name))
|
||||
|
||||
def test_archive_scan_detail_reads_archive_not_live(self):
|
||||
status, data = server.h_recon_archive_scan_detail(
|
||||
self._ctx((self.archive_name, '3')))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['scan']['id'], 3)
|
||||
# The live db has no scan 3; the archive must be the source.
|
||||
self.assertIsNone(server.recon_scan_data(3))
|
||||
|
||||
def test_archive_scan_detail_is_bounded(self):
|
||||
seen = {}
|
||||
orig = server.recon_scan_data
|
||||
def fake(scan_id, _timeout=20, _limit=None, db=None):
|
||||
seen.update({'limit': _limit, 'db': db})
|
||||
return orig(scan_id, _timeout=_timeout, _limit=_limit, db=db)
|
||||
with mock.patch.object(server, 'recon_scan_data', side_effect=fake), \
|
||||
mock.patch.object(server.time, 'sleep'):
|
||||
status, data = server.h_recon_archive_scan_detail(
|
||||
self._ctx((self.archive_name, '1')))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(seen['limit'], 300)
|
||||
self.assertTrue(seen['db'].endswith(self.archive_name))
|
||||
|
||||
def test_archive_scans_list(self):
|
||||
status, data = server.h_recon_archive_scans(self._ctx((self.archive_name,)))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual([s['id'] for s in data['scans']], [3, 2, 1])
|
||||
|
||||
def test_archive_handlers_404_for_missing(self):
|
||||
status, data = server.h_recon_archive_scans(self._ctx(('nope.db',)))
|
||||
self.assertEqual(status, 404)
|
||||
status, data = server.h_recon_archive_scan_detail(self._ctx(('nope.db', '1')))
|
||||
self.assertEqual(status, 404)
|
||||
status, data = server.h_recon_archive_scan_detail(
|
||||
self._ctx((self.archive_name, '999')))
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_archive_downloads(self):
|
||||
status, payload = server.h_recon_archive_scan_download(
|
||||
self._ctx((self.archive_name, '1')))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload.filename, 'scan-1.json')
|
||||
self.assertIn(b'Anderson-5', payload.data)
|
||||
status, payload = server.h_recon_archive_scan_download_csv(
|
||||
self._ctx((self.archive_name, '1')))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload.ctype, 'text/csv')
|
||||
self.assertIn(b'C8:9E:43:64:80:80', payload.data)
|
||||
status, payload = server.h_recon_archive_scan_download_html(
|
||||
self._ctx((self.archive_name, '1')))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload.ctype, 'text/html')
|
||||
text = payload.data.decode('utf-8')
|
||||
self.assertIn('archived history', text)
|
||||
self.assertIn('Anderson-5', text)
|
||||
|
||||
def test_archive_html_omits_current_gps(self):
|
||||
# An archive is historical; attaching a current fix would mislead.
|
||||
with mock.patch.object(server, '_gps_status_data',
|
||||
return_value={'lock': True, 'lat': 37.7, 'lon': -122.4}):
|
||||
status, payload = server.h_recon_archive_scan_download_html(
|
||||
self._ctx((self.archive_name, '1')))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertNotIn('GPS:', payload.data.decode('utf-8'))
|
||||
|
||||
def test_archive_detail_503_on_lock(self):
|
||||
with mock.patch.object(server, 'recon_scan_data',
|
||||
side_effect=RuntimeError('sqlite read failed: locked')), \
|
||||
mock.patch.object(server.time, 'sleep'):
|
||||
status, data = server.h_recon_archive_scan_detail(
|
||||
self._ctx((self.archive_name, '1')))
|
||||
self.assertEqual(status, 503)
|
||||
self.assertIn('temporarily unavailable', data['error'])
|
||||
|
||||
|
||||
class GpsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
server._gps_cache.update({'updated': 0, 'data': None})
|
||||
@@ -821,6 +1015,31 @@ class GpsTest(unittest.TestCase):
|
||||
self.assertTrue(data['present'])
|
||||
self.assertTrue(data['wigle'])
|
||||
|
||||
def test_gps_status_skips_hak5cmd_when_gpsd_down(self):
|
||||
# GPS_GET can block for seconds when gpsd is down; it must not run.
|
||||
with mock.patch.object(server, '_uci_gps_get', return_value=None), \
|
||||
mock.patch.object(server, '_gps_serial_candidates', return_value=[]), \
|
||||
mock.patch.object(server, '_wigle_config', return_value={'logwigle': False}), \
|
||||
mock.patch.object(server, '_gpsd_running', return_value=False), \
|
||||
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
|
||||
mock.patch.object(server, '_gps_from_hak5cmd',
|
||||
side_effect=AssertionError('GPS_GET must not run when gpsd is down')) as hak5:
|
||||
data = server._gps_status_data_nocache()
|
||||
self.assertFalse(data['gpsd_running'])
|
||||
hak5.assert_not_called()
|
||||
|
||||
def test_gps_status_falls_back_to_hak5cmd_when_gpsd_up(self):
|
||||
with mock.patch.object(server, '_uci_gps_get', return_value=None), \
|
||||
mock.patch.object(server, '_gps_serial_candidates', return_value=[]), \
|
||||
mock.patch.object(server, '_wigle_config', return_value={'logwigle': False}), \
|
||||
mock.patch.object(server, '_gpsd_running', return_value=True), \
|
||||
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
|
||||
mock.patch.object(server, '_gps_from_hak5cmd',
|
||||
return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}):
|
||||
data = server._gps_status_data_nocache()
|
||||
self.assertEqual(data['lat'], 37.7)
|
||||
self.assertEqual(data['satellites'], 8)
|
||||
|
||||
def test_configure_binds_preferred_device_and_locks(self):
|
||||
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
|
||||
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
|
||||
@@ -937,152 +1156,6 @@ class WigleTest(unittest.TestCase):
|
||||
hak5.assert_called_with('WIGLE_STOP', timeout=10)
|
||||
|
||||
|
||||
class SurveyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.db = make_survey_db()
|
||||
server.RECON_DB = self.db
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self._orig = {
|
||||
'SURVEY_DIR': server.SURVEY_DIR,
|
||||
'SURVEY_MAX_SAMPLES': server.SURVEY_MAX_SAMPLES,
|
||||
'SURVEY_SAMPLE_INTERVAL': server.SURVEY_SAMPLE_INTERVAL,
|
||||
}
|
||||
server.SURVEY_DIR = self.dir
|
||||
server.SURVEY_MAX_SAMPLES = 3
|
||||
server.SURVEY_SAMPLE_INTERVAL = 0.0
|
||||
server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
|
||||
'started': 0, 'samples': 0, 'last_sample': 0}
|
||||
server._gps_cache.update({'updated': 0, 'data': None})
|
||||
|
||||
def tearDown(self):
|
||||
server.SURVEY_DIR = self._orig['SURVEY_DIR']
|
||||
server.SURVEY_MAX_SAMPLES = self._orig['SURVEY_MAX_SAMPLES']
|
||||
server.SURVEY_SAMPLE_INTERVAL = self._orig['SURVEY_SAMPLE_INTERVAL']
|
||||
server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
|
||||
'started': 0, 'samples': 0, 'last_sample': 0}
|
||||
server._gps_cache.update({'updated': 0, 'data': None})
|
||||
shutil.rmtree(self.dir)
|
||||
os.unlink(self.db)
|
||||
|
||||
def _ctx(self, args=(), body=None):
|
||||
return type('C', (), {'args': args, 'body': body or {}})()
|
||||
|
||||
def test_start_creates_meta_file(self):
|
||||
status, data = server.h_recon_survey_start(self._ctx(body={'name': 'Kitchen Walk'}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data['ok'])
|
||||
path = os.path.join(self.dir, data['id'] + '.jsonl')
|
||||
self.assertTrue(os.path.isfile(path))
|
||||
with open(path) as f:
|
||||
first = f.readline()
|
||||
self.assertIn('"meta"', first)
|
||||
self.assertIn('Kitchen Walk', first)
|
||||
|
||||
def test_start_rejects_duplicate(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
status, data = server.h_recon_survey_start(self._ctx(body={'name': 'B'}))
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
def test_sample_via_watchdog_and_cap(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
server._recon_watchdog_tick()
|
||||
server._recon_watchdog_tick()
|
||||
self.assertEqual(server._survey_state['samples'], 2)
|
||||
status, data = server.h_recon_survey_stop(self._ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['samples'], 2)
|
||||
self.assertFalse(server._survey_state['active'])
|
||||
|
||||
def test_sample_cap_stops_recording(self):
|
||||
server.SURVEY_MAX_SAMPLES = 2
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
for _ in range(4):
|
||||
server._recon_watchdog_tick()
|
||||
self.assertFalse(server._survey_state['active'])
|
||||
self.assertEqual(server._survey_state['samples'], 2)
|
||||
|
||||
def test_live_reports_scan_unassociated_and_recording(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
server._recon_watchdog_tick()
|
||||
status, data = server.h_recon_survey_live(self._ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['scan']['id'], 1)
|
||||
self.assertEqual(len(data['aps']), 2)
|
||||
self.assertEqual(data['unassociated'], 1)
|
||||
self.assertTrue(data['recording']['active'])
|
||||
self.assertEqual(data['recording']['samples'], 1)
|
||||
self.assertIn('wigle', data['gps'])
|
||||
|
||||
def test_live_recording_none_when_stopped(self):
|
||||
status, data = server.h_recon_survey_live(self._ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsNone(data['recording'])
|
||||
|
||||
def test_surveys_list_counts_samples(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
server._recon_watchdog_tick()
|
||||
server.h_recon_survey_stop(self._ctx())
|
||||
status, data = server.h_recon_surveys(self._ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(data['surveys']), 1)
|
||||
survey = data['surveys'][0]
|
||||
self.assertEqual(survey['name'], 'A')
|
||||
self.assertEqual(survey['samples'], 1)
|
||||
self.assertGreater(survey['size'], 0)
|
||||
|
||||
def test_detail_aggregates_signal(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
server._recon_watchdog_tick()
|
||||
server._recon_watchdog_tick()
|
||||
sid = server._survey_state['id']
|
||||
server.h_recon_survey_stop(self._ctx())
|
||||
status, data = server.h_recon_survey_detail(self._ctx((sid,)))
|
||||
self.assertEqual(status, 200)
|
||||
aps = {a['bssid']: a for a in data['aps']}
|
||||
a = aps['C8:9E:43:64:80:80']
|
||||
self.assertEqual(a['min'], -76)
|
||||
self.assertEqual(a['max'], -76)
|
||||
self.assertEqual(a['avg'], -76)
|
||||
self.assertEqual(a['samples'], 2)
|
||||
self.assertEqual(a['band'], '5')
|
||||
self.assertEqual(a['channel'], 149)
|
||||
self.assertEqual(a['first_seen'], a['last_seen'])
|
||||
self.assertEqual(data['gps_fixes'], 0)
|
||||
|
||||
def test_downloads_all_formats(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
server._recon_watchdog_tick()
|
||||
sid = server._survey_state['id']
|
||||
server.h_recon_survey_stop(self._ctx())
|
||||
for fmt, ctype in [('json', 'application/json'), ('csv', 'text/csv'), ('html', 'text/html')]:
|
||||
status, payload = server.h_recon_survey_download(self._ctx((sid, fmt)))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload.ctype, ctype)
|
||||
self.assertEqual(payload.filename, 'survey-%s.%s' % (sid, fmt))
|
||||
status, payload = server.h_recon_survey_download(self._ctx((sid, 'csv')))
|
||||
self.assertIn('C8:9E:43:64:80:80', payload.data.decode('utf-8'))
|
||||
|
||||
def test_delete_removes_file_then_404(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
sid = server._survey_state['id']
|
||||
server.h_recon_survey_stop(self._ctx())
|
||||
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(os.listdir(self.dir))
|
||||
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_delete_blocks_active_survey(self):
|
||||
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
|
||||
sid = server._survey_state['id']
|
||||
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
|
||||
self.assertEqual(status, 409)
|
||||
|
||||
def test_detail_404_missing(self):
|
||||
status, data = server.h_recon_survey_detail(self._ctx(('nope',)))
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
|
||||
class ReconRoutesTest(unittest.TestCase):
|
||||
def test_new_routes_registered(self):
|
||||
expected = [
|
||||
@@ -1093,24 +1166,26 @@ class ReconRoutesTest(unittest.TestCase):
|
||||
('POST', '/api/recon/wigle', 'h_recon_wigle'),
|
||||
('GET', '/api/recon/wigle/files', 'h_recon_wigle_files'),
|
||||
('GET', '/api/recon/wigle/files/x.csv', 'h_recon_wigle_file'),
|
||||
('GET', '/api/recon/survey/live', 'h_recon_survey_live'),
|
||||
('POST', '/api/recon/survey/start', 'h_recon_survey_start'),
|
||||
('POST', '/api/recon/survey/stop', 'h_recon_survey_stop'),
|
||||
('GET', '/api/recon/surveys', 'h_recon_surveys'),
|
||||
('GET', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_detail'),
|
||||
('GET', '/api/recon/surveys/20260818-120000-A/download/csv', 'h_recon_survey_download'),
|
||||
('GET', '/api/recon/surveys/20260818-120000-A/download/json', 'h_recon_survey_download'),
|
||||
('GET', '/api/recon/surveys/20260818-120000-A/download/html', 'h_recon_survey_download'),
|
||||
('DELETE', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_delete'),
|
||||
('GET', '/api/recon/archives', 'h_recon_archives'),
|
||||
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans', 'h_recon_archive_scans'),
|
||||
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1', 'h_recon_archive_scan_detail'),
|
||||
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/json', 'h_recon_archive_scan_download'),
|
||||
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/csv', 'h_recon_archive_scan_download_csv'),
|
||||
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/html', 'h_recon_archive_scan_download_html'),
|
||||
]
|
||||
for method, path, handler in expected:
|
||||
h, args = server.ROUTER.dispatch(method, path)
|
||||
self.assertIsNotNone(h, '%s %s' % (method, path))
|
||||
self.assertEqual(h.__name__, handler, '%s %s' % (method, path))
|
||||
|
||||
def test_survey_download_route_captures_format(self):
|
||||
h, args = server.ROUTER.dispatch('GET', '/api/recon/surveys/abc/download/csv')
|
||||
self.assertEqual(args, ('abc', 'csv'))
|
||||
def test_survey_routes_are_gone(self):
|
||||
for method, path in [('GET', '/api/recon/survey/live'),
|
||||
('POST', '/api/recon/survey/start'),
|
||||
('GET', '/api/recon/surveys'),
|
||||
('GET', '/api/recon/surveys/abc'),
|
||||
('DELETE', '/api/recon/surveys/abc')]:
|
||||
h, args = server.ROUTER.dispatch(method, path)
|
||||
self.assertIsNone(h, '%s %s should not be registered' % (method, path))
|
||||
|
||||
def test_original_recon_routes_unchanged(self):
|
||||
for path in ['/api/recon/start', '/api/recon/status', '/api/recon/scans',
|
||||
|
||||
Reference in New Issue
Block a user