feat: merge attacks into PineAP menu; auto channel; recon fixes; richer reports

- Rail: Attacks tab removed; PineAP becomes a grouped menu (Evil WPA /
  Evil Open / Evil Enterprise / Impersonation / Clients / Filtering);
  old #/attacks* hashes redirect to their PineAP equivalents.
- PineAP tabs gain Evil Enterprise; stock Open AP / Evil WPA / Enterprise
  pages replaced by the verified one-click launchers (status, capture,
  export, deauth, playbooks).
- Channel selects gain an Auto option: deploy resolves the target SSID's
  last-seen channel from recon.db (verified unit-tested end to end).
- Harness: pi.dev prompt section removed; Copy Token inline; robot icon.
- Recon: compare checkboxes no longer hide the AP list (multi-select
  stays visible, rows highlighted, clients table no longer suppressed);
  Previous Scans buttons moved above the dropdown with a Delete All;
  encryption chips + buckets now distinguish WPA2/WPA3 PSK vs Enterprise
  (AKM suites decoded from recon bitfield bits 32-47); scan JSON carries
  GPS when a fix exists; Reports tab shows a GPS column.
- fix: restore top-level EVIL_ENC definition lost in the repo (deployed
  build had it; repo would have thrown at init).
This commit is contained in:
2026-08-18 22:35:51 -05:00
parent b6be1c40a8
commit 60a726dc70
8 changed files with 375 additions and 572 deletions
+128 -21
View File
@@ -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)