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
@@ -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() {