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:
2026-08-18 14:55:02 -05:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent 251b1f6261
commit d8a7074e1f
6 changed files with 1169 additions and 1071 deletions
+361 -351
View File
@@ -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 &middot; first %.5f, %.5f &middot; 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)