Files
Mark-VIII/payload/user/remote_access/pager-webui/www/js/views.js
T
bzuccaro 5f6dc5bcdb release: Mark VIII 1.1
Wireless client mode (connect to WiFi as client):
- settings/wifi/client API: state, scan, connect, disconnect, route
- Internet Connection topbar dialog and functional Settings > Networking card
- routing toggle syncing UCI flag and daemon state
- fix trailing-slash hash routes (View not available)
- hidden-SSID filtering, encryption classification (Open/WPA2/WPA3/mixed)
- tests for client state, scan parsing, connect/disconnect, routing
2026-08-17 22:28:41 -05:00

2513 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const views = {};
const h = (tag, attrs, ...children) => {
const n = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
if (k === 'class') n.className = attrs[k];
else if (k === 'text') n.textContent = attrs[k];
else if (k === 'html') n.innerHTML = attrs[k];
else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]);
else n.setAttribute(k, attrs[k]);
}
}
for (const c of children) {
if (c == null) continue;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return n;
};
const table = (columns, rows, rowAttrs) => {
const t = h('table', { class: 'tbl' });
const thead = h('thead'), tr = h('tr');
columns.forEach((c) => tr.appendChild(h('th', { text: c.label })));
thead.appendChild(tr); t.appendChild(thead);
const tb = h('tbody');
(rows || []).forEach((r) => {
const trr = h('tr', rowAttrs ? rowAttrs(r) : {});
columns.forEach((c) => 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, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null };
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']]
.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.autoFollow = true;
state.apPage = 0;
state.clientPage = 0;
App.toast('Scan started');
} else {
state.scanActive = false;
state.autoFollow = false;
App.toast('Scan stopped');
}
restartPoll();
load();
})
.catch((err) => {
scanToggle.checked = !on;
App.toast((err && err.message) || '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;
const scanId = state.selected;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
if (!state.scanActive && state.detailId === scanId) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
PagerAPI.get('/api/recon/scans/' + scanId).then((r) => {
if (state.selected !== scanId) return;
state.detail = r.data;
state.detailId = scanId;
drawCharts(r.data);
renderTables();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
const newest = state.scans[0] ? state.scans[0].id : null;
const keep = state.autoFollow
? newest
: (state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : newest);
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/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
if (!pendingScan) scanToggle.checked = scanning;
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).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;
button.textContent = busy ? (label || button.dataset.label || button.textContent)
: (button.dataset.label || button.textContent);
}
views.modules = (root) => {
const box = payloadShell(root, '#/modules');
let alive = true;
let rows = [];
const list = h('div', { class: 'payload-list' });
const status = h('p', { class: 'payload-result-count muted' });
const filters = payloadFilters(render);
const refresh = btn('Refresh', load, 'ghost');
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Installed Payloads', 'Payloads installed on this Pager. Launch them here or manage their Pager Portal version.', refresh),
filters.node, status, list));
function render() {
if (!alive) return;
const q = filters.search.value.trim().toLowerCase();
const category = filters.category.value;
const visible = rows.filter((item) => (!category || item.category === category) &&
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
status.textContent = visible.length + ' of ' + rows.length + ' installed';
list.innerHTML = '';
if (!visible.length) {
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No payloads are installed. Get payloads from the Payloads tab.' }));
return;
}
visible.forEach((item) => {
const launch = btn('Run', () => {
setPayloadBusy(launch, true, 'Starting...');
PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => {
App.toast(item.title + ' started');
location.hash = '#/modules/running';
}).catch(apiError('Unable to start payload')).finally(() => setPayloadBusy(launch, false));
});
launch.dataset.label = 'Run';
if (item.disabled || item.key === 'user~remote_access~pager-webui') {
launch.disabled = true;
launch.title = item.disabled ? 'This payload is disabled' : 'The WebUI cannot launch itself';
}
const update = item.update ? btn('Update', () => installPayload(item, update), 'ghost') : null;
if (update) update.dataset.label = 'Update';
const remove = btn('Remove', () => {
if (!confirm('Remove "' + item.title + '" from this Pager?')) return;
setPayloadBusy(remove, true, 'Removing...');
PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => {
App.toast(item.title + ' removed'); load();
}).catch(apiError('Unable to remove payload')).finally(() => setPayloadBusy(remove, false));
}, 'danger');
remove.dataset.label = 'Remove';
if (item.key === 'user~remote_access~pager-webui') {
remove.disabled = true;
remove.title = 'The active WebUI cannot remove itself';
}
list.appendChild(payloadCard(item, [launch, update, remove].filter(Boolean),
[item.category && item.category.replace(/_/g, ' '), payloadVersionTag(item.version),
item.update && 'Update available', item.missingmanifest && 'Local manifest']));
});
}
function installPayload(item, button) {
setPayloadBusy(button, true, 'Updating...');
PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
App.toast(item.title + ' updated'); load();
}).catch(apiError('Unable to update payload')).finally(() => setPayloadBusy(button, false));
}
function load() {
refresh.disabled = true;
PagerAPI.get('/api/payloads/installed').then((r) => {
if (!alive) return;
rows = (r.data && r.data.payloads) || [];
filters.category.innerHTML = '<option value="">All categories</option>';
fillPayloadCategories(filters.category, rows, 'category');
render();
}).catch(apiError('Unable to load installed payloads')).finally(() => { refresh.disabled = false; });
}
load();
return { destroy: () => { alive = false; } };
};
views.modules_online = (root) => {
const box = payloadShell(root, '#/modules/online');
let alive = true;
let rows = [], installed = new Set();
const list = h('div', { class: 'payload-list' });
const status = h('p', { class: 'payload-result-count muted' });
const filters = payloadFilters(render);
const refresh = btn('Get Available Payloads', () => {
refresh.disabled = true;
PagerAPI.post('/api/payloads/refresh').then(() => {
App.toast('Pager Portal listings refreshed'); return load();
}).catch(apiError('Unable to refresh Pager Portal')).finally(() => { refresh.disabled = false; });
});
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Available Payloads', 'Browse community payloads from the Pager Portal and install them directly on this Pager.', refresh),
filters.node, status, list));
function render() {
if (!alive) return;
const q = filters.search.value.trim().toLowerCase();
const category = filters.category.value;
const matches = rows.filter((item) => (!category || item.parent === category) &&
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
const visible = matches.slice(0, 120);
status.textContent = matches.length + ' result' + (matches.length === 1 ? '' : 's') +
(matches.length > visible.length ? ' - showing first ' + visible.length : '');
list.innerHTML = '';
if (!visible.length) {
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No Portal listing is cached. Select Get Available Payloads.' }));
return;
}
visible.forEach((item) => {
const isInstalled = installed.has(item.key);
const install = btn(isInstalled ? 'Installed' : 'Install', () => {
if (installed.has(item.key)) return;
install.dataset.label = 'Install';
setPayloadBusy(install, true, 'Installing...');
PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
installed.add(item.key); App.toast(item.title + ' installed'); render();
}).catch(apiError('Unable to install payload')).finally(() => setPayloadBusy(install, false));
}, isInstalled ? 'ghost' : '');
install.dataset.label = isInstalled ? 'Installed' : 'Install';
install.disabled = isInstalled;
list.appendChild(payloadCard(item, [install],
[item.parent && item.parent.replace('/', ' / ').replace(/_/g, ' '), payloadVersionTag(item.version)]));
});
}
function load() {
return Promise.all([PagerAPI.get('/api/payloads/index'), PagerAPI.get('/api/payloads/installed')]).then((results) => {
if (!alive) return;
rows = (results[0].data && results[0].data.payloads) || [];
installed = new Set(((results[1].data && results[1].data.payloads) || []).map((item) => item.key));
filters.category.innerHTML = '<option value="">All categories</option>';
fillPayloadCategories(filters.category, rows, 'parent');
render();
}).catch(apiError('Unable to load Pager Portal payloads'));
}
load();
return { destroy: () => { alive = false; } };
};
views.modules_running = (root) => {
const box = payloadShell(root, '#/modules/running');
let alive = true, pending = false;
const list = h('div', { class: 'payload-runs' });
const refresh = btn('Refresh', load, 'ghost');
box.appendChild(h('div', { class: 'section' },
payloadToolbar('Running Payloads', 'Tracks payloads launched from this WebUI session. Output is read from each payload process.', refresh), list));
function render(rows) {
if (!alive) return;
list.innerHTML = '';
if (!rows.length) {
list.appendChild(h('div', { class: 'payload-empty', text: 'No payloads have been launched from this WebUI session.' }));
return;
}
rows.forEach((run) => {
const state = h('span', { class: 'payload-run-state ' + (run.running ? 'running' : 'finished'),
text: run.running ? 'Running' : (run.returncode === 0 ? 'Completed' : 'Exited ' + run.returncode) });
const actions = h('div', { class: 'payload-actions' });
if (run.running) {
const stop = btn('Stop', () => {
stop.disabled = true;
PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => {
App.toast(run.title + ' stop requested'); setTimeout(load, 500);
}).catch(apiError('Unable to stop payload')).finally(() => { stop.disabled = false; });
}, 'danger');
actions.appendChild(stop);
}
const output = h('pre', { class: 'payload-output', text: run.output || 'No output yet.' });
list.appendChild(h('article', { class: 'payload-run' },
h('div', { class: 'payload-run-head' },
h('div', {}, h('h3', { text: run.title }),
h('p', { class: 'muted', text: 'PID ' + run.pid + ' - started ' + fmtTime(run.started) })),
state, actions), output));
});
}
function load() {
if (pending) return;
pending = true; refresh.disabled = true;
PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || []))
.catch(apiError('Unable to load payload runs')).finally(() => { pending = false; refresh.disabled = false; });
}
load();
const iv = setInterval(load, 3000);
return { destroy: () => { alive = false; clearInterval(iv); } };
};
views.modules_develop = (root) => {
const box = payloadShell(root, '#/modules/develop');
box.appendChild(h('div', { class: 'section payload-develop' },
h('h2', { text: 'Developing Pager Payloads' }),
h('p', { text: 'Pager payloads are native shell or script-based workflows, not Pineapple modules. A payload lives in a context and category folder and declares its launchpoint and metadata in _hak5_manifest.json.' }),
h('div', { class: 'payload-dev-grid' },
h('div', { class: 'payload-dev-block' },
h('h3', { text: 'Payload structure' }),
h('pre', { class: 'payload-code', text: '/root/payloads/user/general/my_payload/\n payload.sh\n _hak5_manifest.json\n assets/' })),
h('div', { class: 'payload-dev-block' },
h('h3', { text: 'Native capabilities' }),
h('p', { text: 'Scripts launched here keep access to Pager commands such as ALERT, LOG, CONFIRMATION_DIALOG, TEXT_PICKER and other hak5cmd-backed helpers.' }))),
h('p', { class: 'muted', text: 'Use the terminal to create or inspect payload files. Refresh Installed when the manifest changes so the firmware inventory is re-read.' }),
btn('Open Terminal', () => {
const terminal = document.getElementById('terminal-btn');
if (terminal) terminal.click();
})));
return { destroy: () => {} };
};
const SETTINGS_TABS = [
{ label: 'General', hash: '#/settings' },
{ label: 'Networking', hash: '#/settings/networking' },
{ label: 'WiFi', hash: '#/settings/wifi' },
{ label: 'LED', hash: '#/settings/led' },
{ label: 'Advanced', hash: '#/settings/advanced' },
{ label: 'Help', hash: '#/settings/help' }
];
function settingsShell(root, activeHash) {
root.appendChild(h('h1', { class: 'page-title', text: 'Settings' }));
tabBar(root, SETTINGS_TABS, activeHash);
const box = h('div', { class: 'settings-content' });
root.appendChild(box);
return box;
}
function settingsCard(box, title, subtitle) {
const card = h('div', { class: 'section settings-card' }, h('h2', {}, title));
if (subtitle) card.appendChild(h('p', { class: 'muted settings-subtitle', text: subtitle }));
box.appendChild(card);
return card;
}
function apiError(message) {
return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error');
}
const WIFI_CLIENT_ENC_VALUES = {
'Open': 'open', 'WPA2': 'wpa2', 'WPA3': 'wpa3', 'WPA2/WPA3': 'wpa2wpa3'
};
function clientModeEncValue(label) {
return WIFI_CLIENT_ENC_VALUES[label] || (label && label !== 'Open' ? 'wpa2' : 'open');
}
function clientModePanel(box, options) {
const state = { status: null, networks: [], connecting: false, generation: 0 };
const statusEl = h('div', { class: 'client-status' });
const routingSwitch = h('input', { type: 'checkbox' });
const routingRow = h('label', { class: 'switch client-routing' },
routingSwitch, h('span', { class: 'track' }), 'Route LAN clients through this connection');
const actionsEl = h('div', { class: 'client-actions' });
const netsEl = h('div', { class: 'client-networks' });
routingSwitch.addEventListener('change', () => {
if (!(state.status || {}).enabled) {
routingSwitch.checked = false;
App.toast('Client mode must be enabled before routing can be changed', 'error');
return;
}
PagerAPI.post('/api/settings/wifi/client/route', { routed: routingSwitch.checked })
.then(() => {
if (state.status) state.status.routed = routingSwitch.checked;
App.toast('Client routing ' + (routingSwitch.checked ? 'enabled' : 'disabled'));
App.checkInternet(false);
})
.catch((e) => {
routingSwitch.checked = !routingSwitch.checked;
App.toast(e.message || 'Failed to update routing', 'error');
});
});
function renderStatus() {
const s = state.status || {};
statusEl.innerHTML = '';
const lines = [];
if (s.connected) {
lines.push(['Status', 'Connected'], ['Network', s.connected_ssid || s.ssid || '\u2014'],
['IP Address', s.ip || '\u2014'],
['Signal', s.signal != null ? s.signal + ' dBm' : '\u2014']);
} else if (s.enabled) {
lines.push(['Status', 'Enabled \u2014 not associated'], ['Network', s.ssid || '\u2014']);
} else {
lines.push(['Status', 'Disabled']);
}
lines.forEach(([k, v]) => statusEl.appendChild(h('div', { class: 'client-kv' },
h('span', { text: k }), h('code', { text: v }))));
routingSwitch.checked = !!s.routed;
routingRow.classList.toggle('hidden', !s.enabled);
}
function refresh() {
PagerAPI.get('/api/settings/wifi/client').then((r) => {
state.status = r.data || {};
renderStatus();
}).catch(apiError('Failed to load client mode status'));
}
function renderNetworks() {
netsEl.innerHTML = '';
if (!state.networks.length) {
netsEl.appendChild(h('div', { class: 'empty', text: 'No networks shown. Scan to discover nearby WiFi.' }));
return;
}
state.networks.forEach((net) => {
const needsPw = net.encryption !== 'Open';
const row = h('div', { class: 'client-net-row' },
h('div', { class: 'client-net-main' },
h('span', { class: 'client-net-ssid', text: net.ssid || '(hidden)' }),
h('span', { class: 'client-net-enc', text: net.encryption })),
h('span', { class: 'client-net-signal', text: net.signal != null ? net.signal + ' dBm' : '\u2014' }),
btn(needsPw ? 'Connect' : 'Join', () => connectRow(row, net), needsPw ? '' : 'primary'));
netsEl.appendChild(row);
});
}
function connectRow(row, net) {
row.innerHTML = '';
const encSel = h('select', {},
h('option', { value: 'wpa2', text: 'WPA2' }),
h('option', { value: 'wpa3', text: 'WPA3' }),
h('option', { value: 'wpa2wpa3', text: 'WPA2/WPA3' }));
const value = clientModeEncValue(net.encryption);
encSel.value = value === 'open' ? 'wpa2' : value;
const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'new-password' });
const routedCb = h('input', { type: 'checkbox' });
const routedLabel = h('label', { class: 'switch client-routing' }, routedCb,
h('span', { class: 'track' }), 'Route LAN clients');
const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel,
btn('Connect', () => doConnect({ ssid: net.ssid, encryption: encSel.value,
password: pw.value, routed: routedCb.checked }), 'primary'),
btn('Cancel', () => renderNetworks(), 'ghost'));
row.appendChild(form);
pw.focus();
}
function doConnect(opts) {
if (state.connecting) return;
state.connecting = true;
setBusy(true);
PagerAPI.post('/api/settings/wifi/client/connect', opts)
.then(() => {
App.toast('Connecting to ' + opts.ssid + '\u2026');
state.status = null;
pollConnected(opts.ssid);
})
.catch((e) => {
state.connecting = false;
setBusy(false);
App.toast(e.message || 'Failed to connect', 'error');
refresh();
});
}
function pollConnected(targetSsid) {
const gen = ++state.generation;
let attempts = 0;
let ipWait = 0;
const tick = () => {
if (gen !== state.generation) return;
PagerAPI.get('/api/settings/wifi/client').then((r) => {
const s = r.data || {};
state.status = s;
renderStatus();
if (s.connected && s.ip) {
finish('Connected to ' + (s.connected_ssid || targetSsid));
} else if (s.connected && ++ipWait < 6) {
setTimeout(tick, 2000);
} else if (!s.connected && ++attempts >= 22) {
finish('Timed out waiting for ' + targetSsid + ' to connect', 'error');
} else if (!s.connected) {
setTimeout(tick, 2000);
} else {
finish('Connected to ' + (s.connected_ssid || targetSsid));
}
}).catch(() => {
state.connecting = false;
setBusy(false);
App.toast('Failed to check connection status', 'error');
});
};
function finish(message, kind) {
state.connecting = false;
setBusy(false);
App.toast(message, kind);
App.checkInternet(false);
}
setTimeout(tick, 2000);
}
function doDisconnect() {
if (state.connecting) return;
PagerAPI.post('/api/settings/wifi/client/disconnect')
.then(() => {
App.toast('WiFi client disabled');
state.networks = [];
refresh();
App.checkInternet(false);
})
.catch((e) => App.toast(e.message || 'Failed to disconnect', 'error'));
}
function scan() {
if (state.connecting) return;
netsEl.textContent = 'Scanning\u2026';
PagerAPI.post('/api/settings/wifi/client/scan').then((r) => {
state.networks = (r.data && r.data.networks) || [];
renderNetworks();
}).catch((e) => {
netsEl.innerHTML = '';
netsEl.appendChild(h('div', { class: 'empty', text: 'Scan failed: ' + (e.message || 'unknown error') }));
});
}
function setBusy(busy) {
const buttons = actionsEl.querySelectorAll('button');
buttons.forEach((b) => { b.disabled = busy; });
}
actionsEl.appendChild(btn('Scan for Networks', scan));
actionsEl.appendChild(btn('Disconnect', doDisconnect));
actionsEl.appendChild(btn('Refresh', refresh, 'ghost'));
if (options && options.close) actionsEl.appendChild(btn('Close', options.close, 'ghost'));
box.appendChild(statusEl);
box.appendChild(routingRow);
box.appendChild(actionsEl);
box.appendChild(netsEl);
refresh();
return { refresh };
}
views.openClientModeModal = () => {
const overlay = h('div', { class: 'modal-overlay' });
function close() { overlay.remove(); }
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
const modal = h('div', { class: 'modal client-modal' },
h('div', { class: 'modal-title', text: 'Internet Connection' }),
h('div', { class: 'modal-body' }));
overlay.appendChild(modal);
document.body.appendChild(overlay);
clientModePanel(modal.querySelector('.modal-body'), { close });
};
views.settings = (root) => {
const box = settingsShell(root, '#/settings');
const user = settingsCard(box, 'User Management & Timezone');
const currentPw = h('input', { type: 'password', autocomplete: 'current-password' });
const newPw = h('input', { type: 'password', autocomplete: 'new-password' });
const repeatPw = h('input', { type: 'password', autocomplete: 'new-password' });
user.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'Current Password', currentPw),
h('label', {}, 'New Password', newPw),
h('label', {}, 'Repeat New Password', repeatPw)));
user.appendChild(btn('Update Password', () => {
if (!currentPw.value) { App.toast('Current password is required', 'error'); return; }
if (newPw.value.length < 4) { App.toast('New password is too short', 'error'); return; }
if (newPw.value !== repeatPw.value) { App.toast('New passwords do not match', 'error'); return; }
PagerAPI.post('/api/settings/password', {
current_password: currentPw.value, new_password: newPw.value,
repeat_password: repeatPw.value
}).then(() => {
currentPw.value = ''; newPw.value = ''; repeatPw.value = '';
App.toast('Root password updated');
}).catch(apiError('Failed to update password'));
}));
const timezones = [
['UTC', '(GMT+0) Coordinated Universal Time', 'Etc/UTC'],
['EST5EDT,M3.2.0,M11.1.0', '(GMT-5) Eastern Time (US & Canada)', 'America/New_York'],
['CST6CDT,M3.2.0,M11.1.0', '(GMT-6) Central Time (US & Canada)', 'America/Chicago'],
['MST7MDT,M3.2.0,M11.1.0', '(GMT-7) Mountain Time (US & Canada)', 'America/Denver'],
['MST7', '(GMT-7) Arizona', 'America/Phoenix'],
['PST8PDT,M3.2.0,M11.1.0', '(GMT-8) Pacific Time (US & Canada)', 'America/Los_Angeles'],
['AKST9AKDT,M3.2.0,M11.1.0', '(GMT-9) Alaska', 'America/Anchorage'],
['HST10', '(GMT-10) Hawaii', 'Pacific/Honolulu'],
['GMT0BST,M3.5.0/1,M10.5.0', '(GMT+0) London', 'Europe/London'],
['CET-1CEST,M3.5.0,M10.5.0/3', '(GMT+1) Central Europe', 'Europe/Berlin'],
['JST-9', '(GMT+9) Japan', 'Asia/Tokyo'],
['AEST-10AEDT,M10.1.0,M4.1.0/3', '(GMT+10) Sydney', 'Australia/Sydney']
];
const tzSel = h('select', { 'aria-label': 'Timezone' });
timezones.forEach(([value, label, zone]) => tzSel.appendChild(h('option', { value, text: label, 'data-zone': zone })));
const tzActions = h('div', { class: 'settings-actions' },
btn('Update Timezone', () => {
const opt = tzSel.options[tzSel.selectedIndex];
PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' })
.then(() => App.toast('Timezone updated')).catch(apiError('Failed to update timezone'));
}),
btn('Sync Browser Time', () => {
const d = new Date();
const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' +
String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' +
String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0');
PagerAPI.post('/api/settings/synctime', { timestamp: stamp })
.then(() => App.toast('Pager time synchronized')).catch(apiError('Failed to synchronize time'));
}, 'ghost'));
user.appendChild(h('label', {}, 'Timezone', tzSel));
user.appendChild(tzActions);
PagerAPI.get('/api/settings/timezone').then((r) => {
const value = (r.data || {}).timezone || 'UTC';
if (!Array.from(tzSel.options).some((o) => o.value === value)) {
tzSel.insertBefore(h('option', { value, text: value + ' (current)' }), tzSel.firstChild);
}
tzSel.value = value;
}).catch(apiError('Unable to load timezone'));
const ntp = settingsCard(box, 'Network Time', 'Configure the Pager\'s NTP client.');
const ntpEnabled = h('input', { type: 'checkbox' });
const ntpServers = h('input', {});
ntp.appendChild(h('label', { class: 'switch' }, ntpEnabled, h('span', { class: 'track' }), 'Enabled'));
ntp.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers));
ntp.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', {
enabled: ntpEnabled.checked,
servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean)
}).then(() => App.toast('Network time settings saved')).catch(apiError('Failed to save network time'))));
PagerAPI.get('/api/settings/ntp').then((r) => {
ntpEnabled.checked = !!r.data.enabled;
ntpServers.value = (r.data.servers || []).join(', ');
}).catch(apiError('Unable to load network time settings'));
const details = settingsCard(box, 'Device & Web Interface');
const detailBody = h('div', { class: 'settings-kv-grid' });
details.appendChild(detailBody);
Promise.all([PagerAPI.get('/api/status'), PagerAPI.get('/api/settings/service')]).then(([status, service]) => {
const s = status.data || {}, svc = service.data || {};
[['Model', 'WiFi Pineapple Pager'], ['Firmware', s.firmware || 'Unavailable'],
['Uptime', fmtDur(s.uptime)], ['Disk', s.disk && s.disk.size != null ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'],
['WebUI Service', (svc.running ? 'Running' : 'Stopped') + (svc.background ? ' (background)' : '')]]
.forEach(([k, v]) => detailBody.appendChild(h('div', { class: 'settings-kv' }, h('span', { text: k }), h('code', { text: v }))));
}).catch(apiError('Unable to load device details'));
settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.');
const resources = settingsCard(box, 'Resources');
const resourceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
resources.appendChild(resourceBody);
PagerAPI.get('/api/settings/resources').then((r) => {
resourceBody.innerHTML = '';
resourceBody.appendChild(table([
{ label: 'Filesystem', key: 'filesystem' }, { label: 'Format', key: 'format' },
{ label: 'Size', render: (x) => fmtBytes(x.size) }, { label: 'Used', render: (x) => fmtBytes(x.used) },
{ label: 'Available', render: (x) => fmtBytes(x.available) }, { label: 'Used %', key: 'used_percent' },
{ label: 'Mount Point', key: 'mount' }
], (r.data || {}).filesystems || []));
}).catch(() => { resourceBody.textContent = 'Unable to load filesystems.'; });
const usb = settingsCard(box, 'USB Devices');
const usbBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
usb.appendChild(usbBody);
PagerAPI.get('/api/settings/usb').then((r) => {
usbBody.innerHTML = '';
const rows = (r.data || {}).devices || [];
usbBody.appendChild(table([{ label: 'Bus', key: 'bus' }, { label: 'Device', key: 'device' },
{ label: 'VID:PID', key: 'id' }, { label: 'Name', key: 'name' }], rows));
if (!rows.length) usbBody.appendChild(h('div', { class: 'empty', text: 'No USB devices reported.' }));
}).catch(() => { usbBody.textContent = 'Unable to load USB devices.'; });
return { destroy: () => {} };
};
views.settings_networking = (root) => {
const box = settingsShell(root, '#/settings/networking');
const client = settingsCard(box, 'Wireless Client Mode', 'Connect the Pager to an in-range WiFi network for internet access. The management AP keeps running; the radio channel follows the selected network.');
clientModePanel(client, {});
const recon = settingsCard(box, 'Recon Wireless Interfaces');
const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' });
recon.appendChild(reconBody);
const interfaces = settingsCard(box, 'Interfaces');
const interfaceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
interfaces.appendChild(interfaceBody);
const routes = settingsCard(box, 'Routing Table');
const routeBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
routes.appendChild(routeBody);
PagerAPI.get('/api/settings/network').then((r) => {
const d = r.data || {};
reconBody.innerHTML = '';
(d.recon_interfaces || []).forEach((name) => reconBody.appendChild(h('code', { class: 'settings-chip', text: name })));
if (!(d.recon_interfaces || []).length) reconBody.textContent = 'No monitor interfaces detected.';
interfaceBody.innerHTML = '';
interfaceBody.appendChild(table([{ label: 'Name', key: 'name' },
{ label: 'IP Address', render: (x) => (x.addresses || []).join(', ') || '—' },
{ label: 'MAC Address', render: (x) => x.mac || '—' },
{ label: 'Flags', render: (x) => (x.flags || []).join(', ') }], d.interfaces || []));
routeBody.innerHTML = '';
routeBody.appendChild(table([{ label: 'Destination', key: 'destination' }, { label: 'Gateway', key: 'gateway' },
{ label: 'Genmask', key: 'genmask' }, { label: 'Interface', key: 'interface' },
{ label: 'Flags', key: 'flags' }, { label: 'Metric', key: 'metric' },
{ label: 'Ref', key: 'ref' }, { label: 'Use', key: 'use' }], d.routes || []));
}).catch(apiError('Unable to load networking information'));
return { destroy: () => {} };
};
views.settings_wifi = (root) => {
const box = settingsShell(root, '#/settings/wifi');
const card = settingsCard(box, 'Management Access Point', 'Configure the Pager management WiFi interface. Saving reloads wireless services; USB management access remains available.');
const ssid = h('input', {}), bssid = h('input', { placeholder: 'Optional' });
const password = h('input', { type: 'password', placeholder: 'Leave blank to keep the existing password', autocomplete: 'new-password' });
const confirm = h('input', { type: 'password', placeholder: 'Repeat only when changing it', autocomplete: 'new-password' });
const hidden = h('input', { type: 'checkbox' }), enabled = h('input', { type: 'checkbox' });
card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Management SSID', ssid), h('label', {}, 'BSSID', bssid)));
card.appendChild(h('label', { class: 'switch' }, hidden, h('span', { class: 'track' }), 'Hidden'));
card.appendChild(h('label', { class: 'switch' }, enabled, h('span', { class: 'track' }), 'Enabled'));
card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Password', password), h('label', {}, 'Confirm Password', confirm)));
const save = btn('Save', () => {
if (password.value !== confirm.value) { App.toast('Management passwords do not match', 'error'); return; }
save.disabled = true;
PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(),
password: password.value, hidden: hidden.checked, enabled: enabled.checked })
.then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); })
.catch(apiError('Failed to save management WiFi')).finally(() => { save.disabled = false; });
});
card.appendChild(save);
PagerAPI.get('/api/settings/wifi/management').then((r) => {
const d = r.data || {}; ssid.value = d.ssid || ''; bssid.value = d.bssid || '';
hidden.checked = !!d.hidden; enabled.checked = !!d.enabled;
}).catch(apiError('Unable to load management WiFi'));
return { destroy: () => {} };
};
views.settings_led = (root) => {
const box = settingsShell(root, '#/settings/led');
const card = settingsCard(box, 'LED Configuration', 'Pager-specific equivalents of the Mark VII LED controls. Settings are stored in the native Pager configuration.');
const color = h('select', {});
['red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white'].forEach((v) => color.appendChild(h('option', { value: v, text: v[0].toUpperCase() + v.slice(1) })));
const vibrate = h('input', { type: 'checkbox' }), clock24 = h('input', { type: 'checkbox' });
const lcd = h('input', { type: 'number', min: '1', max: '11' });
const dim = h('input', { type: 'number', min: '0', max: '11' });
const dimTimeout = h('input', { type: 'number', min: '0', max: '3600' });
const lcdTimeout = h('input', { type: 'number', min: '0', max: '86400' });
card.appendChild(h('label', {}, 'Status LED color', color));
card.appendChild(h('label', { class: 'switch' }, vibrate, h('span', { class: 'track' }), 'Vibrate with alerts'));
card.appendChild(h('label', { class: 'switch' }, clock24, h('span', { class: 'track' }), '24-hour clock'));
card.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'LCD brightness (111)', lcd), h('label', {}, 'Dim brightness (011)', dim),
h('label', {}, 'Dim timeout (seconds)', dimTimeout), h('label', {}, 'LCD timeout (seconds)', lcdTimeout)));
function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked,
lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value),
dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; }
card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values())
.then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences'))));
PagerAPI.get('/api/settings/hardware').then((r) => {
const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate;
clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness;
dimTimeout.value = d.dim_timeout; lcdTimeout.value = d.lcd_timeout;
}).catch(apiError('Unable to load hardware preferences'));
return { destroy: () => {} };
};
views.settings_advanced = (root) => {
const box = settingsShell(root, '#/settings/advanced');
const updates = settingsCard(box, 'Alternative Updates', 'Select the Pager software update channel. Update checking and firmware installation remain in the native Pager workflow.');
const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }),
h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' }));
updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel));
updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value })
.then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel'))));
const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.');
const hostname = h('input', {});
host.appendChild(h('label', {}, 'Hostname', hostname));
host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() })
.then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname'))));
const webui = settingsCard(box, 'Web Interface');
const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' }));
const poll = h('input', { type: 'number', min: '2', max: '120' });
const hotkeys = h('input', { type: 'checkbox' });
theme.value = Theme.current(); poll.value = localStorage.getItem('pw-poll') || '5';
hotkeys.checked = localStorage.getItem('pw_hotkeys') !== 'false';
webui.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'UI Theme', theme), h('label', {}, 'Poll interval (seconds)', poll)));
webui.appendChild(h('label', { class: 'switch' }, hotkeys, h('span', { class: 'track' }), 'Enable Hot Keys'));
webui.appendChild(btn('Save Web Interface', () => {
localStorage.setItem(Theme.KEY, theme.value); localStorage.setItem('pw-poll', poll.value);
localStorage.setItem('pw_hotkeys', hotkeys.checked ? 'true' : 'false'); Theme.apply();
App.toast('Web interface preferences saved');
}));
PagerAPI.get('/api/settings/advanced').then((r) => { channel.value = r.data.update_channel || 'stable'; hostname.value = r.data.hostname || ''; })
.catch(apiError('Unable to load advanced settings'));
return { destroy: () => {} };
};
views.settings_help = (root) => {
const box = settingsShell(root, '#/settings/help');
const help = settingsCard(box, 'Help & Information');
help.appendChild(h('p', {}, 'Documentation: ', h('a', { href: 'https://docs.hak5.org/wifi-pineapple/', target: '_blank', rel: 'noopener', text: 'docs.hak5.org' })));
help.appendChild(h('p', {}, 'Community support: ', h('a', { href: 'https://community.hak5.org/', target: '_blank', rel: 'noopener', text: 'community.hak5.org' })));
help.appendChild(h('p', {}, 'Downloads: ', h('a', { href: 'https://downloads.hak5.org/', target: '_blank', rel: 'noopener', text: 'downloads.hak5.org' })));
const diagnostics = settingsCard(box, 'Diagnostics', 'Generate a local support report containing device, network, storage, radio, USB, and recent log information.');
const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' });
let report = '';
const generate = btn('Generate Diagnostics', () => {
generate.disabled = true; output.textContent = 'Generating diagnostics…';
PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; })
.catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); })
.finally(() => { generate.disabled = false; });
});
diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate,
btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost')));
diagnostics.appendChild(output);
const license = settingsCard(box, 'Licenses');
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
return { destroy: () => {} };
};