'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) => trr.appendChild(h('td', { text: c.render ? c.render(r) : r[c.key] })));
tb.appendChild(trr);
});
t.appendChild(tb);
return t;
};
const fmtTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
return d.toLocaleString();
};
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 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); 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'));
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(() => {
cb.checked = !requested;
load();
App.toast('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', 'Broadcast the impersonation pool', 'Enable the PineAP response engine']
};
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 || {};
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 OPEN_CHANNELS = Array.from({ length: 11 }, (_, i) => {
const c = i + 1;
return [c, 'Channel ' + c + ' (' + (2412 + (c - 1) * 5) + ' MHz)'];
});
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' });
OPEN_CHANNELS.forEach(([v, l]) => chSel.appendChild(h('option', { value: v, text: l })));
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)),
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) chSel.value = String(open.channel);
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' });
cfg.appendChild(h('label', {}, 'SSID', ssidIn));
cfg.appendChild(h('label', {}, 'Passphrase', pskIn));
cfg.appendChild(h('label', {}, 'Encryption', encSel));
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 }
}).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;
}).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: '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_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: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : a.signal + ' dBm' },
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
{ 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 reconDefaultCols() {
return {
ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: 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) 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));
}
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';
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 };
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);
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
sel.addEventListener('change', () => {
state.selected = parseInt(sel.value, 10) || null;
state.apPage = 0; state.clientPage = 0;
loadDetail();
});
psRow.appendChild(sel);
psRow.appendChild(iconBtn('file_download', 'Download scan JSON', () => {
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json';
}));
psRow.appendChild(iconBtn('delete', 'Delete scan', () => {
if (state.selected == null) 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'));
}));
// ---- 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'], [0, 'Continuous']]
.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);
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
let pendingScan = false;
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.selected = null;
state.apPage = 0;
state.clientPage = 0;
App.toast('Scan started');
} else {
state.scanActive = false;
App.toast('Scan stopped');
}
restartPoll();
load();
})
.catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); })
.finally(() => { pendingScan = false; scanToggle.disabled = false; });
});
// ---- 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: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['channel', 'Show Channel'],
['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['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();
}
// ---- results tables ----
const apCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(apCard);
const cliCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(cliCard);
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 reconPageCount(key) {
const d = state.detail || {};
const rows = key === 'ap' ? (d.aps || []) : (d.clients || []);
const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS;
const q = key === 'ap' ? state.apSearch : state.clientSearch;
return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).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;
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;
}
function renderTables() {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
const apF = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS);
const cliF = reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
renderTable(apBody, 'ap', sortRows(apF, 'ap', RECON_AP_COLS), RECON_AP_COLS, 'No access points in this scan.');
renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
}
function drawCharts(d) {
const n = (d.aps || []).length;
const c = (d.clients || []).length;
const land = document.getElementById('recon-landscape');
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (n + c > 0) {
MiniChart.doughnut(land, [
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Unassociated', value: 0, color: RECON_LANDSCAPE_COLORS[2] }
], { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
land.classList.add('hidden');
landEmpty.classList.remove('hidden');
}
}
const counts = {};
(d.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 = {};
(d.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 ((d.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 loadDetail() {
if (state.selected == null) return;
PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => {
state.detail = r.data;
drawCharts(r.data);
renderTables();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {});
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
const keep = state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : (state.scans[0] ? state.scans[0].id : null);
sel.innerHTML = '';
state.scans.forEach((s) => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time);
sel.appendChild(opt);
});
if (keep == null) {
state.detail = null;
drawCharts({ aps: [], clients: [], handshakes: [] });
renderTables();
hsCount.textContent = '0';
}
if (keep != null) sel.value = keep;
state.selected = keep;
if (keep != null) loadDetail();
}).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
state.scanActive = scanning;
if (!pendingScan) scanToggle.checked = scanning;
restartPoll();
}).catch(() => {});
}
load();
let pollIv = null;
const restartPoll = () => {
clearInterval(pollIv);
pollIv = setInterval(load, state.scanActive ? 5000 : 10000);
};
restartPoll();
return { destroy: () => clearInterval(pollIv) };
};
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;
if (label) button.textContent = busy ? label : button.dataset.label;
}
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 = '';
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 = '';
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');
}
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');
settingsCard(box, 'Wireless Client Mode', 'The Pager firmware owns client-mode association. Status is shown here; connection changes remain in the native Pager interface to protect PineAP radio state.');
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 (1–11)', lcd), h('label', {}, 'Dim brightness (0–11)', 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: () => {} };
};