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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user