diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 2bd4a73..3cc1529 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -953,10 +953,26 @@ ENC_CCMP = 0x08 ENC_GCMP = 0x20 ENC_GCMP256 = 0x80 ENC_CCMP256 = 0x100 +# AKM suites ride in bits 32-47 (bit k = RSN suite selector k advertised). +ENC_AKM_EAP = 1 << (32 + 1) # 802.1X / EAP (Enterprise) +ENC_AKM_PSK = 1 << (32 + 2) # PSK +ENC_AKM_FT_EAP = 1 << (32 + 3) # FT-802.1X (Enterprise) +ENC_AKM_FT_PSK = 1 << (32 + 4) # FT-PSK +ENC_AKM_EAP_SHA256 = 1 << (32 + 5) # 802.1X-SHA256 (Enterprise) +ENC_AKM_PSK_SHA256 = 1 << (32 + 6) # PSK-SHA256 +ENC_AKM_SAE = 1 << (32 + 8) # SAE (WPA3-Personal) +ENC_AKM_FT_SAE = 1 << (32 + 9) # FT-SAE +ENC_AKM_OWE = 1 << (32 + 13) # OWE +ENC_AKM_OWE_SHA192 = 1 << (32 + 14) # OWE-SHA256-192 def decode_encryption(v): - """Pager recon.db encryption bitfield -> old-UI-style display string.""" + """Pager recon.db encryption bitfield -> display string. + + Low bits carry pairwise ciphers, bits 32-47 carry the advertised AKM + suites, so WPA2-PSK vs WPA2-Enterprise (and WPA3-Personal vs + WPA3-Enterprise) can be told apart. + """ v = v or 0 if v == 0: return 'Open' @@ -969,6 +985,16 @@ def decode_encryption(v): parts.append('WPA') if v & ENC_WEP: parts.append('WEP') + if not parts: + return 'Open' + if v & (ENC_AKM_EAP | ENC_AKM_FT_EAP | ENC_AKM_EAP_SHA256): + parts.append('Enterprise') + elif v & (ENC_AKM_SAE | ENC_AKM_FT_SAE): + parts.append('SAE') + elif v & ENC_AKM_OWE: + parts.append('OWE') + elif v & (ENC_AKM_PSK | ENC_AKM_FT_PSK | ENC_AKM_PSK_SHA256): + parts.append('PSK') return ' '.join(parts) if parts else 'Open' @@ -1180,12 +1206,22 @@ def recon_scan_data(scan_id, _timeout=20, _limit=None, db=None): handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'), 'client': mac_of.get(r.get('stahash'), '--'), 'time': r.get('time')}) - return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'], - 'name': scans[0].get('name')}, + # GPS attaches to live scans only: an archived scan's coordinates would + # be a current fix, which is misleading for historical data. + scan = {'id': scans[0]['row_id'], 'time': scans[0]['time'], + 'name': scans[0].get('name')} + if db is None: + try: + gps = _gps_status_data() + if gps.get('lock'): + scan['gps'] = {'lat': gps.get('lat'), 'lon': gps.get('lon'), + 'alt': gps.get('alt'), + 'satellites': gps.get('satellites')} + except Exception: + pass + return {'scan': scan, 'aps': aps, 'clients': clients, 'handshakes': handshakes, 'unassociated': unassociated} - - 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 @@ -1351,6 +1387,24 @@ def h_recon_delete(ctx): return 200, {'ok': True} +def h_recon_delete_all(ctx): + """Clear every recorded scan from the live recon database. Rotated + archive files (error-*/diagnostic-* recon dbs) are left untouched.""" + count = 0 + try: + rows = _db_rows(RECON_DB, 'SELECT COUNT(*) AS c FROM scan', timeout=20) + count = rows[0]['c'] if rows else 0 + except RuntimeError: + pass + for t in RECON_CHILD_TABLES + ['scan']: + try: + _db_write(RECON_DB, 'DELETE FROM %s' % t) + except Exception: + continue + _recon_scans_cache['updated'] = 0 + return 200, {'ok': True, 'deleted': count} + + def h_recon_scan_download(ctx): scan_id = int(ctx.args[0]) data = recon_scan_data(scan_id) @@ -1537,14 +1591,18 @@ def _enc_bucket(enc): 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 'Enterprise' in s: + return 'WPA3-Enterprise' if 'WPA3' in s else 'WPA2-Enterprise' + if 'SAE' in s or 'OWE' in s: + return 'WPA3-Personal' + if 'WPA3' in s and 'WPA2' in s: + return 'WPA2-PSK' if 'PSK' in s else 'WPA3-PSK' if 'WPA3' in s: - return 'WPA3' + return 'WPA3-PSK' + if 'WPA2' in s: + return 'WPA2-PSK' if 'WPA' in s: return 'WPA' return 'Unknown' @@ -2621,6 +2679,46 @@ def _open_channel(value): return 1 +def _freq_to_channel(freq): + try: + freq = int(freq) + except (TypeError, ValueError): + return None + if 2412 <= freq <= 2484: + return (freq - 2412) // 5 + 1 + if 5180 <= freq <= 5885: + return (freq - 5180) // 5 + 36 + if 5955 <= freq <= 7115: + return (freq - 5955) // 5 + 1 + return None + + +def _best_channel_for(ssid): + """Resolve an 'auto' attack channel: the channel the target SSID was + last seen on in recon, else None (caller falls back to defaults).""" + if not ssid: + return None + hex_ssid = ssid.encode('utf-8').hex() + try: + rows = _db_rows(RECON_DB, + "SELECT channel, freq FROM ssid WHERE type = 8 " + "AND ssid = X'%s' ORDER BY time DESC LIMIT 1" + % hex_ssid, timeout=20) + except RuntimeError: + return None + if not rows: + return None + channel = rows[0].get('channel') + if channel is not None: + try: + channel = int(channel) + if 1 <= channel <= 233: + return channel + except (TypeError, ValueError): + pass + return _freq_to_channel(rows[0].get('freq')) + + def _read_hop(): rc, out, err = device_run(['uci', 'get', 'pineapd.wlan1mon.hop']) if rc != 0: @@ -2845,7 +2943,12 @@ def _verify_iface(name, timeout=20.0): def _deploy_wpa_open(kind, fields): - band = _band_of_channel(fields.get('channel')) + channel = fields.get('channel') + if channel is None: + channel = _best_channel_for((fields.get('ssid') or '').strip()) + if channel is None: + channel = 1 + band = _band_of_channel(channel) ssid = (fields.get('ssid') or '').strip() if not ssid: raise ValueError('SSID is required') @@ -2866,7 +2969,7 @@ def _deploy_wpa_open(kind, fields): 'ssid': ssid, 'enabled': True, 'hidden': bool(fields.get('hidden')), - 'channel': int(fields.get('channel') or 1), + 'channel': int(channel or 1), } if kind == 'wpa': daemon_cfg['enctype'] = enctype @@ -2883,7 +2986,7 @@ def _deploy_wpa_open(kind, fields): if status != 200: raise RuntimeError('daemon rejected AP config: %r' % (data,)) if kind == 'open': - _apply_open_radio({'channel': int(fields.get('channel') or 1), + _apply_open_radio({'channel': int(channel or 1), 'country': fields.get('country') or 'US'}) else: # 5/6 GHz: radio1 feature @@ -2892,14 +2995,14 @@ def _deploy_wpa_open(kind, fields): _apply_radio1_ap(None, { 'ssid': ssid, 'passphrase': fields.get('passphrase') or '', 'enctype': enctype, 'hidden': bool(fields.get('hidden')), - 'enabled': True, 'channel': int(fields.get('channel')), + 'enabled': True, 'channel': int(channel), 'country': fields.get('country') or 'US', }) iface = 'wlan1wpa' else: _apply_radio1_ap({ 'ssid': ssid, 'hidden': bool(fields.get('hidden')), - 'enabled': True, 'channel': int(fields.get('channel')), + 'enabled': True, 'channel': int(channel), 'bssid': fields.get('bssid') or '', 'country': fields.get('country') or 'US', }, None) @@ -2909,7 +3012,8 @@ def _deploy_wpa_open(kind, fields): # The daemon applies AP changes asynchronously; allow a full reload cycle. verified = _verify_iface(iface, timeout=45) return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band, - 'channel': int(fields.get('channel')), 'verified': verified} + 'channel': int(channel or 1), 'auto': fields.get('channel') is None, + 'verified': verified} def _disable_enterprise_ap(): @@ -2967,12 +3071,14 @@ def _deploy_enterprise(fields): if enctype not in ('wpa2', 'wpa3'): raise ValueError('enterprise encryption must be wpa2 or wpa3') ch = fields.get('channel') - if ch is not None: - band = channel_band(ch) - if band != BAND_5G: - raise ValueError('enterprise AP runs on 5 GHz (36-177)') + if ch is None: + ch = _best_channel_for(ssid) + if ch is None or channel_band(ch) != BAND_5G: + ch = 36 else: - ch = 36 + ch = int(ch) + if channel_band(ch) != BAND_5G: + raise ValueError('enterprise AP runs on 5 GHz (36-177)') # Radio0 karma surface is shared: stop 2.4 GHz WPA/Open attacks first. for name in ('wlan0wpa', 'wlan0open'): cfg = _uci_wifi_iface(name) @@ -4863,6 +4969,7 @@ ROUTER.add('POST', r'/api/recon/start', h_recon_start) ROUTER.add('POST', r'/api/recon/stop', h_recon_stop) ROUTER.add('GET', r'/api/recon/status', h_recon_status) ROUTER.add('GET', r'/api/recon/scans', h_recon_scans) +ROUTER.add('DELETE', r'/api/recon/scans', h_recon_delete_all) ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/json', h_recon_scan_download) ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail) ROUTER.add('DELETE', r'/api/recon/scans/(\d+)', h_recon_delete) diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 9853988..9cc4415 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -143,6 +143,12 @@ body { align-items: center; justify-content: center; margin-right: 14px; flex: none; } .entry-icon svg { width: 24px; height: 24px; display: block; } +#rail .entry.sub { + height: 36px; padding-left: 34px; font-size: 13px; +} +#rail .entry.sub .entry-icon { width: 18px; height: 18px; margin-right: 10px; } +#rail .entry.sub .entry-icon svg { width: 18px; height: 18px; } +#rail.open .entry.sub .entry-text { font-size: 12px; } #topbar .btn.ghost { color: #fff; border-color: #fff; } .entry-text { white-space: nowrap; opacity: 0; transition: opacity .2s; } #rail.open .entry-text { opacity: 1; } @@ -335,6 +341,9 @@ html.dark .recon-scan-status.warn { color: #ffb74d; } .recon-paginator .icon-btn svg { width: 18px; height: 18px; } .recon-row-selected td { background: #eaeaea; } html.dark .recon-row-selected td { background: #565656; } +.recon-row-compare td { background: rgba(25, 118, 210, .08); } +html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); } +.recon-gps-cell { font-variant-numeric: tabular-nums; } .recon-settings-sidebar { position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50; background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px; diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 4802230..6dd6aca 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -6,7 +6,7 @@