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 72f6a68897
commit e93359fb69
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)
@@ -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;
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260811-9">
<link rel="stylesheet" href="css/app.css?v=20260818-7">
<link rel="stylesheet" href="js/xterm.css">
</head>
<body>
@@ -261,14 +261,14 @@
<div id="toast-container"></div>
<script src="js/config.js"></script>
<script src="js/icons.js?v=20260811-6"></script>
<script src="js/icons.js?v=20260818-7"></script>
<script src="js/api.js?v=20260817-4"></script>
<script src="js/chart.js"></script>
<script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script>
<script src="js/pager.js"></script>
<script src="js/views.js?v=20260818-6"></script>
<script src="js/app.js?v=20260818-3"></script>
<script src="js/views.js?v=20260818-7"></script>
<script src="js/app.js?v=20260818-7"></script>
</body>
</html>
@@ -29,12 +29,20 @@ const App = (() => {
const railItems = [
{ key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' },
{ key: 'attacks', label: 'Attacks', hash: '#/attacks', icon: 'attack' },
{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' },
{
key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap', children: [
{ key: 'pineap_evilwpa', label: 'Evil WPA', hash: '#/pineap/evilwpa', icon: 'attack' },
{ key: 'pineap_open', label: 'Evil Open', hash: '#/pineap/open', icon: 'wifi' },
{ key: 'pineap_enterprise', label: 'Evil Enterprise', hash: '#/pineap/enterprise', icon: 'record' },
{ key: 'pineap_impersonation', label: 'Impersonation', hash: '#/pineap/impersonation', icon: 'place' },
{ key: 'pineap_clients', label: 'Clients', hash: '#/pineap/clients', icon: 'pager' },
{ key: 'pineap_filtering', label: 'Filtering', hash: '#/pineap/filtering', icon: 'settings' }
]
},
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
{ key: 'harness', label: 'Harness', hash: '#/harness', icon: 'extension' },
{ key: 'harness', label: 'Harness', hash: '#/harness', icon: 'robot' },
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
];
const railDividers = new Set(['logging']);
@@ -63,6 +71,13 @@ const App = (() => {
rail.appendChild(d);
}
rail.appendChild(railEntry(it));
if (it.children) {
it.children.forEach((sub) => {
const s = railEntry(sub);
s.classList.add('sub');
rail.appendChild(s);
});
}
});
const foot = document.createElement('div');
foot.className = 'rail-footer';
@@ -85,11 +100,22 @@ const App = (() => {
function route() {
closeToolbarMenus();
const hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
let hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
if (hash === '#/recon/survey') {
location.hash = '#/recon';
return;
}
if (hash.indexOf('#/attacks') === 0) {
const map = {
'#/attacks': '#/pineap',
'#/attacks/wpa': '#/pineap/evilwpa',
'#/attacks/open': '#/pineap/open',
'#/attacks/enterprise': '#/pineap/enterprise'
};
hash = map[hash] || '#/pineap';
location.replace(hash);
return;
}
const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = '';
@@ -103,9 +129,13 @@ const App = (() => {
currentView = views[name](els.content);
}
const key = keyOf(hash);
const subs = els.rail.querySelectorAll('.entry.sub');
const subActive = Array.prototype.some.call(subs, (s) => s.getAttribute('href') === hash);
Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => {
const href = a.getAttribute('href');
a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key));
const exact = !!href && href === hash;
const groupActive = !subActive && !!href && keyOf(href) === key;
a.classList.toggle('active', exact || groupActive);
});
}
@@ -395,13 +425,10 @@ const App = (() => {
const routes = {
'#/dashboard': 'dashboard',
'#/attacks': 'attacks',
'#/attacks/wpa': 'attacks_wpa',
'#/attacks/open': 'attacks_open',
'#/attacks/enterprise': 'attacks_enterprise',
'#/pineap': 'pineap',
'#/pineap/open': 'pineap_open',
'#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/enterprise': 'pineap_enterprise',
'#/pineap/open': 'pineap_open',
'#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
@@ -10,12 +10,14 @@ window.PineappleIcons = {
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>',
robot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13M12,20A2,2 0 0,0 14,18H10A2,2 0 0,0 12,20Z"/></svg>',
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>',
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>',
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
delete_forever: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8.46,11.88L9.87,10.47L12,12.59L14.12,10.47L15.53,11.88L13.41,14L15.53,16.12L14.12,17.53L12,15.41L9.88,17.53L8.47,16.12L10.59,14L8.46,11.88M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
@@ -246,8 +246,9 @@ views.dashboard = (root) => {
const PINEAP_TABS = [
{ label: 'PineAP', hash: '#/pineap' },
{ label: 'Open AP', hash: '#/pineap/open' },
{ label: 'Evil WPA', hash: '#/pineap/evilwpa' },
{ label: 'Evil Open', hash: '#/pineap/open' },
{ label: 'Evil Enterprise', hash: '#/pineap/enterprise' },
{ label: 'Impersonation', hash: '#/pineap/impersonation' },
{ label: 'Clients', hash: '#/pineap/clients' },
{ label: 'Filtering', hash: '#/pineap/filtering' }
@@ -336,10 +337,12 @@ views.pineap = (root) => {
modeRow.appendChild(quickCard);
box.appendChild(modeRow);
box.appendChild(h('div', { class: 'pineap-infobox info' },
h('span', { text: 'For one-click Evil WPA / Open AP / Enterprise attacks, use the Attacks section — it deploys, enables karma, verifies on-device and captures loot automatically.' }),
h('span', { text: 'The Evil WPA / Evil Open / Evil Enterprise tabs deploy one-click attacks — they enable karma, verify on-device and capture loot automatically.' }),
h('div', { class: 'pineap-infobox-actions' },
h('a', { class: 'btn ghost', href: '#/attacks', style: 'text-decoration:none',
onclick: (e) => { e.preventDefault(); App.go('#/attacks'); } }, 'Go to Attacks'))));
h('a', { class: 'btn ghost', href: '#/pineap/evilwpa', style: 'text-decoration:none',
onclick: (e) => { e.preventDefault(); App.go('#/pineap/evilwpa'); } }, 'Evil WPA'),
h('a', { class: 'btn ghost', href: '#/pineap/enterprise', style: 'text-decoration:none',
onclick: (e) => { e.preventDefault(); App.go('#/pineap/enterprise'); } }, 'Evil Enterprise'))));
const cards = { karma: {}, open: {}, wpa: {} };
const cardWrap = h('div', { class: 'pineap-title-card-container' });
@@ -487,6 +490,10 @@ views.pineap = (root) => {
return { destroy: () => clearInterval(iv) };
};
const EVIL_ENC = [
['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE']
];
const BAND_GROUPS = [
{ band: '2.4', label: '2.4 GHz', dfs: false,
channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
@@ -512,6 +519,7 @@ function chanLabel(band, ch) {
}
function chanSelect(sel, value) {
sel.appendChild(h('option', { value: '', text: 'Auto — best channel for the target' }));
BAND_GROUPS.forEach((g) => {
const og = h('optgroup', { label: g.label });
g.channels.forEach((ch) => {
@@ -519,7 +527,9 @@ function chanSelect(sel, value) {
});
sel.appendChild(og);
});
if (value != null) {
if (value == null || value === '') {
sel.value = '';
} else {
const opts = Array.prototype.slice.call(sel.options);
const hit = opts.find((o) => Number(o.value) === Number(value));
if (hit) sel.value = hit.value;
@@ -528,7 +538,7 @@ function chanSelect(sel, value) {
}
function bandOfChannel(ch) {
if (ch == null) return '2.4';
if (ch == null || ch === '') return '2.4';
ch = Number(ch);
if (ch >= 1 && ch <= 14) return '2.4';
if (ch >= 36 && ch <= 177) return '5';
@@ -556,347 +566,52 @@ const OPEN_COUNTRIES = [
['VE', 'Venezuela'], ['VN', 'Vietnam']
];
views.pineap_open = (root) => {
const box = pineapShell(root, '#/pineap/open');
const card = h('div', { class: 'pineap-title-card' });
box.appendChild(card);
card.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAP Open Access Point'));
const subtitle = h('div', { class: 'pineap-card-subtitle' });
card.appendChild(subtitle);
const ssidIn = h('input', { id: 'oa-ssid' });
const bssidIn = h('input', { id: 'oa-bssid' });
const chSel = h('select', { id: 'oa-channel' });
chanSelect(chSel, null);
const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
function applyOaHint() {
const b = bandOfChannel(chSel.value);
bandHint.textContent = b === '6' ? '6 GHz open APs require WPA3/OWE on real clients — most devices will not associate to an open 6 GHz network.' : '';
}
chSel.addEventListener('change', applyOaHint);
const coSel = h('select', { id: 'oa-country' });
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' });
const karmaCb = h('input', { type: 'checkbox', id: 'oa-karma' });
let karmaDirty = false;
karmaCb.addEventListener('change', () => {
karmaDirty = true;
karmaCb.indeterminate = false;
render();
});
card.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'Open SSID', ssidIn)),
h('div', {}, h('label', {}, 'BSSID', bssidIn))));
card.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'Channel', chSel), bandHint),
h('div', {}, h('label', {}, 'Current Country', coSel))));
card.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')),
h('div', {}, h('label', { class: 'switch' }, karmaCb, h('span', { class: 'track' }), ' Respond to all probe requests (impersonate all networks)'))));
const info = h('div', { class: 'muted', style: 'margin-top:10px;font-size:13px' });
card.appendChild(info);
const boxes = h('div', {});
card.appendChild(boxes);
card.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, btn('Save', save)),
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
const state = {};
function cfgLink() {
return h('a', { href: '#/pineap/filtering', style: 'color:var(--primary);cursor:pointer', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'filter configuration');
}
function filterBtn() {
return h('a', { class: 'btn', href: '#/pineap/filtering', style: 'text-decoration:none;display:inline-block', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'Change Filters');
}
function infobox(severity, text, ...actions) {
return h('div', { class: 'pineap-infobox ' + severity },
h('span', { text }),
h('div', { class: 'pineap-infobox-actions' }, ...actions));
}
function filterSentence(sm, cm) {
if (sm === 'allow' && cm === 'allow') return 'any client in the filter configuration may connect to any SSID in the filter configuration.';
if (sm === 'deny' && cm === 'allow') return 'any client not in the filter configuration may connect to any SSID in the filter configuration.';
if (sm === 'allow' && cm === 'deny') return 'any client in the filter configuration may connect to any SSID not in the filter configuration.';
return 'any client not in the filter configuration may connect to any SSID not in the filter configuration.';
}
function save() {
const requests = [PagerAPI.post('/api/pineap/wifi/set_ap', {
open: {
ssid: ssidIn.value,
bssid: bssidIn.value.trim(),
hidden: hiddenCb.checked,
enabled: state.enabledLoaded ? !!state.enabled : true,
channel: chSel.value ? parseInt(chSel.value, 10) : null,
country: coSel.value
}
})];
if (karmaDirty) requests.push(PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked }));
Promise.allSettled(requests).then((results) => {
const ok = results.every((r) => r.status === 'fulfilled');
if (ok && karmaDirty) {
PINEAP_SESSION.karma = karmaCb.checked;
karmaDirty = false;
}
App.toast(ok ? 'Open AP saved' : 'Some settings failed', ok ? '' : 'error');
load();
});
}
function render() {
const sm = state.ssidMode || 'deny';
const cm = state.clientMode || 'deny';
subtitle.textContent = '';
subtitle.appendChild(document.createTextNode('The Open SSID is advertised without encryption. When client association is enabled, '));
subtitle.appendChild(cfgLink());
subtitle.appendChild(document.createTextNode(' ' + filterSentence(sm, cm)));
const hidden = hiddenCb.checked;
const karma = karmaCb.checked;
let t = 'The Open access point will be ' + (hidden ? 'hidden' : 'advertised');
if (!karma) {
t += '.';
} else {
if (sm === 'allow' && cm === 'allow') t += ', and clients in the allowed client filter list will be able to connect to any SSID in the allowed SSID filter.';
else if (sm === 'allow' && cm === 'deny') t += ', and clients in the allowed client filter list will be able to connect to any SSID not blocked by the SSID filter.';
else if (sm === 'deny' && cm === 'allow') t += ', and clients not in the denied client filter list will be able to connect to any SSID in the allowed SSID filter.';
else t += ', and clients not in the denied client filter list will be able to connect to any SSID not blocked by the SSID filter.';
}
info.textContent = t;
boxes.innerHTML = '';
const openSsid = ssidIn.value;
const ssidList = state.ssidList || [];
const clientList = state.clientList || [];
if (state.ssidFetched && sm === 'allow' && openSsid && ssidList.indexOf(openSsid) === -1) {
boxes.appendChild(infobox('error',
'The open SSID "' + openSsid + '" is not included in the filter allow list, clients will not be able to connect.',
btn('Add Allowed', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'add', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
}
if (state.ssidFetched && sm === 'deny' && openSsid && ssidList.indexOf(openSsid) !== -1) {
boxes.appendChild(infobox('error',
'The open SSID "' + openSsid + '" is included in the filter deny list, clients will not be able to connect.',
btn('Remove Filter', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'delete', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
}
if (sm === 'allow' && ssidList.length > 0 && karmaCb.checked) {
boxes.appendChild(infobox('info',
'Remember to add SSIDs you wish to impersonate to the PineAP SSID filter, or change to "Deny" mode to allow responding to all requested networks!',
filterBtn()));
}
if (state.clientFetched && cm === 'allow' && clientList.length === 0) {
boxes.appendChild(infobox('error',
'The PineAP Client filter is set to "allow", but no clients are listed; no clients will be able to connect!',
btn('Change Mode', () => PagerAPI.post('/api/pineap/filters/client', { action: 'set_mode', mode: 'deny' }).then(load).catch(() => App.toast('Failed', 'error'))),
filterBtn()));
views.pineap_evilwpa = attackLauncher('wpa', {
title: 'Evil WPA',
subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture',
passphrase: true,
encodings: EVIL_ENC,
handshakes: true,
export: true,
deauth: true,
tabHash: '#/pineap/evilwpa',
playbook: {
steps: ['Deploy the evil twin',
'Wait for a client to associate',
'Deauth the target client to force the 4-way',
'Export .hc22000 and crack with hashcat'],
currentStep: (s, w) => {
if (!w || !w.enabled) return 'Deploy the evil twin';
if (!s || !(s.handshakes > 0)) return 'Wait for a client to associate';
return 'Export .hc22000 and crack with hashcat';
},
hint: (s, w) => {
if (!w || !w.enabled) return '1. Set the target SSID and passphrase, pick a channel (Auto finds it from recon), Deploy. 2. When the target client is near, use Deauth Targeting below. 3. Captured handshakes appear above — Export and run the hashcat command.';
if (s && s.handshakes > 0) return 'Handshake captured! Export .hc22000 and run hashcat -m 22000.';
return 'AP is live on ' + (w.ssid || 'the target') + '. Watch the handshakes list — use Deauth Targeting to nudge the client. If a client refuses to join the twin, its reconnect to the real AP is still captured passively.';
}
}
});
function load() {
Promise.all([
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/filters/ssid').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} }))
]).then(([ap, sf, cf]) => {
const a = ap.data || {};
const open = a.open || {};
ssidIn.value = open.ssid || '';
bssidIn.value = open.bssid || '';
if (open.channel != null) {
const opts = Array.prototype.slice.call(chSel.options);
if (opts.some((o) => Number(o.value) === Number(open.channel))) {
chSel.value = String(open.channel);
}
}
applyOaHint();
if (open.country) coSel.value = open.country;
hiddenCb.checked = !!open.hidden;
state.enabledLoaded = !!(a.open);
state.enabled = !!open.enabled;
if (!karmaDirty) setKnownCheckbox(karmaCb, PINEAP_SESSION.karma);
const sd = sf.data || {}, cd = cf.data || {};
state.ssidFetched = !!sd.mode;
state.clientFetched = !!cd.mode;
state.ssidMode = sd.mode;
state.clientMode = cd.mode;
state.ssidList = sd.entries || [];
state.clientList = cd.entries || [];
render();
});
views.pineap_open = attackLauncher('open', {
title: 'Evil Open',
subtitle: 'Open network evil twin',
bssid: true,
country: true,
tabHash: '#/pineap/open',
playbook: {
steps: ['Deploy the open AP',
'Wait for clients to associate',
'Watch connected clients under PineAP → Clients'],
currentStep: (s, w) => {
if (!w || !w.enabled) return 'Deploy the open AP';
return 'Wait for clients to associate';
},
hint: (s, w) => !w || !w.enabled
? 'Set the SSID (optionally spoof a BSSID), pick a channel (Auto finds it from recon), Deploy.'
: 'Open AP is live on ' + (w.ssid || 'the target') + ' — clients that join appear in the Clients list.'
}
load();
return { destroy: () => {} };
};
const EVIL_ENC = [
['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE']
];
views.pineap_evilwpa = (root) => {
const box = pineapShell(root, '#/pineap/evilwpa');
const cfg = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title' }, 'Evil WPA'));
box.appendChild(cfg);
const ssidIn = h('input', { id: 'ew-ssid' });
const pskIn = h('input', { id: 'ew-psk', type: 'password', autocomplete: 'new-password' });
const encSel = h('select', { id: 'ew-enc' });
EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' });
const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' });
const wpaChan = h('select', { id: 'ew-channel' });
chanSelect(wpaChan, null);
const wpaHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
function applyWpaHint() {
const six = bandOfChannel(wpaChan.value) === '6';
Array.prototype.forEach.call(encSel.options, (o) => { o.disabled = six && o.value === 'psk2'; });
if (six && encSel.value === 'psk2') encSel.value = 'sae';
wpaHint.textContent = six ? '6 GHz requires WPA3 (SAE or OWE).' : '';
}
wpaChan.addEventListener('change', applyWpaHint);
cfg.appendChild(h('label', {}, 'SSID', ssidIn));
cfg.appendChild(h('label', {}, 'Passphrase', pskIn));
cfg.appendChild(h('label', {}, 'Encryption', encSel));
cfg.appendChild(h('label', {}, 'Channel', wpaChan));
cfg.appendChild(wpaHint);
cfg.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled'));
cfg.appendChild(h('div', { class: 'row' },
h('div', {}, btn('Save', () => {
PagerAPI.post('/api/pineap/wifi/set_ap', {
wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value,
hidden: hiddenCb.checked, enabled: enabledCb.checked,
channel: wpaChan.value ? parseInt(wpaChan.value, 10) : 1 }
}).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error'));
})),
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
const capBox = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title' }, 'Handshake Capture'));
box.appendChild(capBox);
const captureCb = h('input', { type: 'checkbox', id: 'ew-capture' });
const partialCb = h('input', { type: 'checkbox', id: 'ew-partial' });
capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Automatic Capture' }));
capBox.appendChild(h('label', { class: 'switch' }, captureCb, h('span', { class: 'track' }), 'Capture WPA handshakes'));
capBox.appendChild(h('label', { class: 'switch' }, partialCb, h('span', { class: 'track' }), 'Keep partial handshakes'));
capBox.appendChild(h('div', { class: 'muted', style: 'margin:6px 0 10px;font-size:12px' },
'Automatically save handshakes observed by PineAP. Partial captures may not contain enough material for password recovery.'));
capBox.appendChild(btn('Save capture settings', () => {
PagerAPI.post('/api/pineap/set_config', {
loghandshake: captureCb.checked,
logpartialhandshake: partialCb.checked
}).then(() => { App.toast('Handshake capture settings saved'); load(); })
.catch(() => App.toast('Failed to save capture settings', 'error'));
}, 'ghost'));
capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Targeted Capture' }));
const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' });
const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' });
capBox.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'BSSID', bssidIn)),
h('div', {}, h('label', {}, 'Seconds', secsIn)),
h('div', {}, btn('Examine', () => {
const b = bssidIn.value.trim();
if (!b) { App.toast('BSSID required', 'error'); return; }
PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 })
.then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error'));
})),
h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger'))));
const hsBody = h('div', {});
const hsBox = h('div', { class: 'pineap-title-card pineap-card-handshakes' },
h('div', { class: 'pineap-card-title' }, 'Captured Handshakes'),
hsBody);
box.appendChild(hsBox);
function load() {
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
const w = (r.data || {}).wpa || {};
ssidIn.value = w.ssid || '';
pskIn.value = w.passphrase || '';
if (w.enctype && Array.prototype.some.call(encSel.options, (o) => o.value === w.enctype)) {
encSel.value = w.enctype;
}
hiddenCb.checked = !!w.hidden;
enabledCb.checked = !!w.enabled;
if (w.channel != null) {
const opts = Array.prototype.slice.call(wpaChan.options);
if (opts.some((o) => Number(o.value) === Number(w.channel))) {
wpaChan.value = String(w.channel);
}
}
applyWpaHint();
}).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
const p = r.data || {};
captureCb.checked = !!p.loghandshake;
partialCb.checked = !!p.logpartialhandshake;
}).catch(() => {});
PagerAPI.get('/api/pineap/handshakes').then((r) => {
hsBody.innerHTML = '';
const rows = (r.data.handshakes || []).map((x) => ({
name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--'
}));
hsBody.appendChild(table(
[{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' },
{ label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }],
rows));
if (!rows.length) hsBody.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No handshakes captured yet.' }));
}).catch(() => {});
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
views.pineap_enterprise = (root) => {
const box = pineapShell(root, '#/pineap/enterprise');
const cfg = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title' }, 'Evil Enterprise'));
box.appendChild(cfg);
const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' });
const authCb = h('input', { type: 'checkbox', id: 'ee-auth' });
cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled'));
cfg.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture'));
enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; App.toast('Failed', 'error'); }));
authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); }));
function tableBox(name, endpoint, clearTable) {
const body = h('div', {});
const tb = h('div', { class: 'pineap-title-card pineap-card-inject' },
h('div', { class: 'pineap-card-title-flex' },
h('span', { text: name }),
h('span', { class: 'toolbar-spacer' }),
btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')),
body);
box.appendChild(tb);
return { body, endpoint };
}
const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic');
const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge');
function load() {
PagerAPI.get('/api/pineap/hostapd').then((r) => {
const hh = r.data || {};
enabledCb.checked = !hh.pineape_disabled;
authCb.checked = !!hh.pineape_auth_pass;
}).catch(() => {});
[basic, chall].forEach((t) => {
PagerAPI.get(t.endpoint).then((r) => {
const rows = (r.data.rows || []).slice();
t.body.innerHTML = '';
const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k }))
: [{ label: '—', key: '_none' }];
t.body.appendChild(table(cols, rows));
if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' }));
}).catch(() => {});
});
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
});
views.pineap_impersonation = (root) => {
const box = pineapShell(root, '#/pineap/impersonation');
@@ -1207,22 +922,6 @@ function reconFiltered(rows, q, colsArr) {
// Attacks: one-click Evil WPA / Open / Enterprise launchers.
// ---------------------------------------------------------------------------
const ATTACK_TABS = [
{ label: 'Overview', hash: '#/attacks' },
{ label: 'Evil WPA', hash: '#/attacks/wpa' },
{ label: 'Open AP', hash: '#/attacks/open' },
{ label: 'Evil Enterprise', hash: '#/attacks/enterprise' }
];
function attacksShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'Attacks' }));
tabBar(root, ATTACK_TABS, activeHash);
const box = h('div', {});
box.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin:8px 0' },
'Targets: only networks you are authorized to test. The device is the source of truth — every change is verified against it.'));
root.appendChild(box);
return box;
}
function attackBadge(ap) {
if (!ap) return badge(false);
@@ -1246,52 +945,9 @@ function verifiedToast(result) {
else App.toast('Deploy failed', 'error');
}
views.attacks = (root) => {
const box = attacksShell(root, '#/attacks');
const wrap = h('div', { class: 'pineap-title-card-container' });
box.appendChild(wrap);
const kinds = [
['wpa', 'Evil WPA (PSK)', 'Clone a WPA2/WPA3-PSK network and capture the four-way handshake.', '#/attacks/wpa'],
['open', 'Evil Open', 'Advertise an open network and watch who connects.', '#/attacks/open'],
['enterprise', 'Evil Enterprise', 'Serve WPA2/3-Enterprise with PineAPE and harvest 802.1X credentials.', '#/attacks/enterprise']
];
const statusEls = {};
kinds.forEach(([kind, label, desc, hash]) => {
const card = h('div', { class: 'pineap-title-card' });
const st = h('span', { class: 'badge', text: '—' });
statusEls[kind] = st;
card.appendChild(h('div', { class: 'pineap-card-title' },
h('a', { href: hash, style: 'color:var(--primary);cursor:pointer',
onclick: (e) => { e.preventDefault(); App.go(hash); } }, label)));
card.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin:6px 0', text: desc }));
card.appendChild(h('div', { class: 'row' }, st,
h('a', { class: 'btn ghost', href: hash, style: 'text-decoration:none',
onclick: (e) => { e.preventDefault(); App.go(hash); } }, 'Configure')));
wrap.appendChild(card);
});
function load() {
PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {};
const live = (x) => !!(x && x.enabled);
const summary = {
wpa: live(s.wpa && s.wpa.radio0) || live(s.wpa && s.wpa.radio1) ? 'LIVE' : 'OFF',
open: live(s.open && s.open.radio0) || live(s.open && s.open.radio1) ? 'LIVE' : 'OFF',
enterprise: live(s.enterprise && s.enterprise.ap) ? 'LIVE' : 'OFF'
};
Object.keys(summary).forEach((k) => {
statusEls[k].textContent = summary[k];
statusEls[k].className = 'badge ' + (summary[k] === 'LIVE' ? 'on' : 'off');
});
}).catch(() => {});
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
function attackLauncher(kind, opts) {
return (root) => {
const box = attacksShell(root, '#/attacks/' + kind);
const box = pineapShell(root, opts.tabHash || '#/pineap/evilwpa');
const form = h('div', { class: 'pineap-title-card' });
form.appendChild(h('div', { class: 'pineap-card-title' },
opts.title + (opts.subtitle ? ' — ' + opts.subtitle : '')));
@@ -1500,7 +1156,12 @@ views.harness = (root) => {
const tok = h('code', { style: 'font-size:12px', text: '…' });
const endpoint = h('code', { style: 'font-size:12px', text: location.origin + '/mcp' });
infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Endpoint' }), endpoint));
infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok));
infoBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok,
h('div', {}, btn('Copy Token', () => {
navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied'))
.catch(() => App.toast('Copy failed', 'error'));
}))));
infoBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', text: 'Agents call POST /mcp with JSON-RPC 2.0 (MCP Streamable HTTP). The token is the current session token.' }));
const snippet = h('pre', { style: 'font-size:12px;overflow:auto;background:rgba(127,127,127,.12);padding:10px;border-radius:4px;white-space:pre-wrap' });
@@ -1510,38 +1171,6 @@ views.harness = (root) => {
capBox.appendChild(capBody);
box.appendChild(capBox);
const promptBox = h('div', { class: 'pineap-title-card' });
promptBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Prompt for pi.dev'));
const promptArea = h('textarea', { rows: 14, style: 'width:100%;font-family:monospace;font-size:12px;box-sizing:border-box' });
promptBox.appendChild(promptArea);
promptBox.appendChild(h('div', { class: 'row', style: 'margin-top:8px' },
h('div', {}, btn('Copy Prompt', () => {
promptArea.select();
document.execCommand('copy');
App.toast('Copied');
})),
h('div', {}, btn('Copy Token', () => {
navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied'))
.catch(() => App.toast('Copy failed', 'error'));
}))));
box.appendChild(promptBox);
function buildPrompt(token) {
return 'You are driving a WiFi Pineapple Pager (FENRIS firmware) through its local MCP harness.\n' +
'Endpoint: ' + location.origin + '/mcp (Streamable HTTP, POST JSON-RPC 2.0).\n' +
'Authorization: Bearer ' + token + '\n\n' +
'Before acting, read these resources (MCP resources/read) — they are the field-verified operating manual:\n' +
' skills://pineapple-control (device access, radios, UCI truth, pineapd crash-loop fix)\n' +
' skills://wifi-deauth (deauth + handshake methodology, PMKSA failure modes)\n' +
' skills://aircrack-suite (hashcat handoff)\n\n' +
'Rules:\n' +
'1. The DEVICE is the source of truth: read device.state / UCI before and after every change; never assume.\n' +
'2. Only attack the network the operator explicitly authorized (currently <authorized-test-ssid>). No deauth blasts — short targeted bursts.\n' +
'3. After attack.deploy, verify with attack.status (live flag) before proceeding.\n' +
'4. Use the playbook prompts (prompts/get): evil-wpa-attack, evil-enterprise-attack, recon-survey.\n' +
'5. Report verified outcomes only; say what you changed on the device.';
}
function load() {
PagerAPI.get('/api/harness/capabilities').then((r) => {
const d = r.data || {};
@@ -1562,7 +1191,6 @@ views.harness = (root) => {
' -H "Content-Type: application/json" \\\n' +
' -H "Authorization: Bearer ' + t + '" \\\n' +
' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\'';
promptArea.value = buildPrompt(t);
infoBody.appendChild(snippet);
}).catch(() => {});
}
@@ -1633,53 +1261,8 @@ function deauthPanel(ssidRef) {
return wrap;
}
views.attacks_wpa = attackLauncher('wpa', {
title: 'Evil WPA',
subtitle: 'WPA2-PSK / WPA3-SAE / WPA3-OWE evil twin with handshake capture',
passphrase: true,
encodings: EVIL_ENC,
handshakes: true,
export: true,
deauth: true,
playbook: {
steps: ['Deploy the evil twin',
'Wait for a client to associate',
'Deauth the target client to force the 4-way',
'Export .hc22000 and crack with hashcat'],
currentStep: (s, w) => {
if (!w || !w.enabled) return 'Deploy the evil twin';
if (!s || !(s.handshakes > 0)) return 'Wait for a client to associate';
return 'Export .hc22000 and crack with hashcat';
},
hint: (s, w) => {
if (!w || !w.enabled) return '1. Set the target SSID and passphrase, pick a channel, Deploy. 2. When the target client is near, use Deauth Targeting below. 3. Captured handshakes appear above — Export and run the hashcat command.';
if (s && s.handshakes > 0) return 'Handshake captured! Export .hc22000 and run hashcat -m 22000.';
return 'AP is live on ' + (w.ssid || 'the target') + '. Watch the handshakes list — use Deauth Targeting to nudge the client. If a client refuses to join the twin, its reconnect to the real AP is still captured passively.';
}
}
});
views.attacks_open = attackLauncher('open', {
title: 'Evil Open',
subtitle: 'Open network evil twin',
bssid: true,
country: true,
playbook: {
steps: ['Deploy the open AP',
'Wait for clients to associate',
'Watch connected clients under PineAP → Clients'],
currentStep: (s, w) => {
if (!w || !w.enabled) return 'Deploy the open AP';
return 'Wait for clients to associate';
},
hint: (s, w) => !w || !w.enabled
? 'Set the SSID (optionally spoof a BSSID), pick a channel, Deploy.'
: 'Open AP is live on ' + (w.ssid || 'the target') + ' — clients that join appear in the Clients list.'
}
});
views.attacks_enterprise = (root) => {
const box = attacksShell(root, '#/attacks/enterprise');
views.pineap_enterprise = (root) => {
const box = pineapShell(root, '#/pineap/enterprise');
const form = h('div', { class: 'pineap-title-card' });
form.appendChild(h('div', { class: 'pineap-card-title' },
'Evil Enterprise — WPA2/3-Enterprise with PineAPE credential harvest'));
@@ -1692,15 +1275,19 @@ views.attacks_enterprise = (root) => {
.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
const pskIn = h('input', { id: 'ent-pass', type: 'password', autocomplete: 'new-password' });
const hiddenCb = h('input', { type: 'checkbox', id: 'ent-hidden' });
const chanSel = h('select', { id: 'ent-channel' });
chanSelect(chanSel, null);
f.appendChild(h('label', {}, 'SSID', ssidIn));
f.appendChild(h('label', {}, 'Encryption', encSel));
f.appendChild(h('label', {}, 'Passphrase (EAP server secret)', pskIn));
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
f.appendChild(h('label', {}, 'Channel (5 GHz only — Auto uses the target SSID\u2019s recon channel)', chanSel));
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, btn('Deploy Attack', () => {
PagerAPI.post('/api/attacks/deploy', {
kind: 'enterprise', ssid: ssidIn.value.trim(),
enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked
enctype: encSel.value, passphrase: pskIn.value, hidden: hiddenCb.checked,
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
}).then((r) => { verifiedToast(r.data || {}); load(); })
.catch((e) => App.toast(e.message || 'Deploy failed', 'error'));
})),
@@ -1779,11 +1366,15 @@ views.attacks_enterprise = (root) => {
};
function reconEncBucket(enc) {
const s = (enc || '').trim();
if (s === 'Open') return 'Open';
if (s.indexOf('Enterprise') !== -1) return 'Enterprise';
if (!s || s === 'Open') return 'Open';
if (s.indexOf('WEP') !== -1) return 'WEP';
if (s.indexOf('WPA2') !== -1) return 'WPA2';
if (s.indexOf('WPA3') !== -1) return 'WPA3';
if (s.indexOf('Enterprise') !== -1) {
return s.indexOf('WPA3') !== -1 ? 'WPA3-Enterprise' : 'WPA2-Enterprise';
}
if (s.indexOf('SAE') !== -1 || s.indexOf('OWE') !== -1) return 'WPA3-Personal';
if (s.indexOf('WPA3') !== -1 && s.indexOf('WPA2') !== -1) return 'WPA2-PSK';
if (s.indexOf('WPA3') !== -1) return 'WPA3-PSK';
if (s.indexOf('WPA2') !== -1) return 'WPA2-PSK';
if (s.indexOf('WPA') !== -1) return 'WPA';
return s || 'Unknown';
}
@@ -1878,8 +1469,6 @@ views.recon = (root) => {
hsCol.appendChild(hsAuto);
const psContent = titleCard('Previous Scans', false);
const psRow = h('div', { class: 'recon-ps-row' });
psContent.appendChild(psRow);
let pickerOptions = [];
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
sel.addEventListener('change', () => {
@@ -1891,7 +1480,6 @@ views.recon = (root) => {
state.detailId = null; state.detailArchive = null;
loadDetail();
});
psRow.appendChild(sel);
function dlBase() {
if (state.selected == null) return null;
return state.archive
@@ -1910,9 +1498,6 @@ views.recon = (root) => {
const base = dlBase();
if (base) window.location = base + '/download/html';
});
psRow.appendChild(dlJson);
psRow.appendChild(dlCsv);
psRow.appendChild(dlHtml);
const delBtn = iconBtn('delete', 'Delete scan', () => {
if (state.selected == null || state.archive) return;
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
@@ -1920,7 +1505,26 @@ views.recon = (root) => {
.then(() => { App.toast('Scan deleted'); load(); })
.catch(() => App.toast('Delete failed', 'error'));
});
psRow.appendChild(delBtn);
const delAllBtn = iconBtn('delete_forever', 'Delete all scans', () => {
if (!confirm('Delete ALL recorded scans? This cannot be undone.')) return;
PagerAPI.del('/api/recon/scans')
.then((r) => {
App.toast('All scans deleted' + (r.data && r.data.deleted ? ' (' + r.data.deleted + ')' : ''));
state.selected = null; state.archive = null;
load();
})
.catch(() => App.toast('Delete failed', 'error'));
});
const psActions = h('div', { class: 'row', style: 'margin:6px 0 8px' });
psActions.appendChild(dlJson);
psActions.appendChild(dlCsv);
psActions.appendChild(dlHtml);
psActions.appendChild(delBtn);
psActions.appendChild(delAllBtn);
psContent.appendChild(psActions);
const psRow = h('div', { class: 'recon-ps-row' });
psContent.appendChild(psRow);
psRow.appendChild(sel);
// ---- scan bar ----
const scanBar = h('div', { class: 'section recon-scan-bar' });
@@ -2280,7 +1884,8 @@ views.recon = (root) => {
const groups = [
['Band', 'apBand', [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']]],
['Encryption', 'apEnc', [['all', 'All'], ['Open', 'Open'], ['WEP', 'WEP'], ['WPA', 'WPA'],
['WPA2', 'WPA2'], ['WPA3', 'WPA3'], ['Enterprise', 'Enterprise']]]
['WPA2-PSK', 'WPA2-PSK'], ['WPA2-Enterprise', 'WPA2-Enterprise'],
['WPA3-PSK', 'WPA3-Personal'], ['WPA3-Enterprise', 'WPA3-Enterprise']]]
];
groups.forEach(([label, key, opts]) => {
chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label }));
@@ -2342,21 +1947,12 @@ views.recon = (root) => {
function filteredRows(key) {
const d = state.detail || {};
if (key === 'client') {
if (state.compare.length) return [];
return reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
}
// Comparing never hides the list: selection just adds chips, a compare
// table and charts. The full AP set stays visible so more boxes can be
// ticked without clearing the selection first.
const all = d.aps || [];
if (state.compare.length) {
if (state.apSearch) {
// Candidate list: search the full AP list so more networks can be
// added without clearing the selection. Band/enc chips apply here.
let out = reconFiltered(all, state.apSearch, RECON_AP_COLS);
if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
return out;
}
return all.filter((a) => state.compare.indexOf(a.bssid) !== -1);
}
let out = reconFiltered(all, state.apSearch, RECON_AP_COLS);
if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
@@ -2408,7 +2004,8 @@ views.recon = (root) => {
const slice = rows.slice(start, start + per);
const rowAttrs = key === 'ap'
? (r) => ({
class: state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '',
class: (state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '')
+ (state.compare.indexOf(r.bssid) !== -1 ? ' recon-row-compare' : ''),
style: 'cursor:pointer',
onclick: () => toggleFocus(r)
})
@@ -2475,12 +2072,10 @@ views.recon = (root) => {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
const apF = filteredRows('ap');
const cliF = filteredRows('client');
cliCard.classList.toggle('hidden', state.compare.length > 0);
cliCard.classList.remove('hidden');
renderTable(apBody, 'ap', sortRows(apF, 'ap', apCols), apCols,
state.compare.length && !state.apSearch ? 'No access points selected.' : 'No access points in this scan.');
if (!state.compare.length) {
renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
}
'No access points in this scan.');
renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
}
function drawCharts(d) {
@@ -2756,24 +2351,34 @@ views.recon_reports = (root) => {
reportCard.appendChild(h('h2', { text: 'Scan Reports' }));
const box = h('div');
reportCard.appendChild(box);
PagerAPI.get('/api/recon/scans').then((r) => {
const scans = (r.data && r.data.scans) || [];
box.innerHTML = '';
if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; }
box.appendChild(table(
[
{ key: 'id', label: 'Scan', render: (s) => '#' + s.id },
{ key: 'time', label: 'Started', render: (s) => fmtTime(s.time) },
{ key: 'aps', label: 'APs' },
{ key: 'devices', label: 'Clients' },
{ key: 'handshakes', label: 'Handshakes' },
{ key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' },
iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')),
iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')),
iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) }
],
scans));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' })));
let gpsFix = null;
PagerAPI.get('/api/recon/gps').then((r) => {
const g = r.data || {};
gpsFix = g.lock ? { lat: g.lat, lon: g.lon, sats: g.satellites } : null;
}).catch(() => {}).then(() => {
PagerAPI.get('/api/recon/scans').then((r) => {
const scans = (r.data && r.data.scans) || [];
box.innerHTML = '';
if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; }
const gpsCell = (s) => gpsFix
? h('span', { class: 'recon-gps-cell', text: Number(gpsFix.lat).toFixed(5) + ', ' + Number(gpsFix.lon).toFixed(5) })
: h('span', { class: 'muted', text: '' });
box.appendChild(table(
[
{ key: 'id', label: 'Scan', render: (s) => '#' + s.id },
{ key: 'time', label: 'Started', render: (s) => fmtTime(s.time) },
{ key: 'gps', label: 'GPS', render: gpsCell },
{ key: 'aps', label: 'APs' },
{ key: 'devices', label: 'Clients' },
{ key: 'handshakes', label: 'Handshakes' },
{ key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' },
iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')),
iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')),
iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) }
],
scans));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' })));
});
}
function renderWigle() {