Files
Mark-VIII/payload/user/remote_access/pager-webui/www/js/views.js
T
bzuccaro e1eba04775 feat: attacks workflow — playbook steps + auto-targeting deauth panel
Evil WPA/Open pages now show a state-aware playbook (current step
highlighted, contextual hint) and the Deauth Targeting panel
auto-fills the live attack SSID and refreshes clients automatically.
Enterprise page gains a playbook hint card.
2026-08-18 21:43:05 -05:00

3771 lines
173 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const views = {};
const h = (tag, attrs, ...children) => {
const n = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === 'class') n.className = attrs[k];
else if (k === 'text') n.textContent = attrs[k];
else if (k === 'html') n.innerHTML = attrs[k];
else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, attrs[k]);
}
}
for (const c of children) {
if (c == null) continue;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return n;
};
const table = (columns, rows, rowAttrs) => {
const t = h('table', { class: 'tbl' });
const thead = h('thead'), tr = h('tr');
columns.forEach((c) => tr.appendChild(h('th', { text: c.label })));
thead.appendChild(tr); t.appendChild(thead);
const tb = h('tbody');
(rows || []).forEach((r) => {
const trr = h('tr', rowAttrs ? rowAttrs(r) : {});
columns.forEach((c) => {
const v = c.render ? c.render(r) : r[c.key];
const td = h('td');
if (v == null) td.textContent = '';
else if (typeof v === 'string' || typeof v === 'number') td.textContent = String(v);
else td.appendChild(v);
trr.appendChild(td);
});
tb.appendChild(trr);
});
t.appendChild(tb);
return t;
};
const fmtTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
return d.toLocaleString();
};
const fmtShortTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
const now = new Date();
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (sameDay) return hm;
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
};
const fmtDur = (secs) => {
if (secs == null) return '--';
const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600),
mm = Math.floor((secs % 3600) / 60);
return (d ? d + 'd ' : '') + hh + 'h ' + mm + 'm';
};
const fmtBytes = (bytes) => {
if (bytes == null || !isFinite(Number(bytes))) return '--';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = Number(bytes), unit = 0;
while (Math.abs(value) >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return value.toFixed(unit < 2 ? 0 : 1) + ' ' + units[unit];
};
const downloadText = (filename, text) => {
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
const a = h('a', { href: url, download: filename });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' });
const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label });
const iconBtn = (name, title, onclk) => {
const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk });
b.innerHTML = PineappleIcons[name] || '';
return b;
};
const tabBar = (box, items, activeHash) => {
const bar = h('div', { class: 'tabbar' });
items.forEach((it) => {
const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label });
t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
bar.appendChild(t);
});
box.appendChild(bar);
return bar;
};
views.dashboard = (root) => {
const history = { clients: [] };
const max = 60;
const title = h('h1', { class: 'page-title', text: 'Dashboard' });
root.appendChild(title);
const grid = h('div', { class: 'cards' });
root.appendChild(grid);
const defs = [
['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'],
['disk', 'Disk Usage'], ['uptime', 'Uptime']
];
const cards = {};
defs.forEach(([k, label]) => {
const card = h('div', { class: 'card' },
h('div', { class: 'card-label', text: label }),
h('div', { class: 'card-value', text: 'Loading…' }));
grid.appendChild(card);
cards[k] = card.querySelector('.card-value');
});
const live = h('div', { class: 'cards', style: 'margin-top:8px' });
root.appendChild(live);
const liveCards = {};
[['attacks', 'Attacks'], ['health', 'PineAPd / Monitors'], ['recon', 'Recon']]
.forEach(([k, label]) => {
const card = h('div', { class: 'card' },
h('div', { class: 'card-label', text: label }),
h('div', { class: 'card-value', style: 'font-size:12px;line-height:1.8', text: 'Loading…' }));
live.appendChild(card);
liveCards[k] = card.querySelector('.card-value');
});
function loadLive() {
PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {};
const st = (x) => (x && x.enabled ? (x.live ? 'LIVE' : 'ON') : 'off');
const wpa = st(s.wpa && s.wpa.radio0) === 'LIVE' || st(s.wpa && s.wpa.radio1) === 'LIVE'
? 'LIVE' : (st(s.wpa && s.wpa.radio0) !== 'off' || st(s.wpa && s.wpa.radio1) !== 'off' ? 'ON' : 'OFF');
const open = st(s.open && s.open.radio0) === 'LIVE' || st(s.open && s.open.radio1) === 'LIVE'
? 'LIVE' : (st(s.open && s.open.radio0) !== 'off' || st(s.open && s.open.radio1) !== 'off' ? 'ON' : 'OFF');
const ent = s.enterprise && s.enterprise.ap
? (s.enterprise.ap.live ? 'LIVE' : s.enterprise.ap.enabled ? 'ON' : 'OFF') : 'OFF';
liveCards.attacks.textContent = 'WPA ' + wpa + ' · Open ' + open + ' · Ent ' + ent +
' · HS ' + (s.handshakes || 0) + ' · creds ' + ((s.enterprise || {}).creds || 0);
liveCards.attacks.style.color = wpa === 'LIVE' || open === 'LIVE' || ent === 'LIVE'
? 'var(--primary)' : '';
}).catch(() => {});
PagerAPI.get('/api/health').then((r) => {
const h2 = r.data || {};
liveCards.health.textContent = (h2.pineap_up ? 'pineapd up' : 'pineapd DOWN') +
' · wlan0mon ' + (h2.wlan0mon_up ? 'up' : 'down') +
' · wlan1mon ' + (h2.wlan1mon_up ? 'up' : 'down') +
(h2.pool_disabled ? ' · pool off' : '');
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const s = r.data || {};
const last = s.last_activity || s.last_scan;
liveCards.recon.textContent = (s.scanning ? 'scanning' : 'idle') +
(last ? ' · last activity ' + fmtTime(last) : '');
}).catch(() => {});
}
loadLive();
const liveIv = setInterval(loadLive, 10000);
const chartBox = h('div', { class: 'section' },
h('h2', {}, 'Clients'),
h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' }));
root.appendChild(chartBox);
const canvas = chartBox.querySelector('#dash-chart');
const update = (msg) => {
const s = msg.status || {};
const n = (msg.clients || []).length;
history.clients.push(n);
if (history.clients.length > max) { history.clients.shift(); }
cards.clients.textContent = n;
cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime);
cards.disk.textContent = s.disk && s.disk.size != null
? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable';
if (typeof MiniChart !== 'undefined') {
MiniChart.draw(canvas, [
{ label: 'Clients', color: '#1976d2', points: history.clients }
]);
}
};
const unsubscribe = Live.onTick(update);
PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients }))
.catch(() => {
cards.clients.textContent = 'Unavailable';
cards.disk.textContent = 'Unavailable';
cards.uptime.textContent = 'Unavailable';
});
PagerAPI.get('/api/pineap/handshakes').then((r) => {
cards.handshakes.textContent = (r.data.files || []).length;
}).catch(() => { cards.handshakes.textContent = 'Unavailable'; });
const clBody = h('div', {});
const clBox = h('div', { class: 'section' },
h('h2', {}, 'Connected Clients'), clBody);
root.appendChild(clBox);
const hsBody = h('div', {});
const hsBox = h('div', { class: 'section' },
h('h2', {}, 'Captured WPA Handshakes'), hsBody);
root.appendChild(hsBox);
function loadClients() {
PagerAPI.get('/api/pineap/clients').then((r) => {
clBody.innerHTML = '';
clBody.appendChild(table(
[{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' },
{ label: '', render: () => '' }],
r.data.clients,
(c) => ({ style: 'cursor:pointer',
onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } })));
const cols = ['MAC', 'Interface', 'RSSI'];
clBody.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; });
}).catch(() => { clBody.textContent = 'Unable to load connected clients.'; });
}
function loadHandshakes() {
PagerAPI.get('/api/pineap/handshakes').then((r) => {
hsBody.innerHTML = '';
hsBody.appendChild(table(
[{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }],
(r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) }))));
}).catch(() => { hsBody.textContent = 'Unable to load captured handshakes.'; });
}
loadClients();
loadHandshakes();
const iv = setInterval(loadClients, 10000);
return { destroy: () => { clearInterval(iv); clearInterval(liveIv); unsubscribe(); } };
};
const PINEAP_TABS = [
{ label: 'PineAP', hash: '#/pineap' },
{ label: 'Open AP', hash: '#/pineap/open' },
{ label: 'Evil WPA', hash: '#/pineap/evilwpa' },
{ label: 'Impersonation', hash: '#/pineap/impersonation' },
{ label: 'Clients', hash: '#/pineap/clients' },
{ label: 'Filtering', hash: '#/pineap/filtering' }
];
// The Pager daemon can change these states but cannot read them back. Keep
// them explicitly unknown until this WebUI successfully changes them.
const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null };
function setKnownCheckbox(cb, value) {
cb.indeterminate = value == null;
if (value != null) cb.checked = !!value;
}
function pineapShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' }));
tabBar(root, PINEAP_TABS, activeHash);
const box = h('div', {});
root.appendChild(box);
return box;
}
function pineapTitleCard(titleText, linkHash, valueNode) {
const title = linkHash
? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText)
: titleText;
return h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title' }, title),
h('div', { class: 'pineap-card-title-content' }, valueNode));
}
views.pineap = (root) => {
const box = pineapShell(root, '#/pineap');
const stats = {};
const statWrap = h('div', { class: 'pineap-title-card-container' });
const statDefs = [
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
['clients', 'Clients Connected', '#/pineap/clients'],
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
];
statDefs.forEach(([k, label, hash]) => {
const val = h('span', { text: '—' });
stats[k] = val;
statWrap.appendChild(pineapTitleCard(label, hash, val));
});
box.appendChild(statWrap);
const mode = h('span', { class: 'badge', text: '—' });
let selectedMode = 'unknown';
let modeDirty = false;
let modePending = false;
const segBtns = {};
const modeBar = h('div', { class: 'seg' });
['passive', 'active', 'advanced'].forEach((m) => {
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
b.addEventListener('click', () => selectMode(m, true));
modeBar.appendChild(b);
segBtns[m] = b;
});
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
const saveModeBtn = btn('Save Mode', saveMode, 'ghost');
saveModeBtn.disabled = true;
const modeCard = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title-flex' }, mode),
h('div', { class: 'pineap-card-button-group' }, modeBar),
modeInfo,
h('div', { class: 'pineap-mode-save' }, saveModeBtn));
const quick = {
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
};
const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
h('div', { class: 'pineap-card-title' }, 'Quick Settings'));
quickCard.appendChild(h('label', { class: 'switch' }, quick.collect, h('span', { class: 'track' }), 'Capture SSIDs to Pool'));
quickCard.appendChild(h('label', { class: 'switch' }, quick.advertise, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool'));
const poolNotice = h('div', { class: 'pineap-infobox warn', style: 'margin-top:8px',
text: 'Pool broadcast disabled: it segfaults pineapd on this firmware (crash-loop fix).' });
quickCard.appendChild(poolNotice);
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
const modeRow = h('div', { class: 'pineap-title-card-container' });
modeRow.appendChild(modeCard);
modeRow.appendChild(quickCard);
box.appendChild(modeRow);
const cards = { karma: {}, open: {}, wpa: {} };
const cardWrap = h('div', { class: 'pineap-title-card-container' });
const statusDefs = [
['karma', 'Karma', '#/pineap/open'],
['open', 'Open Network', '#/pineap/open'],
['wpa', 'Evil WPA', '#/pineap/evilwpa']
];
statusDefs.forEach(([k, label, hash]) => {
const val = h('span', { text: '—' });
cards[k].value = val;
cardWrap.appendChild(pineapTitleCard(label, hash, val));
});
box.appendChild(cardWrap);
function bind(cb, on, remember) {
cb.addEventListener('change', () => {
const requested = cb.checked;
cb.indeterminate = false;
on(requested).then(() => {
if (remember) remember(requested);
load();
}).catch((e) => {
cb.checked = !requested;
load();
App.toast((e && e.message) || 'Failed', 'error');
});
});
}
function rememberAdvanced(key, value) {
PINEAP_SESSION[key] = value;
PINEAP_SESSION.mode = 'advanced';
modeDirty = false;
selectMode('advanced', false);
}
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }),
(v) => rememberAdvanced('collect', v));
bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }),
(v) => rememberAdvanced('advertise', v));
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
function renderModeInfo(m) {
modeInfo.innerHTML = '';
if (m === 'unknown') {
modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.';
return;
}
const descriptions = {
passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'],
active: ['Capture SSIDs to the impersonation pool', 'Enable the PineAP response engine', 'Pool broadcast stays disabled (firmware crash fix)']
};
if (m === 'advanced') {
modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.';
return;
}
modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' }));
const list = h('ul', { class: 'pineap-mode-features' });
descriptions[m].forEach((text) => list.appendChild(h('li', { text })));
modeInfo.appendChild(list);
}
function selectMode(m, dirty) {
selectedMode = m;
if (dirty) modeDirty = true;
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1);
mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on');
renderModeInfo(m);
saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown';
}
function saveMode() {
if (!modeDirty || modePending || selectedMode === 'unknown') return;
modePending = true;
saveModeBtn.disabled = true;
saveModeBtn.classList.add('busy');
PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => {
const state = r.data || {};
PINEAP_SESSION.mode = state.mode || selectedMode;
['karma', 'advertise', 'collect'].forEach((key) => {
if (typeof state[key] === 'boolean') PINEAP_SESSION[key] = state[key];
});
modeDirty = false;
selectMode(PINEAP_SESSION.mode, false);
App.toast('Mode: ' + PINEAP_SESSION.mode[0].toUpperCase() + PINEAP_SESSION.mode.slice(1));
load();
}).catch(() => { App.toast('Failed to save PineAP mode', 'error'); })
.finally(() => {
modePending = false;
saveModeBtn.classList.remove('busy');
saveModeBtn.disabled = !modeDirty;
});
}
let loadPending = false;
function load() {
if (loadPending) return;
loadPending = true;
const stateRequest = Promise.all([
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} }))
]).then(([cfg, host, ap, preset]) => {
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {};
const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null;
const wpa = a.wpa || {};
PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown';
['karma', 'advertise', 'collect'].forEach((key) => {
if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key];
});
if (!modeDirty) selectMode(PINEAP_SESSION.mode, false);
mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown');
const collect = typeof PINEAP_SESSION.collect === 'boolean'
? PINEAP_SESSION.collect
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
setKnownCheckbox(quick.collect, collect);
const pool = a.pool || {};
const poolDisabled = pool.disabled === true;
poolNotice.classList.toggle('hidden', !poolDisabled);
quick.advertise.disabled = poolDisabled;
quick.advertise.title = poolDisabled ? 'Disabled by firmware crash fix' : '';
if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false;
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
cards.karma.value.textContent = PINEAP_SESSION.karma == null ? 'Unknown' : (PINEAP_SESSION.karma ? 'On' : 'Off');
const open = a.open || {};
cards.open.value.textContent = open.enabled == null ? 'Unavailable' : (open.enabled ? 'On' : 'Off');
cards.wpa.value.textContent = wpa.enabled == null ? 'Unavailable' : (wpa.enabled ? 'On' : 'Off');
});
const statRequests = [
PagerAPI.get('/api/pineap/ssids').then((ss) => {
stats.ssids.textContent = Array.isArray((ss.data || {}).ssids) ? ss.data.ssids.length : 'Unavailable';
}).catch(() => { stats.ssids.textContent = 'Unavailable'; }),
PagerAPI.get('/api/pineap/clients').then((cl) => {
stats.clients.textContent = typeof (cl.data || {}).count === 'number' ? cl.data.count : 'Unavailable';
}).catch(() => { stats.clients.textContent = 'Unavailable'; }),
PagerAPI.get('/api/pineap/handshakes').then((hs) => {
stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable';
}).catch(() => { stats.handshakes.textContent = 'Unavailable'; })
];
Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; });
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
const BAND_GROUPS = [
{ band: '2.4', label: '2.4 GHz', dfs: false,
channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
{ band: '5', label: '5 GHz', dfs: true,
channels: [36, 40, 44, 48, 52, 56, 60, 64,
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
149, 153, 157, 161, 165] },
{ band: '6', label: '6 GHz (WPA3/OWE only)', dfs: false,
channels: Array.from({ length: 14 }, (_, i) => 181 + i * 4) }
];
const DFS_SET = new Set([52, 56, 60, 64, 100, 104, 108, 112, 116, 120,
124, 128, 132, 136, 140, 144]);
function chanFreq(band, ch) {
if (band === '2.4') return 2412 + (ch - 1) * 5;
if (band === '5') return 5180 + (ch - 36) * 5;
return 5955 + (ch - 1) * 5; // 6 GHz
}
function chanLabel(band, ch) {
return 'Channel ' + ch + ' (' + chanFreq(band, ch) + ' MHz)'
+ (band === '5' && DFS_SET.has(ch) ? ' (DFS)' : '');
}
function chanSelect(sel, value) {
BAND_GROUPS.forEach((g) => {
const og = h('optgroup', { label: g.label });
g.channels.forEach((ch) => {
og.appendChild(h('option', { value: ch, text: chanLabel(g.band, ch) }));
});
sel.appendChild(og);
});
if (value != null) {
const opts = Array.prototype.slice.call(sel.options);
const hit = opts.find((o) => Number(o.value) === Number(value));
if (hit) sel.value = hit.value;
}
return sel;
}
function bandOfChannel(ch) {
if (ch == null) return '2.4';
ch = Number(ch);
if (ch >= 1 && ch <= 14) return '2.4';
if (ch >= 36 && ch <= 177) return '5';
return '6';
}
const OPEN_COUNTRIES = [
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'],
['CR', 'Costa Rica'], ['CY', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'],
['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'],
['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'],
['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'],
['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'],
['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'],
['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'],
['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'],
['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'],
['PL', 'Poland'], ['PT', 'Portugal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'],
['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'],
['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'],
['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'],
['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'],
['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uruguay'],
['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()));
}
}
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();
});
}
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');
const poolCount = h('span', { text: '—' });
const countRow = h('div', { class: 'pineap-title-card-container' },
pineapTitleCard('Total SSIDs in Pool', '#/pineap/impersonation', poolCount));
box.appendChild(countRow);
const input = h('input', { id: 'imp-ssid' });
const list = h('div', {});
const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' });
const colCb = h('input', { type: 'checkbox', id: 'imp-collect' });
const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' },
h('div', { class: 'pineap-card-title' }, 'SSID Pool'));
poolBox.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'SSID', input)),
h('div', {}, btn('Add', () => {
const v = input.value.trim(); if (!v) return;
PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); App.toast('Added'); });
})),
h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger'))));
poolBox.appendChild(h('label', { class: 'switch' }, advCb, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool'));
poolBox.appendChild(h('label', { class: 'switch' }, colCb, h('span', { class: 'track' }), 'Capture SSIDs to Pool'));
poolBox.appendChild(list);
box.appendChild(poolBox);
setKnownCheckbox(advCb, PINEAP_SESSION.advertise);
advCb.addEventListener('change', () => {
const requested = advCb.checked;
advCb.indeterminate = false;
PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => {
PINEAP_SESSION.advertise = requested;
load();
}).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); });
});
colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); }));
function render(ssids) {
poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0;
list.innerHTML = '';
list.appendChild(table(
[{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }],
(ssids || []).map((s) => ({ ssid: s })),
(r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } })));
list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; });
if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No SSIDs in pool.' }));
}
function load() {
PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {});
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
const p = (r.data || {}).pool || {};
if (p.disabled != null) PINEAP_SESSION.advertise = p.disabled === false;
setKnownCheckbox(advCb, PINEAP_SESSION.advertise);
setKnownCheckbox(colCb, Object.prototype.hasOwnProperty.call(p, 'collecting') ? !!p.collecting : null);
}).catch(() => {});
}
load();
return { destroy: () => {} };
};
views.pineap_clients = (root) => {
const box = pineapShell(root, '#/pineap/clients');
const state = { clients: [] };
const count = h('span', { text: '—' });
const countRow = h('div', { class: 'pineap-title-card-container' },
pineapTitleCard('Clients Connected', '#/pineap/clients', count));
box.appendChild(countRow);
const body = h('div', {});
const tableCard = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title-flex' },
h('span', { text: 'Connected Clients' }),
h('span', { class: 'toolbar-spacer' }),
btn('Refresh', load, 'ghost')),
body);
box.appendChild(tableCard);
function render() {
body.innerHTML = '';
body.appendChild(table(
[{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' },
{ label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }],
state.clients,
(r) => ({ style: 'cursor:pointer',
onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } })));
const cols = ['MAC', 'Interface', 'RSSI'];
body.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; });
}
let pending = false;
function load() {
if (pending) return;
pending = true;
PagerAPI.get('/api/pineap/clients').then((r) => {
state.clients = r.data.clients || [];
count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : '—';
render();
}).catch(() => { count.textContent = 'Unavailable'; body.textContent = 'Unable to load connected clients.'; })
.finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
views.pineap_filtering = (root) => {
const box = pineapShell(root, '#/pineap/filtering');
function filterCard(title, kind) {
const path = '/api/pineap/filters/' + kind;
const noun = kind === 'client' ? 'client MACs' : 'SSIDs';
const singular = kind === 'client' ? 'client MAC' : 'SSID';
const state = { mode: 'deny', entries: [], pending: false };
const allowDefault = h('button', { class: 'seg-btn', text: 'Allow by default' });
const denyDefault = h('button', { class: 'seg-btn', text: 'Deny by default' });
const modeBar = h('div', { class: 'seg filter-mode-seg' }, allowDefault, denyDefault);
const explanation = h('div', { class: 'pineap-filter-description' });
const valueIn = h('input', { id: 'fv-' + kind, autocomplete: 'off' });
const valueLabel = h('label', {}, h('span', { class: 'pineap-filter-input-label' }), valueIn);
const list = h('div', {});
const addButton = btn('Add', addEntry);
const clearButton = btn('Clear current list', clearCurrent, 'danger');
const allowAllButton = btn('Allow all', allowAll, 'ghost');
const card = h('div', { class: 'pineap-title-card pineap-filter-card' },
h('div', { class: 'pineap-card-title' }, title),
h('div', { class: 'pineap-settings-section', text: 'Default behavior' }),
modeBar,
explanation,
h('div', { class: 'row pineap-filter-entry-row' },
h('div', { class: 'pineap-filter-value' }, valueLabel),
h('div', {}, addButton)),
h('div', { class: 'pineap-filter-actions' }, allowAllButton, clearButton),
list);
box.appendChild(card);
function setPending(value) {
state.pending = value;
[allowDefault, denyDefault, addButton, clearButton, allowAllButton, valueIn]
.forEach((el) => { el.disabled = value; });
}
function applyResponse(r) {
const data = (r && r.data) || {};
state.mode = data.mode === 'allow' ? 'allow' : 'deny';
state.entries = Array.isArray(data.entries) ? data.entries : [];
render();
}
function fail(message) {
App.toast(message, 'error');
}
function mutate(payload, success) {
if (state.pending) return;
setPending(true);
PagerAPI.post(path, Object.assign({ mode: state.mode }, payload))
.then((r) => { applyResponse(r); if (success) App.toast(success); })
.catch(() => fail('Failed to update ' + title.toLowerCase()))
.finally(() => setPending(false));
}
function setMode(mode) {
if (state.mode === mode || state.pending) return;
mutate({ action: 'set_mode', mode }, mode === 'deny'
? title + ': allowing by default'
: title + ': denying by default');
}
function addEntry() {
const value = valueIn.value.trim();
if (!value) { fail(singular + ' required'); return; }
mutate({ action: 'add', value }, singular + ' added');
valueIn.value = '';
}
function removeEntry(value) {
if (confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) {
mutate({ action: 'delete', value }, singular + ' removed');
}
}
function clearCurrent() {
if (confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) {
mutate({ action: 'clear' }, 'Current list cleared');
}
}
function allowAll() {
if (confirm('Allow all ' + noun + '? This clears the deny list and changes the default behavior to allow.')) {
mutate({ action: 'allow_all' }, 'All ' + noun + ' allowed');
}
}
function render() {
const isDenyList = state.mode === 'deny';
allowDefault.classList.toggle('active', isDenyList);
denyDefault.classList.toggle('active', !isDenyList);
explanation.textContent = isDenyList
? 'New ' + noun + ' are allowed. Only entries in the deny list are blocked.'
: 'New ' + noun + ' are blocked. Only entries in the allow list are permitted.';
valueLabel.querySelector('.pineap-filter-input-label').textContent =
'Add to ' + (isDenyList ? 'deny' : 'allow') + ' list';
valueIn.placeholder = kind === 'client' ? 'AA:BB:CC:DD:EE:FF' : 'Network name';
list.innerHTML = '';
list.appendChild(table(
[{ label: (isDenyList ? 'Denied ' : 'Allowed ') + singular, key: 'value' },
{ label: '', render: () => 'Remove' }],
state.entries.map((e) => ({ value: e })),
(row) => ({ onclick: () => removeEntry(row.value) })));
if (!state.entries.length) list.appendChild(h('div', {
class: 'pineap-handshakes-none',
text: 'The ' + (isDenyList ? 'deny' : 'allow') + ' list is empty.'
}));
}
allowDefault.addEventListener('click', () => setMode('deny'));
denyDefault.addEventListener('click', () => setMode('allow'));
valueIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') addEntry(); });
PagerAPI.get(path).then(applyResponse).catch(() => fail('Unable to load ' + title.toLowerCase()));
}
filterCard('Client Filter', 'client');
filterCard('SSID Filter', 'ssid');
return { destroy: () => {} };
};
const RECON_TABS = [
{ label: 'Scanning', hash: '#/recon' },
{ label: 'Reports', hash: '#/recon/reports' },
{ label: 'Handshakes', hash: '#/recon/handshakes' }
];
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e'];
const RECON_ENC_BUCKETS = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA3', 'Enterprise'];
const RECON_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
const RECON_MAX_HISTORY = 90;
const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91'];
const RECON_AP_COLS = [
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
{ key: 'vendor', label: 'Vendor', render: (a) => a.vendor || '--' },
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
{ key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_seen) },
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
];
const RECON_CLIENT_COLS = [
{ key: 'mac', label: 'Client MAC', render: (c) => c.mac },
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 }
];
function reconBandOf(freq) {
if (freq == null) return null;
if (freq >= 2400 && freq < 2500) return '2.4';
if (freq >= 4900 && freq < 5900) return '5';
if (freq >= 5900 && freq < 7125) return '6';
return null;
}
function reconSigColor(dbm) {
if (dbm == null) return '#9e9e9e';
if (dbm >= -50) return '#2e7d32';
if (dbm >= -67) return '#f9a825';
if (dbm >= -80) return '#ef6c00';
return '#c62828';
}
function dbmCell(dbm) {
const pct = dbm == null ? 0 : Math.max(0, Math.min(100, ((dbm + 100) / 60) * 100));
const color = reconSigColor(dbm);
const bar = h('span', { class: 'recon-dbm-bar' },
h('span', { class: 'recon-dbm-fill', style: 'width:' + pct.toFixed(0) + '%;background:' + color }));
return h('span', { class: 'recon-dbm-cell' },
bar, h('span', { class: 'recon-dbm-val', style: 'color:' + color, text: (dbm == null ? '--' : dbm + ' dBm') }));
}
function reconBandLabel(band) {
return band == null ? '--' : band + ' GHz';
}
function reconDefaultCols() {
return {
ap: { compare: true, ssid: true, bssid: true, band: true, channel: true, signal: true, vendor: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true }
};
}
function reconLoadCols() {
try {
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
if (v && v.ap && v.client) {
if (typeof v.ap.compare !== 'boolean') v.ap.compare = true;
return v;
}
} catch (e) {}
return reconDefaultCols();
}
function reconFiltered(rows, q, colsArr) {
const ql = (q || '').toLowerCase();
if (!ql) return rows;
return rows.filter((r) => colsArr.some((c) => String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(ql) !== -1));
}
// ---------------------------------------------------------------------------
// 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);
if (!ap.enabled) return h('span', { class: 'badge off', text: 'OFF' });
return h('span', { class: 'badge' + (ap.live ? ' on' : ' warn'), text: ap.live ? 'LIVE' : 'CONFIGURED' });
}
function attackStatusCard() {
const card = h('div', { class: 'pineap-title-card' });
card.appendChild(h('div', { class: 'pineap-card-title' }, 'Status'));
const rows = h('div', { style: 'font-size:13px;line-height:1.9' });
card.appendChild(rows);
const append = (label, node) => rows.appendChild(
h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: label }), node));
return { card, rows, append };
}
function verifiedToast(result) {
if (result && result.verified) App.toast('Applied and verified on device');
else if (result && result.ok) App.toast('Applied (verification pending — radio reloading)', 'error');
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 form = h('div', { class: 'pineap-title-card' });
form.appendChild(h('div', { class: 'pineap-card-title' },
opts.title + (opts.subtitle ? ' — ' + opts.subtitle : '')));
box.appendChild(form);
const f = h('div', {});
form.appendChild(f);
const ssidIn = h('input', { id: 'atk-ssid' });
const hiddenCb = h('input', { type: 'checkbox', id: 'atk-hidden' });
const chanSel = h('select', { id: 'atk-channel' });
chanSelect(chanSel, null);
const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
let pskIn = null, encSel = null;
chanSel.addEventListener('change', () => {
const b = bandOfChannel(chanSel.value);
bandHint.textContent = b === '6' ? '6 GHz requires WPA3 (SAE/OWE).' : '';
if (encSel) {
Array.prototype.forEach.call(encSel.options, (o) => {
o.disabled = b === '6' && o.value !== 'sae' && o.value !== 'owe';
});
if (b === '6' && encSel.value === 'psk2') encSel.value = 'sae';
}
});
f.appendChild(h('label', {}, 'SSID (target network)', ssidIn));
if (opts.passphrase) {
pskIn = h('input', { id: 'atk-psk', type: 'password', autocomplete: 'new-password' });
encSel = h('select', { id: 'atk-enc' });
(opts.encodings || EVIL_ENC).forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
f.appendChild(h('label', {}, 'Passphrase', pskIn));
f.appendChild(h('label', {}, 'Encryption', encSel));
}
let bssidIn = null, coSel = null;
if (opts.bssid) {
bssidIn = h('input', { id: 'atk-bssid', placeholder: 'AA:BB:CC:DD:EE:FF' });
f.appendChild(h('label', {}, 'BSSID (spoof, optional)', bssidIn));
}
if (opts.country) {
coSel = h('select', { id: 'atk-country' });
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
f.appendChild(h('label', {}, 'Country', coSel));
}
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
f.appendChild(h('label', {}, 'Channel', chanSel));
f.appendChild(bandHint);
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, btn('Deploy Attack', () => {
const body = {
kind, ssid: ssidIn.value.trim(),
hidden: hiddenCb.checked,
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
};
if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; }
if (bssidIn) body.bssid = bssidIn.value.trim();
if (coSel) body.country = coSel.value;
PagerAPI.post('/api/attacks/deploy', body)
.then((r) => { verifiedToast(r.data || {}); load(); })
.catch((e) => { App.toast(e.message || 'Deploy failed', 'error'); });
})),
h('div', {}, btn('Stop Attack', () => {
PagerAPI.post('/api/attacks/stop', { kind })
.then(() => { App.toast('Attack stopped'); load(); })
.catch(() => App.toast('Stop failed', 'error'));
}, 'danger')),
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' },
'Deploy reconfigures the radio — you may be disconnected briefly.')));
const status = attackStatusCard();
box.appendChild(status.card);
const hsBox = h('div', {});
const captureBox = h('div', { class: 'pineap-title-card' });
captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture'));
const capBody = h('div', { style: 'font-size:13px' });
captureBox.appendChild(capBody);
box.appendChild(captureBox);
let capIface = 'wlan0mon';
function capRow(st) {
capBody.innerHTML = '';
const run = !!(st && st.running);
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }),
h('div', {}, btn(run ? 'Stop Capture' : 'Start Capture', () => {
PagerAPI.post('/api/attacks/capture', {
iface: capIface, action: run ? 'stop' : 'start'
}).then((r) => capRow(r.data || {})).catch(() => App.toast('Capture failed', 'error'));
}))));
capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: run && st.path ? st.path : '' }));
}
if (opts.export) {
const exp = h('div', { class: 'pineap-title-card' });
exp.appendChild(h('div', { class: 'pineap-card-title' }, 'Hashcat Export'));
const expBody = h('div', { style: 'font-size:13px;line-height:1.8' });
exp.appendChild(expBody);
expBody.appendChild(h('div', {}, btn('Export .hc22000', () => {
PagerAPI.get('/api/attacks/export/hc22000').then((r) => {
const d = r.data || {};
if (d.file) {
expBody.appendChild(h('div', { class: 'row', style: 'margin-top:8px' },
h('a', { class: 'btn ghost', href: '/api/attacks/export/hc22000/' + d.name,
style: 'text-decoration:none', download: d.name }, 'Download ' + d.name),
h('code', { style: 'font-size:12px', text: d.hashcat || '' })));
App.toast('Export ready');
} else App.toast('No captures to export', 'error');
}).catch(() => App.toast('Export failed', 'error'));
})));
box.appendChild(exp);
}
let deauthCard = null;
const ssidRef = { current: '' };
if (opts.deauth) {
deauthCard = deauthPanel(ssidRef);
box.appendChild(deauthCard);
}
if (opts.playbook) {
const pb = h('div', { class: 'pineap-title-card' });
pb.appendChild(h('div', { class: 'pineap-card-title' }, 'Playbook'));
const pbBody = h('div', { style: 'font-size:13px;line-height:1.8' });
pb.appendChild(pbBody);
box.appendChild(pb);
const steps = opts.playbook.steps;
const stepEls = {};
const stepList = h('ol', { style: 'margin:0;padding-left:20px' });
steps.forEach((s) => {
const li = h('li', { style: 'margin-top:4px' }, h('span', { text: s }));
stepEls[s] = li;
stepList.appendChild(li);
});
pbBody.appendChild(stepList);
const pbHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:6px' });
pbBody.appendChild(pbHint);
const pbTick = (status, which) => {
const activeStep = opts.playbook.currentStep(status, which);
steps.forEach((s) => {
stepEls[s].style.color = s === activeStep ? 'var(--primary)' : '';
stepEls[s].style.fontWeight = s === activeStep ? '600' : '';
});
pbHint.textContent = opts.playbook.hint(status, which) || '';
};
pbTick(null, null);
box.__pbTick = pbTick;
}
function load() {
PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {};
const ap = s[kind] || {};
const which = (ap.radio0 && ap.radio0.enabled) ? ap.radio0
: (ap.radio1 && ap.radio1.enabled) ? ap.radio1 : null;
status.rows.innerHTML = '';
status.append('Attack', attackBadge(which));
status.append('SSID', h('span', { text: which && which.ssid ? which.ssid : '—' }));
status.append('Interface', h('span', { text: which ? which.iface : '—' }));
status.append('Band', h('span', { text: which && which.band ? which.band + ' GHz' : '—' }));
status.append('Channel', h('span', { text: which && which.channel != null ? which.channel : '—' }));
if (which && !which.live) {
status.append('Note', h('span', { class: 'pineap-infobox warn',
text: 'Configured but interface not live yet — radio reload may still be in progress.' }));
}
capIface = (which && (which.band === '5' || which.band === '6')) ? 'wlan1mon' : 'wlan0mon';
if (ssidRef) {
ssidRef.current = which && which.ssid ? which.ssid : '';
if (ssidRef.tick) ssidRef.tick();
}
if (box.__pbTick) box.__pbTick(s, which);
if (opts.handshakes) {
hsBox.innerHTML = '';
hsBox.appendChild(h('div', { class: 'pineap-card-title', text: 'Handshakes Captured (' + (s.handshakes || 0) + ')' }));
PagerAPI.get('/api/pineap/handshakes').then((hr) => {
const hd = (hr.data || {}).parsed || [];
const rows = hd.slice(-10).reverse().map((x) => ({
ssid: x.ssid || '—', bssid: x.bssid || '—',
when: fmtTime(x.ts || 0), type: x.type || ''
}));
hsBox.appendChild(rows.length ? table(
[{ key: 'when', label: 'When' }, { key: 'ssid', label: 'SSID' },
{ key: 'bssid', label: 'BSSID' }, { key: 'type', label: 'Type' }], rows)
: h('div', { class: 'empty', text: 'No handshakes yet.' }));
}).catch(() => {});
box.appendChild(hsBox);
}
}).catch(() => {});
PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {});
}
load();
const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) };
};
}
views.harness = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Harness' }));
const box = h('div', {});
root.appendChild(box);
const info = h('div', { class: 'pineap-title-card' });
info.appendChild(h('div', { class: 'pineap-card-title' }, 'Local Harness (MCP)'));
const infoBody = h('div', { style: 'font-size:13px;line-height:1.9' });
info.appendChild(infoBody);
box.appendChild(info);
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: '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' });
const capBox = h('div', { class: 'pineap-title-card' });
capBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Capabilities'));
const capBody = h('div', { style: 'font-size:13px' });
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 || {};
capBody.innerHTML = '';
const tools = d.tools || [];
const prompts = d.prompts || [];
capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px',
text: tools.length + ' tools, ' + prompts.length + ' playbooks, ' +
((d.resources || []).length) + ' resources' }));
const list = h('ul', { style: 'font-size:12px;padding-left:18px' });
tools.forEach((t) => list.appendChild(h('li', { text: t.name + ' — ' + t.description })));
capBody.appendChild(list);
}).catch(() => {});
PagerAPI.get('/api/harness/token').then((r) => {
const t = (r.data || {}).token || '';
tok.textContent = t ? t.slice(0, 12) + '…' : '(none)';
snippet.textContent = 'curl -s -X POST ' + location.origin + '/mcp \\\n' +
' -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(() => {});
}
load();
return { destroy: () => {} };
};
function deauthPanel(ssidRef) {
const wrap = h('div', { class: 'pineap-title-card' });
wrap.appendChild(h('div', { class: 'pineap-card-title' }, 'Deauth Targeting'));
const body = h('div', { style: 'font-size:13px' });
wrap.appendChild(body);
const ssidBox = h('input', { placeholder: 'SSID to find clients for' });
const apSel = h('select', {});
const clTable = h('div', {});
let lastLookup = '';
function lookup(q) {
if (!q || q === lastLookup) return;
lastLookup = q;
PagerAPI.get('/api/attacks/clients?ssid=' + encodeURIComponent(q)).then((r) => {
const d = r.data || {};
apSel.innerHTML = '';
(d.aps || []).forEach((a) => apSel.appendChild(h('option', {
value: (a.bssid || '') + '|' + (a.channel || 1),
text: (a.ssid || q) + ' — ' + (a.bssid || '?') + ' ch' + (a.channel || 1)
})));
if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' }));
clTable.innerHTML = '';
const cl = (d.clients || []).slice(0, 30);
if (!cl.length) {
clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' }));
return;
}
clTable.appendChild(table(
[{ key: 'mac', label: 'MAC' }, { key: 'freq', label: 'Freq (MHz)' },
{ key: 'signal', label: 'Signal' }, { key: 'packets', label: 'Packets' },
{ key: '_deauth', label: '' }],
cl.map((c) => {
const mac = c.mac || c.client_mac || '';
return { mac: mac || '—', freq: c.freq || '', signal: c.signal || '',
packets: c.packets || '', _deauth: btn('Deauth', () => {
const parts = apSel.value.split('|');
if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; }
PagerAPI.post('/api/attacks/deauth', {
bssid: parts[0], client: mac, channel: parseInt(parts[1], 10)
}).then(() => App.toast('Deauth frames sent'))
.catch((e) => App.toast(e.message || 'Deauth failed', 'error'));
}, 'danger') };
})));
}).catch(() => App.toast('Lookup failed', 'error'));
}
body.appendChild(h('div', { class: 'row' }, ssidBox,
h('div', {}, btn('Find', () => lookup(ssidBox.value.trim())))));
body.appendChild(apSel);
body.appendChild(clTable);
body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Only deauth targets you are authorized to test.' }));
if (ssidRef) {
ssidRef.tick = () => {
const liveSsid = ssidRef.current && ssidRef.current.trim();
if (liveSsid && ssidBox.value.trim() !== liveSsid) {
ssidBox.value = liveSsid;
lastLookup = '';
lookup(liveSsid);
}
};
}
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.';
}
}
});
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');
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'));
box.appendChild(form);
const f = h('div', {});
form.appendChild(f);
const ssidIn = h('input', { id: 'ent-ssid' });
const encSel = h('select', { id: 'ent-enc' });
[['wpa2', 'WPA2 Enterprise'], ['wpa3', 'WPA3 Enterprise']]
.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' });
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('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
}).then((r) => { verifiedToast(r.data || {}); load(); })
.catch((e) => App.toast(e.message || 'Deploy failed', 'error'));
})),
h('div', {}, btn('Stop Attack', () => {
PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' })
.then(() => { App.toast('Attack stopped'); load(); })
.catch(() => App.toast('Stop failed', 'error'));
}, 'danger'))));
const status = attackStatusCard();
box.appendChild(status.card);
const pb = h('div', { class: 'pineap-title-card' });
pb.appendChild(h('div', { class: 'pineap-card-title' }, 'Playbook'));
const pbHint = h('div', { style: 'font-size:13px;line-height:1.8', text:
'1. Deploy the enterprise twin (5 GHz). 2. When a client attempts 802.1X, its identity and MSCHAPv2 challenge/response land in the tables below. 3. Crack offline: hashcat -m 5500 on the challenge data.' });
pb.appendChild(pbHint);
box.appendChild(pb);
const pApe = h('div', { class: 'pineap-title-card' });
pApe.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAPE'));
const pApeBody = h('div', {});
pApe.appendChild(pApeBody);
box.appendChild(pApe);
const authCb = h('input', { type: 'checkbox', id: 'ent-auth' });
authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked })
.then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); }));
const tables = [['Basic Data', '/api/pineap/enterprise/basic', 'basic'],
['Challenge Data', '/api/pineap/enterprise/challenge', 'challenge']]
.map(([name, endpoint, tableKey]) => {
const tb = h('div', { class: 'pineap-title-card' });
const body = h('div', {});
tb.appendChild(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: tableKey }).then(load), 'danger')));
tb.appendChild(body);
box.appendChild(tb);
return { body, endpoint };
});
function load() {
PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {};
const ent = s.enterprise || {};
const ap = ent.ap || null;
status.rows.innerHTML = '';
status.append('Attack', attackBadge(ap));
status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '—' }));
status.append('Interface', h('span', { text: ap ? ap.iface : '—' }));
status.append('Band', h('span', { text: ap && ap.band ? ap.band + ' GHz' : '5 GHz' }));
status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '—' }));
status.append('Credentials', h('span', { text: String(ent.creds || 0) }));
pApeBody.innerHTML = '';
pApeBody.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture'));
if (ent.pineape) {
pApeBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px',
text: 'PineAPE ' + (ent.pineape.enabled ? 'enabled' : 'disabled') }));
}
}).catch(() => {});
tables.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) };
};
function reconEncBucket(enc) {
const s = (enc || '').trim();
if (s === 'Open') return 'Open';
if (s.indexOf('Enterprise') !== -1) return 'Enterprise';
if (s.indexOf('WEP') !== -1) return 'WEP';
if (s.indexOf('WPA2') !== -1) return 'WPA2';
if (s.indexOf('WPA3') !== -1) return 'WPA3';
if (s.indexOf('WPA') !== -1) return 'WPA';
return s || 'Unknown';
}
function reconPer(key, def) {
const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10);
return [10, 25, 50].indexOf(v) !== -1 ? v : def;
}
function reconCmp(a, b, col, dir) {
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' ||
col.key === 'first_seen' || col.key === 'last_seen';
if (numeric) {
const x = a[col.key] == null ? -Infinity : Number(a[col.key]);
const y = b[col.key] == null ? -Infinity : Number(b[col.key]);
return (x - y) * dir;
}
const xs = String(a[col.key] == null ? '' : a[col.key]).toLowerCase();
const ys = String(b[col.key] == null ? '' : b[col.key]).toLowerCase();
return xs.localeCompare(ys) * dir;
}
views.recon = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon');
const state = { scans: [], selected: null, detail: null,
apPage: 0, apSearch: '', clientPage: 0, clientSearch: '',
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null, detailArchive: null,
apBand: 'all', apEnc: 'all', gps: null, wigle: null,
compare: [], history: {}, mapBand: null,
archive: null, archives: [], scanRemaining: null,
hopperOnline: null, historyReset: false };
const cols = reconLoadCols();
// ---- title cards ----
const cardWrap = h('div', { class: 'recon-title-card-container' });
root.appendChild(cardWrap);
function titleCard(titleText, link) {
const wrap = h('div', { class: 'recon-title-card' });
const card = h('div', { class: 'recon-card' });
wrap.appendChild(card);
card.appendChild(link
? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText })
: h('div', { class: 'recon-title-card-title', text: titleText }));
const content = h('div', { class: 'recon-title-card-content' });
card.appendChild(content);
cardWrap.appendChild(wrap);
return content;
}
const landContent = titleCard('Wireless Landscape', false);
const landBox = h('div', { class: 'recon-chart-box' });
landContent.appendChild(landBox);
const landCanvas = h('canvas', { id: 'recon-landscape' });
landBox.appendChild(landCanvas);
const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' });
landBox.appendChild(landEmpty);
const chanContent = titleCard('Channel Distribution', false);
const chanBox = h('div', { class: 'recon-chart-box' });
chanContent.appendChild(chanBox);
const chanCanvas = h('canvas', { id: 'recon-channel' });
chanBox.appendChild(chanCanvas);
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' });
chanBox.appendChild(chanEmpty);
const encContent = titleCard('Encryption Landscape', false);
const encBox = h('div', { class: 'recon-chart-box' });
encContent.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data is available yet.' });
encBox.appendChild(encEmpty);
const hsContent = titleCard('Handshakes', true);
const hsCol = h('div', { class: 'recon-hs-col' });
hsContent.appendChild(hsCol);
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
hsCol.appendChild(hsCount);
hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' }));
const hsAuto = h('label', { class: 'recon-toggle' },
h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Automatically Collect Any Handshakes');
hsAuto.querySelector('input').addEventListener('change', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked })
.then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error'));
});
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', () => {
const meta = pickerOptions[parseInt(sel.value, 10)];
if (!meta) return;
state.archive = meta.archive;
state.selected = meta.scanId;
state.apPage = 0; state.clientPage = 0;
state.detailId = null; state.detailArchive = null;
loadDetail();
});
psRow.appendChild(sel);
function dlBase() {
if (state.selected == null) return null;
return state.archive
? App.apiBase + '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
: App.apiBase + '/api/recon/scans/' + state.selected;
}
const dlJson = iconBtn('file_download', 'Download scan JSON', () => {
const base = dlBase();
if (base) window.location = base + '/download/json';
});
const dlCsv = iconBtn('table_chart', 'Download scan CSV', () => {
const base = dlBase();
if (base) window.location = base + '/download/csv';
});
const dlHtml = iconBtn('description', 'Download scan HTML report', () => {
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;
PagerAPI.del('/api/recon/scans/' + state.selected)
.then(() => { App.toast('Scan deleted'); load(); })
.catch(() => App.toast('Delete failed', 'error'));
});
psRow.appendChild(delBtn);
// ---- scan bar ----
const scanBar = h('div', { class: 'section recon-scan-bar' });
root.appendChild(scanBar);
const scanToggle = h('input', { type: 'checkbox', id: 'recon-scan-toggle' });
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
scanBar.appendChild(scanLabel);
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes']]
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
scanBar.appendChild(durSel);
const scanStatus = h('span', { class: 'recon-scan-status muted', text: '' });
scanBar.appendChild(scanStatus);
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
const gpsPill = h('button', { class: 'recon-pill recon-pill-gps', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
const wiglePill = h('button', { class: 'recon-pill recon-pill-wigle', title: 'WiGLE logging (click to toggle)' });
scanBar.appendChild(gpsPill);
scanBar.appendChild(wiglePill);
scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
function renderPills() {
const g = state.gps || {};
if (g.lock) {
gpsPill.className = 'recon-pill recon-pill-gps on';
gpsPill.textContent = 'GPS ' + (g.lat != null ? Number(g.lat).toFixed(5) : '--') + ', ' + (g.lon != null ? Number(g.lon).toFixed(5) : '--') + (g.satellites ? ' · ' + g.satellites + ' sats' : '');
} else if (g.present) {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS no fix';
} else if (g.gpsd_running) {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS no device';
} else {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS off';
}
wiglePill.className = 'recon-pill recon-pill-wigle ' + (state.wigle ? 'on' : '');
wiglePill.textContent = state.wigle ? 'WiGLE on' : 'WiGLE off';
}
gpsPill.addEventListener('click', () => {
PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
state.gps = r.data;
renderPills();
if (r.data.error) App.toast(r.data.error, 'error');
else if (r.data.lock) App.toast('GPS locked: ' + Number(r.data.lat).toFixed(5) + ', ' + Number(r.data.lon).toFixed(5));
else App.toast((r.data.note || 'GPS bound, waiting for a fix'));
}).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'));
});
wiglePill.addEventListener('click', () => {
const next = !state.wigle;
wiglePill.disabled = true;
PagerAPI.post('/api/recon/wigle', { enable: next }).then((r) => {
state.wigle = next;
App.toast(next ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
}).catch((err) => App.toast((err && err.message) || 'WiGLE toggle failed', 'error'))
.finally(() => { wiglePill.disabled = false; renderPills(); });
});
let pendingScan = false;
function renderScanBar() {
const scanning = state.scanActive;
scanToggle.disabled = scanning || pendingScan;
durSel.disabled = scanning;
const bits = [];
if (scanning) {
bits.push('Scanning' + (state.scanRemaining != null ? ' · ' + state.scanRemaining + 's left' : ''));
}
if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen');
if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)');
scanStatus.textContent = bits.join(' · ');
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset);
}
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
const on = scanToggle.checked;
pendingScan = true;
scanToggle.disabled = true;
PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {})
.then(() => {
if (on) {
state.scanActive = true;
state.autoFollow = true;
state.apPage = 0;
state.clientPage = 0;
App.toast('Scan started');
} else {
state.scanActive = false;
state.autoFollow = false;
App.toast('Scan stopped');
}
restartPoll();
load();
})
.catch((err) => {
if (err && err.status === 409) {
// A native timed scan is already running; keep the toggle on and
// show the remaining time instead of pretending the start failed.
scanToggle.checked = true;
state.scanActive = true;
state.scanRemaining = (err.data && err.data.scan_remaining) != null
? err.data.scan_remaining : null;
renderScanBar();
App.toast('Scan already running — it will finish automatically' +
(state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : ''), 'error');
} else {
scanToggle.checked = !on;
App.toast((err && err.message) || 'Recon control failed', 'error');
}
})
.finally(() => { pendingScan = false; scanToggle.disabled = state.scanActive; });
});
// ---- settings sidebar ----
const sidebar = h('div', { class: 'recon-settings-sidebar hidden' });
sidebar.appendChild(h('div', { class: 'recon-settings-head' },
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
const colDefs = {
ap: [['compare', 'Show Compare'], ['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['band', 'Show Band'],
['channel', 'Show Channel'], ['signal', 'Show Signal'], ['vendor', 'Show Vendor'],
['encryption', 'Show Encryption'], ['first_seen', 'Show First Seen'],
['last_seen', 'Show Last Seen'], ['hidden', 'Show Hidden']],
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
};
Object.keys(colDefs).forEach((grp) => {
sidebar.appendChild(h('div', { class: 'recon-settings-section', text: grp === 'ap' ? 'Access Points' : 'Clients' }));
colDefs[grp].forEach(([key, label]) => {
const cb = h('input', { type: 'checkbox', id: 'col-' + grp + '-' + key });
cb.checked = cols[grp][key];
cb.addEventListener('change', () => { cols[grp][key] = cb.checked; localStorage.setItem('pw_recon_cols', JSON.stringify(cols)); renderTables(); });
sidebar.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
});
});
root.appendChild(sidebar);
// ---- AP focus sidebar ----
const focusSidebar = h('div', { class: 'recon-focus-sidebar hidden' });
root.appendChild(focusSidebar);
function renderFocus() {
const ap = state.focusAp;
if (!ap) return;
focusSidebar.innerHTML = '';
const head = h('div', { class: 'recon-focus-header' },
h('div', { class: 'recon-focus-header-text', text: ap.ssid || 'Hidden SSID' }),
btn('×', () => { state.focusAp = null; focusSidebar.classList.add('hidden'); renderTables(); }, 'ghost'));
focusSidebar.appendChild(head);
focusSidebar.appendChild(h('div', { class: 'recon-focus-bssid', text: ap.bssid || '--' }));
focusSidebar.appendChild(h('div', { class: 'recon-focus-subtext', text: 'Channel ' + (ap.channel == null ? '--' : ap.channel) + ' · ' + (ap.freq || '--') + ' MHz' }));
const actions = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(actions);
actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' }));
const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' });
capture.addEventListener('click', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: true })
.then(() => App.toast('Handshake capture enabled (device-wide on Pager)'))
.catch(() => App.toast('Failed to enable handshake capture', 'error'));
});
actions.appendChild(capture);
const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' });
stopHs.addEventListener('click', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: false })
.then(() => App.toast('Handshake capture disabled'))
.catch(() => App.toast('Failed to disable handshake capture', 'error'));
});
actions.appendChild(stopHs);
const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' });
exB.addEventListener('click', () => {
if (!ap.bssid) return;
PagerAPI.post('/api/recon/examine', { bssid: ap.bssid })
.then(() => App.toast('Examining ' + ap.bssid))
.catch(() => App.toast('Examine failed', 'error'));
});
actions.appendChild(exB);
const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' });
exC.addEventListener('click', () => {
if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; }
PagerAPI.post('/api/recon/examine', { channel: ap.channel })
.then(() => App.toast('Examining channel ' + ap.channel))
.catch(() => App.toast('Examine failed', 'error'));
});
actions.appendChild(exC);
const details = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(details);
details.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Details' }));
[['Channel', ap.channel == null ? '--' : ap.channel],
['Signal', ap.signal == null ? '--' : ap.signal + ' dBm'],
['Encryption', ap.encryption || '--'],
['Frequency', ap.freq || '--'],
['Hidden', ap.hidden ? 'Yes' : 'No']].forEach(([k, v]) => {
details.appendChild(h('div', { class: 'recon-focus-detail' },
h('span', { class: 'recon-focus-detail-label', text: k }),
h('span', { text: v })));
});
}
function toggleFocus(ap) {
if (state.focusAp && state.focusAp.bssid === ap.bssid) {
state.focusAp = null;
focusSidebar.classList.add('hidden');
} else {
state.focusAp = ap;
renderFocus();
focusSidebar.classList.remove('hidden');
}
renderTables();
}
// ---- compare: select up to 6 APs; the rest of the page focuses on them ----
const cmpCard = h('div', { class: 'section recon-compare-card' });
root.appendChild(cmpCard);
cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
const cmpCanvas = h('canvas', { id: 'recon-compare', style: 'width:100%;height:150px' });
cmpCard.appendChild(cmpCanvas);
const cmpLegend = h('div', { class: 'recon-compare-legend' });
cmpCard.appendChild(cmpLegend);
const cmpEmpty = h('div', { class: 'empty recon-compare-empty', text: 'Tick the Compare box on an AP below to track its signal here.' });
cmpCard.appendChild(cmpEmpty);
function toggleCompare(a) {
if (!a || !a.bssid) return false;
const i = state.compare.indexOf(a.bssid);
if (i !== -1) {
state.compare = state.compare.filter((b) => b !== a.bssid);
} else {
if (state.compare.length >= 6) {
App.toast('Compare up to 6 APs', 'error');
return false;
}
state.compare.push(a.bssid);
const name = (a.ssid || '(hidden)');
App.toast('Comparing ' + (name.length > 24 ? name.slice(0, 24) + '…' : name));
}
renderSelection();
renderCompare();
renderTables();
drawCharts(state.detail || {});
renderChannelMap();
return true;
}
function renderCompare() {
const series = [];
const legends = [];
const d = state.detail || {};
const aps = d.aps || [];
state.compare.forEach((bssid, i) => {
const hist = state.history[bssid] || [];
let pts = hist.map((p) => p.sig).filter((v) => v != null);
if (pts.length === 1) pts = [pts[0], pts[0]];
if (pts.length < 2) {
const ap = aps.find((a) => a.bssid === bssid);
if (ap && ap.signal != null) pts = [ap.signal, ap.signal];
}
const color = RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length];
if (pts.length >= 2) series.push({ points: pts, color: color });
const ap = aps.find((a) => a.bssid === bssid);
const last = pts.length ? pts[pts.length - 1] : null;
legends.push(h('span', { class: 'recon-compare-legend-item' },
h('span', { class: 'recon-compare-swatch', style: 'background:' + color }),
h('span', { text: (ap && ap.ssid) || '(hidden) ' + (bssid || '').slice(0, 8) + '…' }),
h('span', { class: 'recon-compare-sig', text: last == null ? '--' : last + ' dBm' })));
});
cmpLegend.innerHTML = '';
legends.forEach((l) => cmpLegend.appendChild(l));
cmpEmpty.classList.toggle('hidden', state.compare.length > 0);
if (typeof MiniChart !== 'undefined' && MiniChart.draw) {
MiniChart.draw(cmpCanvas, series, { min: -100, max: -20, grid: '#e0e0e0' });
}
}
// ---- results tables ----
const apCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(apCard);
// ---- channel map (under Access Points) ----
const mapCard = h('div', { class: 'section recon-map-card' });
root.appendChild(mapCard);
mapCard.appendChild(h('h2', { text: 'Channel Map' }));
mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz.' }));
const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' });
mapCard.appendChild(mapChips);
const mapBox = h('div', { class: 'recon-map-box' });
mapCard.appendChild(mapBox);
const mapCanvas = h('canvas', { id: 'recon-map', style: 'width:100%;height:180px' });
mapBox.appendChild(mapCanvas);
const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' });
mapBox.appendChild(mapEmpty);
function mapAps() {
const d = state.detail || {};
const all = d.aps || [];
const sel = state.compare.length
? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
: all;
return sel.map((a) => {
const idx = state.compare.length ? state.compare.indexOf(a.bssid) : -1;
const color = idx !== -1
? RECON_COMPARE_COLORS[idx % RECON_COMPARE_COLORS.length]
: reconSigColor(a.signal);
return Object.assign({}, a, { color: color });
});
}
function renderChannelMap() {
const aps = mapAps().filter((a) => a.band === '2.4' || a.band === '5' || a.band === '6');
const bands = {};
aps.forEach((a) => { bands[a.band] = (bands[a.band] || 0) + 1; });
const order = ['2.4', '5', '6'];
const present = order.filter((b) => bands[b]);
if (!state.mapBand || !bands[state.mapBand]) {
state.mapBand = present.length
? present.slice().sort((a, b) => bands[b] - bands[a])[0]
: null;
}
mapChips.innerHTML = '';
mapChips.appendChild(h('span', { class: 'recon-chips-label', text: 'Band' }));
order.forEach((b) => {
const c = h('button', {
class: 'recon-chip' + (state.mapBand === b ? ' active' : '') + (bands[b] ? '' : ' disabled'),
text: b + ' GHz'
});
c.addEventListener('click', () => {
if (!bands[b]) return;
state.mapBand = b;
renderChannelMap();
});
mapChips.appendChild(c);
});
const vis = aps.filter((a) => a.band === state.mapBand);
const hasChan = vis.some((a) => a.channel != null || a.freq != null);
if (!vis.length || !hasChan) {
mapCanvas.classList.add('hidden');
mapEmpty.classList.remove('hidden');
return;
}
mapEmpty.classList.add('hidden');
mapCanvas.classList.remove('hidden');
if (typeof MiniChart !== 'undefined' && MiniChart.channelMap) {
MiniChart.channelMap(mapCanvas, vis, { grid: '#e0e0e0' });
}
}
const cliCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(cliCard);
// ---- selection chips (visible while comparing) ----
const selRow = h('div', { class: 'recon-sel-chips hidden' });
apCard.appendChild(selRow);
// ---- band / encryption filter chips ----
const chipRow = h('div', { class: 'recon-chips-row' });
apCard.appendChild(chipRow);
function renderChips() {
chipRow.innerHTML = '';
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']]]
];
groups.forEach(([label, key, opts]) => {
chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label }));
opts.forEach(([v, t]) => {
const c = h('button', { class: 'recon-chip' + (state[key] === v ? ' active' : ''), text: t });
c.addEventListener('click', () => {
state[key] = v;
state.apPage = 0;
renderChips();
renderTables();
});
chipRow.appendChild(c);
});
});
}
renderChips();
function renderSelection() {
selRow.innerHTML = '';
const d = state.detail || {};
const aps = d.aps || [];
if (!state.compare.length) { selRow.classList.add('hidden'); return; }
selRow.classList.remove('hidden');
selRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Comparing' }));
state.compare.forEach((bssid, i) => {
const ap = aps.find((a) => a.bssid === bssid);
const chip = h('span', { class: 'recon-sel-chip', style: 'border-color:' + RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length] },
h('span', { text: (ap && ap.ssid) || '(hidden)' }),
h('span', { class: 'recon-sel-chip-x', text: '×', title: 'Remove from comparison' }));
chip.querySelector('.recon-sel-chip-x').addEventListener('click', () => {
state.compare = state.compare.filter((b) => b !== bssid);
renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
});
selRow.appendChild(chip);
});
const clear = h('button', { class: 'btn ghost recon-sel-clear', text: 'Clear selection' });
clear.addEventListener('click', () => {
state.compare = [];
renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
});
selRow.appendChild(clear);
}
function buildPaginator(key) {
const mk = (id, icon, title, fn) => {
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title });
b.innerHTML = PineappleIcons[icon] || '';
b.addEventListener('click', fn);
return b;
};
return h('div', { class: 'recon-paginator' },
mk('first', 'first_page', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }),
mk('prev', 'chevron_left', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }),
h('span', { class: 'muted', id: key + '-range', text: '' }),
mk('next', 'chevron_right', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }),
mk('last', 'last_page', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
}
function filteredRows(key) {
const d = state.detail || {};
if (key === 'client') {
if (state.compare.length) return [];
return reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
}
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);
return out;
}
function reconPageCount(key) {
return Math.max(1, Math.ceil(filteredRows(key).length / state[key + 'Per']));
}
function perSelect(key) {
const sel = h('select', { class: 'sel recon-per', id: key + '-per' });
[10, 25, 50].forEach((n) => sel.appendChild(h('option', { value: String(n), text: n + ' / page' })));
sel.value = String(state[key + 'Per']);
sel.addEventListener('change', () => {
state[key + 'Per'] = parseInt(sel.value, 10) || 10;
localStorage.setItem('pw_recon_per_' + key, String(state[key + 'Per']));
state[key + 'Page'] = 0;
renderTables();
});
return sel;
}
function tableHead(box, title, key, searchId, onInput) {
const head = h('div', { class: 'recon-table-head' },
h('h2', { text: title }),
h('span', { class: 'toolbar-spacer' }),
h('input', { class: 'recon-search', id: searchId, placeholder: 'Search' }),
perSelect(key),
buildPaginator(key));
box.appendChild(head);
head.querySelector('#' + searchId).addEventListener('input', onInput);
}
tableHead(apCard, 'Access Points', 'ap', 'ap-search', () => { state.apSearch = document.getElementById('ap-search').value; state.apPage = 0; renderTables(); });
const apBody = h('div', { class: 'recon-table-body' });
apCard.appendChild(apBody);
tableHead(cliCard, 'Clients', 'client', 'cli-search', () => { state.clientSearch = document.getElementById('cli-search').value; state.clientPage = 0; renderTables(); });
const cliBody = h('div', { class: 'recon-table-body' });
cliCard.appendChild(cliBody);
function renderTable(box, key, rows, colsArr, emptyMsg) {
box.innerHTML = '';
const vis = colsArr.filter((c) => cols[key][c.key]);
const per = state[key + 'Per'];
const page = state[key + 'Page'];
const start = page * per;
const slice = rows.slice(start, start + per);
const rowAttrs = key === 'ap'
? (r) => ({
class: state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '',
style: 'cursor:pointer',
onclick: () => toggleFocus(r)
})
: undefined;
const tbl = table(vis, slice, rowAttrs);
box.appendChild(tbl);
tbl.querySelectorAll('th').forEach((th, i) => {
const col = vis[i];
if (!col) return;
if (col.key === 'compare') return;
th.style.cursor = 'pointer';
th.title = 'Sort by ' + col.label;
const arrow = h('span', { class: 'recon-sort-arrow' });
th.appendChild(arrow);
th.addEventListener('click', () => {
const cur = state[key + 'Sort'];
state[key + 'Sort'] = (cur && cur.key === col.key) ? { key: col.key, dir: -cur.dir } : { key: col.key, dir: 1 };
state[key + 'Page'] = 0;
renderTables();
});
const cur = state[key + 'Sort'];
if (cur && cur.key === col.key) {
th.classList.add('recon-sorted');
arrow.textContent = cur.dir === 1 ? ' ▲' : ' ▼';
}
});
if (!rows.length) box.appendChild(h('div', { class: 'empty', text: emptyMsg }));
const range = document.getElementById(key + '-range');
if (range) range.textContent = rows.length ? (start + 1) + '' + Math.min(start + per, rows.length) + ' of ' + rows.length : '0 of 0';
const p = reconPageCount(key);
const first = document.getElementById(key + '-first');
const prev = document.getElementById(key + '-prev');
const next = document.getElementById(key + '-next');
const last = document.getElementById(key + '-last');
if (first) first.disabled = page === 0;
if (prev) prev.disabled = page === 0;
if (next) next.disabled = page >= p - 1;
if (last) last.disabled = page >= p - 1;
}
function sortRows(rows, key, colsArr) {
const s = state[key + 'Sort'];
if (!s) return rows;
const col = colsArr.find((c) => c.key === s.key);
if (!col) return rows;
const copy = rows.slice();
copy.sort((a, b) => reconCmp(a, b, col, s.dir));
return copy;
}
const compareCol = { key: 'compare', label: 'Compare', render: (a) => {
const cb = h('input', { type: 'checkbox', title: 'Compare this AP' });
cb.checked = state.compare.indexOf(a.bssid) !== -1;
cb.addEventListener('click', (e) => e.stopPropagation());
cb.addEventListener('change', () => {
const ok = toggleCompare(a);
if (!ok) cb.checked = false;
});
return h('span', { class: 'recon-cmp-check' }, cb);
} };
const apCols = [compareCol].concat(RECON_AP_COLS);
function renderTables() {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
const apF = filteredRows('ap');
const cliF = filteredRows('client');
cliCard.classList.toggle('hidden', state.compare.length > 0);
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.');
}
}
function drawCharts(d) {
const all = d.aps || [];
const aps = state.compare.length
? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
: all;
const n = aps.length;
const c = (d.clients || []).length;
const land = document.getElementById('recon-landscape');
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (n > 0) {
const segs = state.compare.length
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
: [
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
];
MiniChart.doughnut(land, segs, { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
land.classList.add('hidden');
landEmpty.classList.remove('hidden');
}
}
const counts = {};
aps.forEach((a) => {
const ch = a.channel == null ? '?' : a.channel;
counts[ch] = (counts[ch] || 0) + 1;
});
const keys = Object.keys(counts).sort((a, b) => {
if (a === '?') return 1;
if (b === '?') return -1;
return Number(a) - Number(b);
});
const ch = document.getElementById('recon-channel');
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
if (keys.length) {
MiniChart.bar(ch, keys.map((k, i) => ({
label: k, value: counts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
})), { height: 130 });
ch.classList.remove('hidden');
chanEmpty.classList.add('hidden');
} else {
ch.classList.add('hidden');
chanEmpty.classList.remove('hidden');
}
}
const encCounts = {};
aps.forEach((a) => {
const b = reconEncBucket(a.encryption);
encCounts[b] = (encCounts[b] || 0) + 1;
});
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: true, height: 130 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
}
}
function detailUrl() {
return state.archive
? '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
: '/api/recon/scans/' + state.selected;
}
function loadDetail() {
if (state.selected == null) return;
const scanId = state.selected;
const arch = state.archive;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
if (!state.scanActive && state.detailId === scanId && state.detailArchive === arch) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
PagerAPI.get(detailUrl()).then((r) => {
if (state.selected !== scanId || state.archive !== arch) return;
const isNewScan = state.detailId !== scanId || state.detailArchive !== arch;
state.detail = r.data;
state.detailId = scanId;
state.detailArchive = arch;
if (isNewScan) state.history = {};
const aps = r.data.aps || [];
const seen = {};
aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; });
if (state.compare.some((b) => !seen[b])) {
state.compare = state.compare.filter((b) => seen[b]);
}
if (!arch) {
// Signal-over-time history only makes sense for the live database.
const nowT = Date.now() / 1000;
aps.forEach((a) => {
if (a.bssid == null || a.signal == null) return;
const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
hist.push({ t: nowT, sig: a.signal });
while (hist.length > RECON_MAX_HISTORY) hist.shift();
});
Object.keys(state.history).forEach((b) => {
if (!seen[b]) {
const hist = state.history[b];
const recent = hist.filter((p) => nowT - p.t < 30);
if (!recent.length) delete state.history[b];
else state.history[b] = recent;
}
});
}
drawCharts(r.data);
renderTables();
renderSelection();
renderCompare();
renderChannelMap();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
}
function pickFollow(scans) {
// Prefer the newest scan with real data so a 0-AP restart row does not
// blank the table while following a live scan.
const nonEmpty = scans.find((s) => s.aps > 0 || s.devices > 0 || s.handshakes > 0);
return nonEmpty ? nonEmpty.id : (scans[0] ? scans[0].id : null);
}
function renderPicker() {
sel.innerHTML = '';
pickerOptions = [];
const cur = { archive: state.archive, scanId: state.selected };
const liveEmpty = (s) => !(s.aps > 0 || s.devices > 0 || s.handshakes > 0);
state.scans.forEach((s) => {
pickerOptions.push({ archive: null, scanId: s.id });
const opt = document.createElement('option');
opt.value = String(pickerOptions.length - 1);
opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time) + (liveEmpty(s) ? ' (empty)' : '');
if (liveEmpty(s)) opt.style.opacity = '0.55';
sel.appendChild(opt);
});
state.archives.forEach((a) => {
const og = document.createElement('optgroup');
og.label = 'Archive ·' + (a.max_id ? ' scans 1' + a.max_id : '') +
(a.mtime ? ' · ' + fmtTime(a.mtime) : '');
(a.scans || []).forEach((s) => {
pickerOptions.push({ archive: a.id, scanId: s.id });
const opt = document.createElement('option');
opt.value = String(pickerOptions.length - 1);
opt.textContent = '#' + s.id + ' — ' + fmtTime(s.time) +
((s.aps > 0 || s.devices > 0 || s.handshakes > 0) ? '' : ' (empty)');
og.appendChild(opt);
});
sel.appendChild(og);
});
const idx = pickerOptions.findIndex((m) => m.archive === cur.archive && m.scanId === cur.scanId);
if (idx !== -1) sel.value = String(idx);
delBtn.disabled = state.archive !== null;
delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan';
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
const newest = state.scans[0] ? state.scans[0].id : null;
let keep = null;
if (state.autoFollow) {
// Following a live run jumps back to the live database.
state.archive = null;
state.detailArchive = null;
keep = pickFollow(state.scans);
} else if (!state.archive) {
keep = state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : newest;
}
renderPicker();
if (keep == null) {
if (!state.archive) {
state.detail = null;
state.detailId = null;
state.detailArchive = null;
drawCharts({ aps: [], clients: [], handshakes: [] });
renderTables();
renderSelection();
renderCompare();
renderChannelMap();
hsCount.textContent = '0';
}
} else {
if (keep !== state.selected) {
state.selected = keep;
state.detailId = null;
state.detailArchive = null;
}
if (state.selected != null) loadDetail();
}
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
state.scanRemaining = r.data.scan_remaining != null ? r.data.scan_remaining : null;
state.hopperOnline = r.data.hopper_online;
state.historyReset = !!r.data.history_reset;
if (!pendingScan) scanToggle.checked = scanning;
renderScanBar();
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
}
function loadSlow() {
// GPS and archive discovery change rarely; polling them on every 5s tick
// piles sqlite/iwinfo/gpsd work onto the same cycles as the scan data.
PagerAPI.get('/api/recon/gps').then((r) => {
state.gps = r.data;
state.wigle = !!(r.data || {}).wigle;
renderPills();
}).catch(() => {});
PagerAPI.get('/api/recon/archives').then((r) => {
state.archives = (r.data && r.data.archives) || [];
renderPicker();
}).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load();
loadSlow();
let pollIv = null;
const restartPoll = () => {
clearInterval(pollIv);
pollIv = setInterval(load, state.scanActive ? 5000 : 10000);
};
restartPoll();
let slowIv = null;
const restartSlow = () => {
clearInterval(slowIv);
slowIv = setInterval(loadSlow, 30000);
};
restartSlow();
return { destroy: () => { clearInterval(pollIv); clearInterval(slowIv); } };
};
views.recon_reports = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/reports');
const reportCard = h('div', { class: 'section' });
const wigleCard = h('div', { class: 'section' });
function dl(path) { window.location = App.apiBase + path; }
function renderScans() {
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.' })));
}
function renderWigle() {
wigleCard.appendChild(h('h2', { text: 'WiGLE Uploads' }));
const box = h('div');
wigleCard.appendChild(box);
PagerAPI.get('/api/recon/wigle/files').then((r) => {
const files = (r.data && r.data.files) || [];
box.innerHTML = '';
if (!files.length) { box.appendChild(h('div', { class: 'empty', text: 'No WiGLE files yet. WiGLE logging writes a CSV per capture session.' })); return; }
box.appendChild(table(
[
{ key: 'name', label: 'File', render: (f) => f.name },
{ key: 'mtime', label: 'Modified', render: (f) => fmtTime(f.mtime) },
{ key: 'size', label: 'Size', render: (f) => fmtBytes(f.size) },
{ key: 'rows', label: 'AP rows', render: (f) => f.rows == null ? '--' : f.rows },
{ key: 'warn', label: '', render: (f) => f.rows === 0 ? h('span', { class: 'wigle-warn', text: 'Header only — no data yet (needs a GPS fix)' }) : h('span', {}) },
{ key: 'dl', label: 'Download', render: (f) => iconBtn('file_download', 'Download ' + f.name, () => dl('/api/recon/wigle/files/' + encodeURIComponent(f.name))) }
],
files));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load WiGLE files.' })));
}
root.appendChild(reportCard);
root.appendChild(wigleCard);
renderScans();
renderWigle();
return { destroy: () => {} };
};
const LOGGING_TABS = [
{ label: 'PineAP', hash: '#/logging' },
{ label: 'System', hash: '#/logging/system' }
];
function hsType(name) {
const n = (name || '').toLowerCase();
if (/\.(cap|pcap|pcapng)$/.test(n)) return 'PCAP';
if (/\.(hccapx|hc22000|22000|hccap)$/.test(n)) return 'Hashcat';
return 'Unknown';
}
views.recon_handshakes = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/handshakes');
const box = h('div', { class: 'section recon-handshakes-card' });
root.appendChild(box);
let flashTimer = null;
function flash(ok, msg) {
const head = box.querySelector('.recon-table-head');
if (!head) return;
const old = head.querySelector('.hs-flash');
if (old) old.remove();
const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg });
head.appendChild(el);
clearTimeout(flashTimer);
flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000);
}
function hsIconBtn(name, title, cls, onclk) {
const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk });
b.innerHTML = PineappleIcons[name] || '';
return b;
}
function textCell(v) {
return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) });
}
function naGlyph() {
return h('td', { class: 'mat-cell hs-cell-center' },
h('span', { class: 'hs-na',
title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted.",
html: PineappleIcons.question_mark }));
}
function boolGlyph(v) {
return h('td', { class: 'mat-cell hs-cell-center' },
h('span', { class: v ? 'hs-ok' : 'hs-bad', html: v ? PineappleIcons.check : PineappleIcons.close }));
}
function msgCell(inDb, present) {
return inDb ? boolGlyph(present) : naGlyph();
}
function load(done) {
PagerAPI.get('/api/pineap/handshakes').then((r) => {
box.innerHTML = '';
const head = h('div', { class: 'recon-table-head' },
h('h2', { text: 'Captured WPA Handshakes' }),
h('span', { class: 'toolbar-spacer' }),
iconBtn('settings', 'Handshakes settings', openSettings));
box.appendChild(head);
box.appendChild(h('div', { class: 'row' },
h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })),
h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))),
h('div', {}, btn('Refresh', () => load(), 'ghost'))));
const hs = r.data.handshakes || [];
if (!hs.length) {
box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' }));
if (done) done();
return;
}
const t = h('table', { class: 'tbl' });
const thead = h('thead');
const th = h('tr');
['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2',
'Message 3', 'Message 4', 'Beacon Frame', '']
.forEach((c) => th.appendChild(h('th', { text: c })));
thead.appendChild(th);
t.appendChild(thead);
const tb = h('tbody');
hs.forEach((f) => {
const trr = h('tr');
trr.appendChild(textCell(f.mac));
trr.appendChild(textCell(f.client));
trr.appendChild(textCell(f.source));
trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name)));
trr.appendChild(textCell(fmtTime(f.timestamp)));
[1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0)));
trr.appendChild(msgCell(f.in_db, !!f.beacon));
const act = h('td', { class: 'mat-cell' },
h('span', { class: 'hs-actions' },
hsIconBtn('file_download', 'Download', '', () => {
window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name);
}),
hsIconBtn('delete', 'Delete', 'hs-warn', () => {
PagerAPI.del('/api/pineap/handshakes', { name: f.name })
.then(() => load(() => flash(true, 'Deleted ' + f.name)))
.catch(() => flash(false, 'Failed to delete ' + f.name));
})));
trr.appendChild(act);
tb.appendChild(trr);
});
t.appendChild(tb);
box.appendChild(t);
if (done) done();
}).catch(() => {
if (box.querySelector('.recon-table-head')) flash(false, 'Failed to load handshakes');
else App.toast('Failed to load handshakes', 'error');
});
}
function openSettings() {
PagerAPI.get('/api/pineap/handshakes/location').then((r) => {
const loc = (r.data || {}).location || '--';
const overlay = h('div', { class: 'modal-overlay' });
function close() { overlay.remove(); }
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const modal = h('div', { class: 'modal' },
h('div', { class: 'modal-title', text: 'Handshake Settings' }),
h('div', { class: 'modal-body' },
h('div', { class: 'hs-settings-row' },
h('span', { class: 'hs-settings-label', text: 'Handshake Location' }),
h('span', { class: 'hs-settings-value', text: loc })),
btn('Delete All Handshakes', () => {
PagerAPI.del('/api/pineap/handshakes/all')
.then(() => { close(); load(() => flash(true, 'All handshakes deleted')); })
.catch(() => { close(); flash(false, 'Failed to delete all handshakes'); });
}, 'danger')),
h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost')));
overlay.appendChild(modal);
document.body.appendChild(overlay);
}).catch(() => App.toast('Failed to load handshake settings', 'error'));
}
load();
return { destroy: () => {} };
};
views.logging = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Logging' }));
tabBar(root, LOGGING_TABS, '#/logging');
const lines = [];
const search = h('input', { class: 'log-search', placeholder: 'Search activity log' });
const output = h('pre', { class: 'logs' });
const pineBox = h('div', { class: 'section' },
h('div', { class: 'log-toolbar' },
h('h2', {}, 'Activity Log'),
h('span', { class: 'toolbar-spacer' }),
search,
btn('Download', () => downloadText('pineap.log', lines.join('\n')), 'ghost'),
btn('Refresh', load, 'ghost')),
output);
root.appendChild(pineBox);
function render() {
const q = search.value.trim().toLowerCase();
output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n');
}
search.addEventListener('input', render);
let pending = false;
function load() {
if (pending) return;
pending = true;
PagerAPI.get('/api/logging/pineap?lines=200').then((r) => {
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
render();
}).catch(() => { App.toast('Failed to load PineAP log', 'error'); })
.finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 10000);
return { destroy: () => clearInterval(iv) };
};
views.logging_system = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Logging' }));
tabBar(root, LOGGING_TABS, '#/logging/system');
const lines = [];
const search = h('input', { class: 'log-search', placeholder: 'Search system log' });
const output = h('pre', { class: 'logs' });
const box = h('div', { class: 'section' },
h('div', { class: 'log-toolbar' },
h('h2', {}, 'System Log'),
h('span', { class: 'toolbar-spacer' }),
search,
btn('Download', () => downloadText('system.log', lines.join('\n')), 'ghost'),
btn('Refresh', load, 'ghost')),
output);
root.appendChild(box);
function render() {
const q = search.value.trim().toLowerCase();
output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n');
}
search.addEventListener('input', render);
let pending = false;
function load() {
if (pending) return;
pending = true;
PagerAPI.get('/api/logging/system?lines=400').then((r) => {
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
render();
}).catch(() => { App.toast('Failed to load system log', 'error'); })
.finally(() => { pending = false; });
}
load();
const iv = setInterval(load, 10000);
return { destroy: () => clearInterval(iv) };
};
const PAYLOAD_TABS = [
{ label: 'Installed', hash: '#/modules' },
{ label: 'Payloads', hash: '#/modules/online' },
{ label: 'Running', hash: '#/modules/running' },
{ label: 'Develop', hash: '#/modules/develop' }
];
function payloadShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'Payloads' }));
tabBar(root, PAYLOAD_TABS, activeHash);
const box = h('div', { class: 'payload-content' });
root.appendChild(box);
return box;
}
function payloadToolbar(title, description, action) {
const head = h('div', { class: 'payload-heading' },
h('div', {}, h('h2', { text: title }), h('p', { class: 'muted', text: description })));
if (action) head.appendChild(action);
return head;
}
function payloadFilters(onchange) {
const search = h('input', { class: 'payload-search', placeholder: 'Search payloads' });
const category = h('select', { class: 'payload-category' }, h('option', { value: '', text: 'All categories' }));
search.addEventListener('input', onchange);
category.addEventListener('change', onchange);
return { search, category, node: h('div', { class: 'payload-filters' }, search, category) };
}
function fillPayloadCategories(select, rows, key) {
const values = Array.from(new Set((rows || []).map((row) => row[key] || '').filter(Boolean))).sort();
values.forEach((value) => select.appendChild(h('option', { value, text: value.replace(/_/g, ' ') })));
}
function payloadVersionTag(version) {
const value = String(version || '').trim();
return value ? (/^v/i.test(value) ? value : 'v' + value) : '';
}
function payloadCard(item, actions, tags) {
const meta = h('div', { class: 'payload-meta' });
(tags || []).filter(Boolean).forEach((tag) => meta.appendChild(h('span', { class: 'payload-tag', text: tag })));
const controls = h('div', { class: 'payload-actions' });
(actions || []).forEach((action) => controls.appendChild(action));
return h('article', { class: 'payload-card' },
h('div', { class: 'payload-card-main' },
h('h3', { text: item.title || item.key || 'Untitled payload' }),
h('p', { class: 'payload-author muted', text: item.author ? 'by ' + item.author : 'Local payload' }),
h('p', { class: 'payload-description', text: item.description || 'No description provided.' }),
meta), controls);
}
function setPayloadBusy(button, busy, label) {
button.disabled = busy;
button.textContent = busy ? (label || button.dataset.label || button.textContent)
: (button.dataset.label || button.textContent);
}
views.modules = (root) => {
const box = payloadShell(root, '#/modules');
let alive = true;
let rows = [];
const list = h('div', { class: 'payload-list' });
const status = h('p', { class: 'payload-result-count muted' });
const filters = payloadFilters(render);
const refresh = btn('Refresh', load, 'ghost');
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Installed Payloads', 'Payloads installed on this Pager. Launch them here or manage their Pager Portal version.', refresh),
filters.node, status, list));
function render() {
if (!alive) return;
const q = filters.search.value.trim().toLowerCase();
const category = filters.category.value;
const visible = rows.filter((item) => (!category || item.category === category) &&
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
status.textContent = visible.length + ' of ' + rows.length + ' installed';
list.innerHTML = '';
if (!visible.length) {
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No payloads are installed. Get payloads from the Payloads tab.' }));
return;
}
visible.forEach((item) => {
const launch = btn('Run', () => {
setPayloadBusy(launch, true, 'Starting...');
PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => {
App.toast(item.title + ' started');
location.hash = '#/modules/running';
}).catch(apiError('Unable to start payload')).finally(() => setPayloadBusy(launch, false));
});
launch.dataset.label = 'Run';
if (item.disabled || item.key === 'user~remote_access~pager-webui') {
launch.disabled = true;
launch.title = item.disabled ? 'This payload is disabled' : 'The WebUI cannot launch itself';
}
const update = item.update ? btn('Update', () => installPayload(item, update), 'ghost') : null;
if (update) update.dataset.label = 'Update';
const remove = btn('Remove', () => {
if (!confirm('Remove "' + item.title + '" from this Pager?')) return;
setPayloadBusy(remove, true, 'Removing...');
PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => {
App.toast(item.title + ' removed'); load();
}).catch(apiError('Unable to remove payload')).finally(() => setPayloadBusy(remove, false));
}, 'danger');
remove.dataset.label = 'Remove';
if (item.key === 'user~remote_access~pager-webui') {
remove.disabled = true;
remove.title = 'The active WebUI cannot remove itself';
}
list.appendChild(payloadCard(item, [launch, update, remove].filter(Boolean),
[item.category && item.category.replace(/_/g, ' '), payloadVersionTag(item.version),
item.update && 'Update available', item.missingmanifest && 'Local manifest']));
});
}
function installPayload(item, button) {
setPayloadBusy(button, true, 'Updating...');
PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
App.toast(item.title + ' updated'); load();
}).catch(apiError('Unable to update payload')).finally(() => setPayloadBusy(button, false));
}
function load() {
refresh.disabled = true;
PagerAPI.get('/api/payloads/installed').then((r) => {
if (!alive) return;
rows = (r.data && r.data.payloads) || [];
filters.category.innerHTML = '<option value="">All categories</option>';
fillPayloadCategories(filters.category, rows, 'category');
render();
}).catch(apiError('Unable to load installed payloads')).finally(() => { refresh.disabled = false; });
}
load();
return { destroy: () => { alive = false; } };
};
views.modules_online = (root) => {
const box = payloadShell(root, '#/modules/online');
let alive = true;
let rows = [], installed = new Set();
const list = h('div', { class: 'payload-list' });
const status = h('p', { class: 'payload-result-count muted' });
const filters = payloadFilters(render);
const refresh = btn('Get Available Payloads', () => {
refresh.disabled = true;
PagerAPI.post('/api/payloads/refresh').then(() => {
App.toast('Pager Portal listings refreshed'); return load();
}).catch(apiError('Unable to refresh Pager Portal')).finally(() => { refresh.disabled = false; });
});
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Available Payloads', 'Browse community payloads from the Pager Portal and install them directly on this Pager.', refresh),
filters.node, status, list));
function render() {
if (!alive) return;
const q = filters.search.value.trim().toLowerCase();
const category = filters.category.value;
const matches = rows.filter((item) => (!category || item.parent === category) &&
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
const visible = matches.slice(0, 120);
status.textContent = matches.length + ' result' + (matches.length === 1 ? '' : 's') +
(matches.length > visible.length ? ' - showing first ' + visible.length : '');
list.innerHTML = '';
if (!visible.length) {
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No Portal listing is cached. Select Get Available Payloads.' }));
return;
}
visible.forEach((item) => {
const isInstalled = installed.has(item.key);
const install = btn(isInstalled ? 'Installed' : 'Install', () => {
if (installed.has(item.key)) return;
install.dataset.label = 'Install';
setPayloadBusy(install, true, 'Installing...');
PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
installed.add(item.key); App.toast(item.title + ' installed'); render();
}).catch(apiError('Unable to install payload')).finally(() => setPayloadBusy(install, false));
}, isInstalled ? 'ghost' : '');
install.dataset.label = isInstalled ? 'Installed' : 'Install';
install.disabled = isInstalled;
list.appendChild(payloadCard(item, [install],
[item.parent && item.parent.replace('/', ' / ').replace(/_/g, ' '), payloadVersionTag(item.version)]));
});
}
function load() {
return Promise.all([PagerAPI.get('/api/payloads/index'), PagerAPI.get('/api/payloads/installed')]).then((results) => {
if (!alive) return;
rows = (results[0].data && results[0].data.payloads) || [];
installed = new Set(((results[1].data && results[1].data.payloads) || []).map((item) => item.key));
filters.category.innerHTML = '<option value="">All categories</option>';
fillPayloadCategories(filters.category, rows, 'parent');
render();
}).catch(apiError('Unable to load Pager Portal payloads'));
}
load();
return { destroy: () => { alive = false; } };
};
views.modules_running = (root) => {
const box = payloadShell(root, '#/modules/running');
let alive = true, pending = false;
const list = h('div', { class: 'payload-runs' });
const refresh = btn('Refresh', load, 'ghost');
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Running Payloads', 'Tracks payloads launched from this WebUI session. Output is read from each payload process.', refresh), list));
function render(rows) {
if (!alive) return;
list.innerHTML = '';
if (!rows.length) {
list.appendChild(h('div', { class: 'payload-empty', text: 'No payloads have been launched from this WebUI session.' }));
return;
}
rows.forEach((run) => {
const state = h('span', { class: 'payload-run-state ' + (run.running ? 'running' : 'finished'),
text: run.running ? 'Running' : (run.returncode === 0 ? 'Completed' : 'Exited ' + run.returncode) });
const actions = h('div', { class: 'payload-actions' });
if (run.running) {
const stop = btn('Stop', () => {
stop.disabled = true;
PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => {
App.toast(run.title + ' stop requested'); setTimeout(load, 500);
}).catch(apiError('Unable to stop payload')).finally(() => { stop.disabled = false; });
}, 'danger');
actions.appendChild(stop);
}
const output = h('pre', { class: 'payload-output', text: run.output || 'No output yet.' });
list.appendChild(h('article', { class: 'payload-run' },
h('div', { class: 'payload-run-head' },
h('div', {}, h('h3', { text: run.title }),
h('p', { class: 'muted', text: 'PID ' + run.pid + ' - started ' + fmtTime(run.started) })),
state, actions), output));
});
}
function load() {
if (pending) return;
pending = true; refresh.disabled = true;
PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || []))
.catch(apiError('Unable to load payload runs')).finally(() => { pending = false; refresh.disabled = false; });
}
load();
const iv = setInterval(load, 3000);
return { destroy: () => { alive = false; clearInterval(iv); } };
};
views.modules_develop = (root) => {
const box = payloadShell(root, '#/modules/develop');
box.appendChild(h('div', { class: 'section payload-develop' },
h('h2', { text: 'Developing Pager Payloads' }),
h('p', { text: 'Pager payloads are native shell or script-based workflows, not Pineapple modules. A payload lives in a context and category folder and declares its launchpoint and metadata in _hak5_manifest.json.' }),
h('div', { class: 'payload-dev-grid' },
h('div', { class: 'payload-dev-block' },
h('h3', { text: 'Payload structure' }),
h('pre', { class: 'payload-code', text: '/root/payloads/user/general/my_payload/\n payload.sh\n _hak5_manifest.json\n assets/' })),
h('div', { class: 'payload-dev-block' },
h('h3', { text: 'Native capabilities' }),
h('p', { text: 'Scripts launched here keep access to Pager commands such as ALERT, LOG, CONFIRMATION_DIALOG, TEXT_PICKER and other hak5cmd-backed helpers.' }))),
h('p', { class: 'muted', text: 'Use the terminal to create or inspect payload files. Refresh Installed when the manifest changes so the firmware inventory is re-read.' }),
btn('Open Terminal', () => {
const terminal = document.getElementById('terminal-btn');
if (terminal) terminal.click();
})));
return { destroy: () => {} };
};
const SETTINGS_TABS = [
{ label: 'General', hash: '#/settings' },
{ label: 'Networking', hash: '#/settings/networking' },
{ label: 'WiFi', hash: '#/settings/wifi' },
{ label: 'LED', hash: '#/settings/led' },
{ label: 'Advanced', hash: '#/settings/advanced' },
{ label: 'Help', hash: '#/settings/help' }
];
function settingsShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'Settings' }));
tabBar(root, SETTINGS_TABS, activeHash);
const box = h('div', { class: 'settings-content' });
root.appendChild(box);
return box;
}
function settingsCard(box, title, subtitle) {
const card = h('div', { class: 'section settings-card' }, h('h2', {}, title));
if (subtitle) card.appendChild(h('p', { class: 'muted settings-subtitle', text: subtitle }));
box.appendChild(card);
return card;
}
function apiError(message) {
return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error');
}
const WIFI_CLIENT_ENC_VALUES = {
'Open': 'open', 'WPA2': 'wpa2', 'WPA3': 'wpa3', 'WPA2/WPA3': 'wpa2wpa3'
};
function clientModeEncValue(label) {
return WIFI_CLIENT_ENC_VALUES[label] || (label && label !== 'Open' ? 'wpa2' : 'open');
}
function clientModePanel(box, options) {
const state = { status: null, networks: [], connecting: false, generation: 0 };
const statusEl = h('div', { class: 'client-status' });
const routingSwitch = h('input', { type: 'checkbox' });
const routingRow = h('label', { class: 'switch client-routing' },
routingSwitch, h('span', { class: 'track' }), 'Route LAN clients through this connection');
const actionsEl = h('div', { class: 'client-actions' });
const netsEl = h('div', { class: 'client-networks' });
routingSwitch.addEventListener('change', () => {
if (!(state.status || {}).enabled) {
routingSwitch.checked = false;
App.toast('Client mode must be enabled before routing can be changed', 'error');
return;
}
PagerAPI.post('/api/settings/wifi/client/route', { routed: routingSwitch.checked })
.then(() => {
if (state.status) state.status.routed = routingSwitch.checked;
App.toast('Client routing ' + (routingSwitch.checked ? 'enabled' : 'disabled'));
App.checkInternet(false);
})
.catch((e) => {
routingSwitch.checked = !routingSwitch.checked;
App.toast(e.message || 'Failed to update routing', 'error');
});
});
function renderStatus() {
const s = state.status || {};
statusEl.innerHTML = '';
const lines = [];
if (s.connected) {
lines.push(['Status', 'Connected'], ['Network', s.connected_ssid || s.ssid || '\u2014'],
['IP Address', s.ip || '\u2014'],
['Signal', s.signal != null ? s.signal + ' dBm' : '\u2014']);
} else if (s.enabled) {
lines.push(['Status', 'Enabled \u2014 not associated'], ['Network', s.ssid || '\u2014']);
} else {
lines.push(['Status', 'Disabled']);
}
lines.forEach(([k, v]) => statusEl.appendChild(h('div', { class: 'client-kv' },
h('span', { text: k }), h('code', { text: v }))));
routingSwitch.checked = !!s.routed;
routingRow.classList.toggle('hidden', !s.enabled);
}
function refresh() {
PagerAPI.get('/api/settings/wifi/client').then((r) => {
state.status = r.data || {};
renderStatus();
}).catch(apiError('Failed to load client mode status'));
}
function renderNetworks() {
netsEl.innerHTML = '';
if (!state.networks.length) {
netsEl.appendChild(h('div', { class: 'empty', text: 'No networks shown. Scan to discover nearby WiFi.' }));
return;
}
state.networks.forEach((net) => {
const needsPw = net.encryption !== 'Open';
const row = h('div', { class: 'client-net-row' },
h('div', { class: 'client-net-main' },
h('span', { class: 'client-net-ssid', text: net.ssid || '(hidden)' }),
h('span', { class: 'client-net-enc', text: net.encryption })),
h('span', { class: 'client-net-signal', text: net.signal != null ? net.signal + ' dBm' : '\u2014' }),
btn(needsPw ? 'Connect' : 'Join', () => connectRow(row, net), needsPw ? '' : 'primary'));
netsEl.appendChild(row);
});
}
function connectRow(row, net) {
row.innerHTML = '';
const encSel = h('select', {},
h('option', { value: 'wpa2', text: 'WPA2' }),
h('option', { value: 'wpa3', text: 'WPA3' }),
h('option', { value: 'wpa2wpa3', text: 'WPA2/WPA3' }));
const value = clientModeEncValue(net.encryption);
encSel.value = value === 'open' ? 'wpa2' : value;
const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'new-password' });
const routedCb = h('input', { type: 'checkbox' });
const routedLabel = h('label', { class: 'switch client-routing' }, routedCb,
h('span', { class: 'track' }), 'Route LAN clients');
const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel,
btn('Connect', () => doConnect({ ssid: net.ssid, encryption: encSel.value,
password: pw.value, routed: routedCb.checked }), 'primary'),
btn('Cancel', () => renderNetworks(), 'ghost'));
row.appendChild(form);
pw.focus();
}
function doConnect(opts) {
if (state.connecting) return;
state.connecting = true;
setBusy(true);
PagerAPI.post('/api/settings/wifi/client/connect', opts)
.then(() => {
App.toast('Connecting to ' + opts.ssid + '\u2026');
state.status = null;
pollConnected(opts.ssid);
})
.catch((e) => {
state.connecting = false;
setBusy(false);
App.toast(e.message || 'Failed to connect', 'error');
refresh();
});
}
function pollConnected(targetSsid) {
const gen = ++state.generation;
let attempts = 0;
let ipWait = 0;
const tick = () => {
if (gen !== state.generation) return;
PagerAPI.get('/api/settings/wifi/client').then((r) => {
const s = r.data || {};
state.status = s;
renderStatus();
if (s.connected && s.ip) {
finish('Connected to ' + (s.connected_ssid || targetSsid));
} else if (s.connected && ++ipWait < 6) {
setTimeout(tick, 2000);
} else if (!s.connected && ++attempts >= 22) {
finish('Timed out waiting for ' + targetSsid + ' to connect', 'error');
} else if (!s.connected) {
setTimeout(tick, 2000);
} else {
finish('Connected to ' + (s.connected_ssid || targetSsid));
}
}).catch(() => {
state.connecting = false;
setBusy(false);
App.toast('Failed to check connection status', 'error');
});
};
function finish(message, kind) {
state.connecting = false;
setBusy(false);
App.toast(message, kind);
App.checkInternet(false);
}
setTimeout(tick, 2000);
}
function doDisconnect() {
if (state.connecting) return;
PagerAPI.post('/api/settings/wifi/client/disconnect')
.then(() => {
App.toast('WiFi client disabled');
state.networks = [];
refresh();
App.checkInternet(false);
})
.catch((e) => App.toast(e.message || 'Failed to disconnect', 'error'));
}
function scan() {
if (state.connecting) return;
netsEl.textContent = 'Scanning\u2026';
PagerAPI.post('/api/settings/wifi/client/scan').then((r) => {
state.networks = (r.data && r.data.networks) || [];
renderNetworks();
}).catch((e) => {
netsEl.innerHTML = '';
netsEl.appendChild(h('div', { class: 'empty', text: 'Scan failed: ' + (e.message || 'unknown error') }));
});
}
function setBusy(busy) {
const buttons = actionsEl.querySelectorAll('button');
buttons.forEach((b) => { b.disabled = busy; });
}
actionsEl.appendChild(btn('Scan for Networks', scan));
actionsEl.appendChild(btn('Disconnect', doDisconnect));
actionsEl.appendChild(btn('Refresh', refresh, 'ghost'));
if (options && options.close) actionsEl.appendChild(btn('Close', options.close, 'ghost'));
box.appendChild(statusEl);
box.appendChild(routingRow);
box.appendChild(actionsEl);
box.appendChild(netsEl);
refresh();
return { refresh };
}
views.openClientModeModal = () => {
const overlay = h('div', { class: 'modal-overlay' });
function close() { overlay.remove(); }
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const modal = h('div', { class: 'modal client-modal' },
h('div', { class: 'modal-title', text: 'Internet Connection' }),
h('div', { class: 'modal-body' }));
overlay.appendChild(modal);
document.body.appendChild(overlay);
clientModePanel(modal.querySelector('.modal-body'), { close });
};
views.settings = (root) => {
const box = settingsShell(root, '#/settings');
const user = settingsCard(box, 'User Management & Timezone');
const currentPw = h('input', { type: 'password', autocomplete: 'current-password' });
const newPw = h('input', { type: 'password', autocomplete: 'new-password' });
const repeatPw = h('input', { type: 'password', autocomplete: 'new-password' });
user.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'Current Password', currentPw),
h('label', {}, 'New Password', newPw),
h('label', {}, 'Repeat New Password', repeatPw)));
user.appendChild(btn('Update Password', () => {
if (!currentPw.value) { App.toast('Current password is required', 'error'); return; }
if (newPw.value.length < 4) { App.toast('New password is too short', 'error'); return; }
if (newPw.value !== repeatPw.value) { App.toast('New passwords do not match', 'error'); return; }
PagerAPI.post('/api/settings/password', {
current_password: currentPw.value, new_password: newPw.value,
repeat_password: repeatPw.value
}).then(() => {
currentPw.value = ''; newPw.value = ''; repeatPw.value = '';
App.toast('Root password updated');
}).catch(apiError('Failed to update password'));
}));
const timezones = [
['UTC', '(GMT+0) Coordinated Universal Time', 'Etc/UTC'],
['EST5EDT,M3.2.0,M11.1.0', '(GMT-5) Eastern Time (US & Canada)', 'America/New_York'],
['CST6CDT,M3.2.0,M11.1.0', '(GMT-6) Central Time (US & Canada)', 'America/Chicago'],
['MST7MDT,M3.2.0,M11.1.0', '(GMT-7) Mountain Time (US & Canada)', 'America/Denver'],
['MST7', '(GMT-7) Arizona', 'America/Phoenix'],
['PST8PDT,M3.2.0,M11.1.0', '(GMT-8) Pacific Time (US & Canada)', 'America/Los_Angeles'],
['AKST9AKDT,M3.2.0,M11.1.0', '(GMT-9) Alaska', 'America/Anchorage'],
['HST10', '(GMT-10) Hawaii', 'Pacific/Honolulu'],
['GMT0BST,M3.5.0/1,M10.5.0', '(GMT+0) London', 'Europe/London'],
['CET-1CEST,M3.5.0,M10.5.0/3', '(GMT+1) Central Europe', 'Europe/Berlin'],
['JST-9', '(GMT+9) Japan', 'Asia/Tokyo'],
['AEST-10AEDT,M10.1.0,M4.1.0/3', '(GMT+10) Sydney', 'Australia/Sydney']
];
const tzSel = h('select', { 'aria-label': 'Timezone' });
timezones.forEach(([value, label, zone]) => tzSel.appendChild(h('option', { value, text: label, 'data-zone': zone })));
const tzActions = h('div', { class: 'settings-actions' },
btn('Update Timezone', () => {
const opt = tzSel.options[tzSel.selectedIndex];
PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' })
.then(() => App.toast('Timezone updated')).catch(apiError('Failed to update timezone'));
}),
btn('Sync Browser Time', () => {
const d = new Date();
const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' +
String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0');
PagerAPI.post('/api/settings/synctime', { timestamp: stamp })
.then(() => App.toast('Pager time synchronized')).catch(apiError('Failed to synchronize time'));
}, 'ghost'));
user.appendChild(h('label', {}, 'Timezone', tzSel));
user.appendChild(tzActions);
PagerAPI.get('/api/settings/timezone').then((r) => {
const value = (r.data || {}).timezone || 'UTC';
if (!Array.from(tzSel.options).some((o) => o.value === value)) {
tzSel.insertBefore(h('option', { value, text: value + ' (current)' }), tzSel.firstChild);
}
tzSel.value = value;
}).catch(apiError('Unable to load timezone'));
const ntp = settingsCard(box, 'Network Time', 'Configure the Pager\'s NTP client.');
const ntpEnabled = h('input', { type: 'checkbox' });
const ntpServers = h('input', {});
ntp.appendChild(h('label', { class: 'switch' }, ntpEnabled, h('span', { class: 'track' }), 'Enabled'));
ntp.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers));
ntp.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', {
enabled: ntpEnabled.checked,
servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean)
}).then(() => App.toast('Network time settings saved')).catch(apiError('Failed to save network time'))));
PagerAPI.get('/api/settings/ntp').then((r) => {
ntpEnabled.checked = !!r.data.enabled;
ntpServers.value = (r.data.servers || []).join(', ');
}).catch(apiError('Unable to load network time settings'));
const details = settingsCard(box, 'Device & Web Interface');
const detailBody = h('div', { class: 'settings-kv-grid' });
details.appendChild(detailBody);
Promise.all([PagerAPI.get('/api/status'), PagerAPI.get('/api/settings/service')]).then(([status, service]) => {
const s = status.data || {}, svc = service.data || {};
[['Model', 'WiFi Pineapple Pager'], ['Firmware', s.firmware || 'Unavailable'],
['Uptime', fmtDur(s.uptime)], ['Disk', s.disk && s.disk.size != null ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'],
['WebUI Service', (svc.running ? 'Running' : 'Stopped') + (svc.background ? ' (background)' : '')]]
.forEach(([k, v]) => detailBody.appendChild(h('div', { class: 'settings-kv' }, h('span', { text: k }), h('code', { text: v }))));
}).catch(apiError('Unable to load device details'));
settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.');
const resources = settingsCard(box, 'Resources');
const resourceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
resources.appendChild(resourceBody);
PagerAPI.get('/api/settings/resources').then((r) => {
resourceBody.innerHTML = '';
resourceBody.appendChild(table([
{ label: 'Filesystem', key: 'filesystem' }, { label: 'Format', key: 'format' },
{ label: 'Size', render: (x) => fmtBytes(x.size) }, { label: 'Used', render: (x) => fmtBytes(x.used) },
{ label: 'Available', render: (x) => fmtBytes(x.available) }, { label: 'Used %', key: 'used_percent' },
{ label: 'Mount Point', key: 'mount' }
], (r.data || {}).filesystems || []));
}).catch(() => { resourceBody.textContent = 'Unable to load filesystems.'; });
const usb = settingsCard(box, 'USB Devices');
const usbBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
usb.appendChild(usbBody);
PagerAPI.get('/api/settings/usb').then((r) => {
usbBody.innerHTML = '';
const rows = (r.data || {}).devices || [];
usbBody.appendChild(table([{ label: 'Bus', key: 'bus' }, { label: 'Device', key: 'device' },
{ label: 'VID:PID', key: 'id' }, { label: 'Name', key: 'name' }], rows));
if (!rows.length) usbBody.appendChild(h('div', { class: 'empty', text: 'No USB devices reported.' }));
}).catch(() => { usbBody.textContent = 'Unable to load USB devices.'; });
return { destroy: () => {} };
};
views.settings_networking = (root) => {
const box = settingsShell(root, '#/settings/networking');
const client = settingsCard(box, 'Wireless Client Mode', 'Connect the Pager to an in-range WiFi network for internet access. The management AP keeps running; the radio channel follows the selected network.');
clientModePanel(client, {});
const recon = settingsCard(box, 'Recon Wireless Interfaces');
const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' });
recon.appendChild(reconBody);
const interfaces = settingsCard(box, 'Interfaces');
const interfaceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
interfaces.appendChild(interfaceBody);
const routes = settingsCard(box, 'Routing Table');
const routeBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
routes.appendChild(routeBody);
PagerAPI.get('/api/settings/network').then((r) => {
const d = r.data || {};
reconBody.innerHTML = '';
(d.recon_interfaces || []).forEach((name) => reconBody.appendChild(h('code', { class: 'settings-chip', text: name })));
if (!(d.recon_interfaces || []).length) reconBody.textContent = 'No monitor interfaces detected.';
interfaceBody.innerHTML = '';
interfaceBody.appendChild(table([{ label: 'Name', key: 'name' },
{ label: 'IP Address', render: (x) => (x.addresses || []).join(', ') || '—' },
{ label: 'MAC Address', render: (x) => x.mac || '—' },
{ label: 'Flags', render: (x) => (x.flags || []).join(', ') }], d.interfaces || []));
routeBody.innerHTML = '';
routeBody.appendChild(table([{ label: 'Destination', key: 'destination' }, { label: 'Gateway', key: 'gateway' },
{ label: 'Genmask', key: 'genmask' }, { label: 'Interface', key: 'interface' },
{ label: 'Flags', key: 'flags' }, { label: 'Metric', key: 'metric' },
{ label: 'Ref', key: 'ref' }, { label: 'Use', key: 'use' }], d.routes || []));
}).catch(apiError('Unable to load networking information'));
return { destroy: () => {} };
};
views.settings_wifi = (root) => {
const box = settingsShell(root, '#/settings/wifi');
const card = settingsCard(box, 'Management Access Point', 'Configure the Pager management WiFi interface. Saving reloads wireless services; USB management access remains available.');
const ssid = h('input', {}), bssid = h('input', { placeholder: 'Optional' });
const password = h('input', { type: 'password', placeholder: 'Leave blank to keep the existing password', autocomplete: 'new-password' });
const confirm = h('input', { type: 'password', placeholder: 'Repeat only when changing it', autocomplete: 'new-password' });
const hidden = h('input', { type: 'checkbox' }), enabled = h('input', { type: 'checkbox' });
card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Management SSID', ssid), h('label', {}, 'BSSID', bssid)));
card.appendChild(h('label', { class: 'switch' }, hidden, h('span', { class: 'track' }), 'Hidden'));
card.appendChild(h('label', { class: 'switch' }, enabled, h('span', { class: 'track' }), 'Enabled'));
card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Password', password), h('label', {}, 'Confirm Password', confirm)));
const save = btn('Save', () => {
if (password.value !== confirm.value) { App.toast('Management passwords do not match', 'error'); return; }
save.disabled = true;
PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(),
password: password.value, hidden: hidden.checked, enabled: enabled.checked })
.then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); })
.catch(apiError('Failed to save management WiFi')).finally(() => { save.disabled = false; });
});
card.appendChild(save);
PagerAPI.get('/api/settings/wifi/management').then((r) => {
const d = r.data || {}; ssid.value = d.ssid || ''; bssid.value = d.bssid || '';
hidden.checked = !!d.hidden; enabled.checked = !!d.enabled;
}).catch(apiError('Unable to load management WiFi'));
return { destroy: () => {} };
};
views.settings_led = (root) => {
const box = settingsShell(root, '#/settings/led');
const card = settingsCard(box, 'LED Configuration', 'Pager-specific equivalents of the Mark VII LED controls. Settings are stored in the native Pager configuration.');
const color = h('select', {});
['red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white'].forEach((v) => color.appendChild(h('option', { value: v, text: v[0].toUpperCase() + v.slice(1) })));
const vibrate = h('input', { type: 'checkbox' }), clock24 = h('input', { type: 'checkbox' });
const lcd = h('input', { type: 'number', min: '1', max: '11' });
const dim = h('input', { type: 'number', min: '0', max: '11' });
const dimTimeout = h('input', { type: 'number', min: '0', max: '3600' });
const lcdTimeout = h('input', { type: 'number', min: '0', max: '86400' });
card.appendChild(h('label', {}, 'Status LED color', color));
card.appendChild(h('label', { class: 'switch' }, vibrate, h('span', { class: 'track' }), 'Vibrate with alerts'));
card.appendChild(h('label', { class: 'switch' }, clock24, h('span', { class: 'track' }), '24-hour clock'));
card.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'LCD brightness (111)', lcd), h('label', {}, 'Dim brightness (011)', dim),
h('label', {}, 'Dim timeout (seconds)', dimTimeout), h('label', {}, 'LCD timeout (seconds)', lcdTimeout)));
function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked,
lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value),
dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; }
card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values())
.then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences'))));
PagerAPI.get('/api/settings/hardware').then((r) => {
const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate;
clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness;
dimTimeout.value = d.dim_timeout; lcdTimeout.value = d.lcd_timeout;
}).catch(apiError('Unable to load hardware preferences'));
return { destroy: () => {} };
};
views.settings_advanced = (root) => {
const box = settingsShell(root, '#/settings/advanced');
const updates = settingsCard(box, 'Alternative Updates', 'Select the Pager software update channel. Update checking and firmware installation remain in the native Pager workflow.');
const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }),
h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' }));
updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel));
updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value })
.then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel'))));
const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.');
const hostname = h('input', {});
host.appendChild(h('label', {}, 'Hostname', hostname));
host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() })
.then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname'))));
const webui = settingsCard(box, 'Web Interface');
const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' }));
const poll = h('input', { type: 'number', min: '2', max: '120' });
const hotkeys = h('input', { type: 'checkbox' });
theme.value = Theme.current(); poll.value = localStorage.getItem('pw-poll') || '5';
hotkeys.checked = localStorage.getItem('pw_hotkeys') !== 'false';
webui.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'UI Theme', theme), h('label', {}, 'Poll interval (seconds)', poll)));
webui.appendChild(h('label', { class: 'switch' }, hotkeys, h('span', { class: 'track' }), 'Enable Hot Keys'));
webui.appendChild(btn('Save Web Interface', () => {
localStorage.setItem(Theme.KEY, theme.value); localStorage.setItem('pw-poll', poll.value);
localStorage.setItem('pw_hotkeys', hotkeys.checked ? 'true' : 'false'); Theme.apply();
App.toast('Web interface preferences saved');
}));
PagerAPI.get('/api/settings/advanced').then((r) => { channel.value = r.data.update_channel || 'stable'; hostname.value = r.data.hostname || ''; })
.catch(apiError('Unable to load advanced settings'));
return { destroy: () => {} };
};
views.settings_help = (root) => {
const box = settingsShell(root, '#/settings/help');
const help = settingsCard(box, 'Help & Information');
help.appendChild(h('p', {}, 'Documentation: ', h('a', { href: 'https://docs.hak5.org/wifi-pineapple/', target: '_blank', rel: 'noopener', text: 'docs.hak5.org' })));
help.appendChild(h('p', {}, 'Community support: ', h('a', { href: 'https://community.hak5.org/', target: '_blank', rel: 'noopener', text: 'community.hak5.org' })));
help.appendChild(h('p', {}, 'Downloads: ', h('a', { href: 'https://downloads.hak5.org/', target: '_blank', rel: 'noopener', text: 'downloads.hak5.org' })));
const diagnostics = settingsCard(box, 'Diagnostics', 'Generate a local support report containing device, network, storage, radio, USB, and recent log information.');
const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' });
let report = '';
const generate = btn('Generate Diagnostics', () => {
generate.disabled = true; output.textContent = 'Generating diagnostics…';
PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; })
.catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); })
.finally(() => { generate.disabled = false; });
});
diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate,
btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost')));
diagnostics.appendChild(output);
const license = settingsCard(box, 'Licenses');
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
return { destroy: () => {} };
};