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
2513 lines
116 KiB
JavaScript
2513 lines
116 KiB
JavaScript
'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 (1–11)', lcd), h('label', {}, 'Dim brightness (0–11)', dim),
|
||
h('label', {}, 'Dim timeout (seconds)', dimTimeout), h('label', {}, 'LCD timeout (seconds)', lcdTimeout)));
|
||
function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked,
|
||
lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value),
|
||
dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; }
|
||
card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values())
|
||
.then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences'))));
|
||
PagerAPI.get('/api/settings/hardware').then((r) => {
|
||
const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate;
|
||
clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness;
|
||
dimTimeout.value = d.dim_timeout; lcdTimeout.value = d.lcd_timeout;
|
||
}).catch(apiError('Unable to load hardware preferences'));
|
||
return { destroy: () => {} };
|
||
};
|
||
|
||
views.settings_advanced = (root) => {
|
||
const box = settingsShell(root, '#/settings/advanced');
|
||
const updates = settingsCard(box, 'Alternative Updates', 'Select the Pager software update channel. Update checking and firmware installation remain in the native Pager workflow.');
|
||
const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }),
|
||
h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' }));
|
||
updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel));
|
||
updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value })
|
||
.then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel'))));
|
||
|
||
const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.');
|
||
const hostname = h('input', {});
|
||
host.appendChild(h('label', {}, 'Hostname', hostname));
|
||
host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() })
|
||
.then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname'))));
|
||
|
||
const webui = settingsCard(box, 'Web Interface');
|
||
const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' }));
|
||
const poll = h('input', { type: 'number', min: '2', max: '120' });
|
||
const hotkeys = h('input', { type: 'checkbox' });
|
||
theme.value = Theme.current(); poll.value = localStorage.getItem('pw-poll') || '5';
|
||
hotkeys.checked = localStorage.getItem('pw_hotkeys') !== 'false';
|
||
webui.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'UI Theme', theme), h('label', {}, 'Poll interval (seconds)', poll)));
|
||
webui.appendChild(h('label', { class: 'switch' }, hotkeys, h('span', { class: 'track' }), 'Enable Hot Keys'));
|
||
webui.appendChild(btn('Save Web Interface', () => {
|
||
localStorage.setItem(Theme.KEY, theme.value); localStorage.setItem('pw-poll', poll.value);
|
||
localStorage.setItem('pw_hotkeys', hotkeys.checked ? 'true' : 'false'); Theme.apply();
|
||
App.toast('Web interface preferences saved');
|
||
}));
|
||
|
||
PagerAPI.get('/api/settings/advanced').then((r) => { channel.value = r.data.update_channel || 'stable'; hostname.value = r.data.hostname || ''; })
|
||
.catch(apiError('Unable to load advanced settings'));
|
||
return { destroy: () => {} };
|
||
};
|
||
|
||
views.settings_help = (root) => {
|
||
const box = settingsShell(root, '#/settings/help');
|
||
const help = settingsCard(box, 'Help & Information');
|
||
help.appendChild(h('p', {}, 'Documentation: ', h('a', { href: 'https://docs.hak5.org/wifi-pineapple/', target: '_blank', rel: 'noopener', text: 'docs.hak5.org' })));
|
||
help.appendChild(h('p', {}, 'Community support: ', h('a', { href: 'https://community.hak5.org/', target: '_blank', rel: 'noopener', text: 'community.hak5.org' })));
|
||
help.appendChild(h('p', {}, 'Downloads: ', h('a', { href: 'https://downloads.hak5.org/', target: '_blank', rel: 'noopener', text: 'downloads.hak5.org' })));
|
||
const diagnostics = settingsCard(box, 'Diagnostics', 'Generate a local support report containing device, network, storage, radio, USB, and recent log information.');
|
||
const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' });
|
||
let report = '';
|
||
const generate = btn('Generate Diagnostics', () => {
|
||
generate.disabled = true; output.textContent = 'Generating diagnostics…';
|
||
PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; })
|
||
.catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); })
|
||
.finally(() => { generate.disabled = false; });
|
||
});
|
||
diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate,
|
||
btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost')));
|
||
diagnostics.appendChild(output);
|
||
const license = settingsCard(box, 'Licenses');
|
||
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
|
||
return { destroy: () => {} };
|
||
};
|