4101 lines
183 KiB
JavaScript
4101 lines
183 KiB
JavaScript
'use strict';
|
||
const views = {};
|
||
|
||
// One-shot prefill passed between views (Recon target -> PineAP twin form).
|
||
// consume() clears the value so a stale prefill can never leak into a
|
||
// manually opened form.
|
||
const PineAPPrefill = {
|
||
data: null,
|
||
set(d) { PineAPPrefill.data = d || null; },
|
||
consume() {
|
||
const d = PineAPPrefill.data;
|
||
PineAPPrefill.data = null;
|
||
return d;
|
||
}
|
||
};
|
||
window.PineAPPrefill = PineAPPrefill;
|
||
|
||
const h = (tag, attrs, ...children) => {
|
||
const n = document.createElement(tag);
|
||
if (attrs) {
|
||
for (const k in attrs) {
|
||
if (k === 'class') n.className = attrs[k];
|
||
else if (k === 'text') n.textContent = attrs[k];
|
||
else if (k === 'html') n.innerHTML = attrs[k];
|
||
else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]);
|
||
else n.setAttribute(k, attrs[k]);
|
||
}
|
||
}
|
||
for (const c of children) {
|
||
if (c == null) continue;
|
||
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||
}
|
||
return n;
|
||
};
|
||
|
||
const table = (columns, rows, rowAttrs) => {
|
||
const t = h('table', { class: 'tbl' });
|
||
const thead = h('thead'), tr = h('tr');
|
||
columns.forEach((c) => tr.appendChild(h('th', { text: c.label })));
|
||
thead.appendChild(tr); t.appendChild(thead);
|
||
const tb = h('tbody');
|
||
(rows || []).forEach((r) => {
|
||
const trr = h('tr', rowAttrs ? rowAttrs(r) : {});
|
||
columns.forEach((c) => {
|
||
const v = c.render ? c.render(r) : r[c.key];
|
||
const td = h('td');
|
||
if (v == null) td.textContent = '';
|
||
else if (typeof v === 'string' || typeof v === 'number') td.textContent = String(v);
|
||
else td.appendChild(v);
|
||
trr.appendChild(td);
|
||
});
|
||
tb.appendChild(trr);
|
||
});
|
||
t.appendChild(tb);
|
||
return t;
|
||
};
|
||
|
||
const fmtTime = (ts) => {
|
||
if (!ts) return '--';
|
||
const d = new Date(ts * 1000);
|
||
return d.toLocaleString();
|
||
};
|
||
|
||
const fmtShortTime = (ts) => {
|
||
if (!ts) return '--';
|
||
const d = new Date(ts * 1000);
|
||
const now = new Date();
|
||
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||
const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
if (sameDay) return hm;
|
||
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
|
||
};
|
||
|
||
const fmtDur = (secs) => {
|
||
if (secs == null) return '--';
|
||
const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600),
|
||
mm = Math.floor((secs % 3600) / 60);
|
||
return (d ? d + 'd ' : '') + hh + 'h ' + mm + 'm';
|
||
};
|
||
|
||
const fmtBytes = (bytes) => {
|
||
if (bytes == null || !isFinite(Number(bytes))) return '--';
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
let value = Number(bytes), unit = 0;
|
||
while (Math.abs(value) >= 1024 && unit < units.length - 1) {
|
||
value /= 1024;
|
||
unit += 1;
|
||
}
|
||
return value.toFixed(unit < 2 ? 0 : 1) + ' ' + units[unit];
|
||
};
|
||
|
||
const downloadText = (filename, text) => {
|
||
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||
const a = h('a', { href: url, download: filename });
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
};
|
||
|
||
const copyText = (text, okMsg) => {
|
||
const value = String(text == null ? '' : text);
|
||
if (!value) return Promise.resolve();
|
||
const done = () => App.toast(okMsg || 'Copied');
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
return navigator.clipboard.writeText(value).then(done).catch(() => downloadText('copy.txt', value));
|
||
}
|
||
downloadText('copy.txt', value);
|
||
return Promise.resolve();
|
||
};
|
||
|
||
const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' });
|
||
|
||
function markBusy(button, busy, busyText) {
|
||
if (!button) return;
|
||
const labeled = button.classList.contains('btn');
|
||
if (busy) {
|
||
if (labeled && !button.dataset.label) button.dataset.label = button.textContent;
|
||
button.disabled = true;
|
||
button.classList.add('busy');
|
||
button.setAttribute('aria-busy', 'true');
|
||
if (busyText && labeled) button.textContent = busyText;
|
||
} else {
|
||
button.disabled = false;
|
||
button.classList.remove('busy');
|
||
button.removeAttribute('aria-busy');
|
||
if (labeled && button.dataset.label) button.textContent = button.dataset.label;
|
||
}
|
||
}
|
||
|
||
function runAction(button, work, busyText) {
|
||
if (button && (button.disabled || button.classList.contains('busy'))) {
|
||
return Promise.resolve();
|
||
}
|
||
markBusy(button, true, busyText);
|
||
return Promise.resolve()
|
||
.then(work)
|
||
.catch((e) => {
|
||
App.toast((e && e.message) || 'Action failed', 'error');
|
||
})
|
||
.finally(() => markBusy(button, false));
|
||
}
|
||
|
||
function armBusy(button, onclk) {
|
||
if (!onclk) return;
|
||
button.addEventListener('click', (event) => {
|
||
if (button.disabled || button.classList.contains('busy')) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
const result = onclk(event);
|
||
if (!result || typeof result.then !== 'function') return;
|
||
if (!button.classList.contains('busy')) markBusy(button, true);
|
||
Promise.resolve(result).catch(() => {}).finally(() => {
|
||
if (button.classList.contains('busy')) markBusy(button, false);
|
||
});
|
||
});
|
||
}
|
||
|
||
const btn = (label, onclk, cls) => {
|
||
const b = h('button', { class: 'btn ' + (cls || ''), text: label });
|
||
armBusy(b, onclk);
|
||
return b;
|
||
};
|
||
|
||
const iconBtn = (name, title, onclk) => {
|
||
const b = h('button', { class: 'icon-btn', title: title || '' });
|
||
b.innerHTML = PineappleIcons[name] || '';
|
||
armBusy(b, onclk);
|
||
return b;
|
||
};
|
||
|
||
const tabBar = (box, items, activeHash) => {
|
||
const bar = h('div', { class: 'tabbar' });
|
||
items.forEach((it) => {
|
||
const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label });
|
||
t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
|
||
bar.appendChild(t);
|
||
});
|
||
box.appendChild(bar);
|
||
return bar;
|
||
};
|
||
|
||
views.dashboard = (root) => {
|
||
const history = { clients: [] };
|
||
const max = 60;
|
||
|
||
const title = h('h1', { class: 'page-title', text: 'Dashboard' });
|
||
root.appendChild(title);
|
||
|
||
const grid = h('div', { class: 'cards' });
|
||
root.appendChild(grid);
|
||
const defs = [
|
||
['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'],
|
||
['disk', 'Disk Usage'], ['uptime', 'Uptime']
|
||
];
|
||
const cards = {};
|
||
defs.forEach(([k, label]) => {
|
||
const card = h('div', { class: 'card' },
|
||
h('div', { class: 'card-label', text: label }),
|
||
h('div', { class: 'card-value', text: 'Loading…' }));
|
||
grid.appendChild(card);
|
||
cards[k] = card.querySelector('.card-value');
|
||
});
|
||
|
||
const live = h('div', { class: 'cards', style: 'margin-top:8px' });
|
||
root.appendChild(live);
|
||
const liveCards = {};
|
||
[['attacks', 'Attacks'], ['health', 'PineAPd / Monitors'], ['recon', 'Recon']]
|
||
.forEach(([k, label]) => {
|
||
const card = h('div', { class: 'card' },
|
||
h('div', { class: 'card-label', text: label }),
|
||
h('div', { class: 'card-value', style: 'font-size:12px;line-height:1.8', text: 'Loading…' }));
|
||
live.appendChild(card);
|
||
liveCards[k] = card.querySelector('.card-value');
|
||
});
|
||
|
||
const counterDefs = [
|
||
['boots', 'Boots'], ['unexpected_boots', 'Unexpected Boots'],
|
||
['rollbacks', 'Rollbacks'], ['restarts', 'Restarts'], ['guard_fixes', 'Guard Fixes']
|
||
];
|
||
const counterVals = {};
|
||
const counterRow = h('div', { class: 'mk8-counter-row' });
|
||
counterDefs.forEach(([k, label]) => {
|
||
const val = h('div', { class: 'mk8-counter-value', text: '—' });
|
||
counterRow.appendChild(h('div', { class: 'mk8-counter' },
|
||
val, h('div', { class: 'mk8-counter-label', text: label })));
|
||
counterVals[k] = val;
|
||
});
|
||
const guardChip = h('span', { class: 'health-chip', text: 'GUARD —' });
|
||
const feedBody = h('div', { class: 'mk8-events-feed' },
|
||
h('div', { class: 'empty', text: 'No events recorded.' }));
|
||
root.appendChild(h('div', { class: 'section' },
|
||
h('div', { class: 'mk8-rel-head' }, h('h2', {}, 'Reliability'), guardChip),
|
||
counterRow,
|
||
feedBody));
|
||
function renderEvents(events) {
|
||
feedBody.innerHTML = '';
|
||
if (!Array.isArray(events) || !events.length) {
|
||
feedBody.appendChild(h('div', { class: 'empty', text: 'No events recorded.' }));
|
||
return;
|
||
}
|
||
events.forEach((ev) => {
|
||
const sev = ev.sev === 'error' ? 'error' : ev.sev === 'warn' ? 'warn' : 'info';
|
||
feedBody.appendChild(h('div', { class: 'mk8-event-row sev-' + sev },
|
||
h('span', { class: 'mk8-event-time', text: fmtShortTime(ev.ts) }),
|
||
h('span', { class: 'mk8-event-kind', text: String(ev.kind || '?') }),
|
||
h('span', { class: 'mk8-event-msg', text: String(ev.msg || '') })));
|
||
});
|
||
}
|
||
function loadLive() {
|
||
PagerAPI.get('/api/attacks/status').then((r) => {
|
||
const s = r.data || {};
|
||
const st = (x) => (x && x.enabled ? (x.live ? 'LIVE' : 'ON') : 'off');
|
||
const wpa = st(s.wpa && s.wpa.radio0) === 'LIVE' || st(s.wpa && s.wpa.radio1) === 'LIVE'
|
||
? 'LIVE' : (st(s.wpa && s.wpa.radio0) !== 'off' || st(s.wpa && s.wpa.radio1) !== 'off' ? 'ON' : 'OFF');
|
||
const open = st(s.open && s.open.radio0) === 'LIVE' || st(s.open && s.open.radio1) === 'LIVE'
|
||
? 'LIVE' : (st(s.open && s.open.radio0) !== 'off' || st(s.open && s.open.radio1) !== 'off' ? 'ON' : 'OFF');
|
||
const ent = s.enterprise && s.enterprise.ap
|
||
? (s.enterprise.ap.live ? 'LIVE' : s.enterprise.ap.enabled ? 'ON' : 'OFF') : 'OFF';
|
||
liveCards.attacks.textContent = 'WPA ' + wpa + ' · Open ' + open + ' · Ent ' + ent +
|
||
' · HS ' + (s.handshakes || 0) + ' · creds ' + ((s.enterprise || {}).creds || 0);
|
||
liveCards.attacks.style.color = wpa === 'LIVE' || open === 'LIVE' || ent === 'LIVE'
|
||
? 'var(--primary)' : '';
|
||
}).catch(() => {});
|
||
PagerAPI.get('/api/pineap/handshakes').then((r) => {
|
||
const hs = (r.data.files || []).length;
|
||
const cur = liveCards.attacks.textContent.replace(/HS \d+/, 'HS ' + hs);
|
||
liveCards.attacks.textContent = cur;
|
||
}).catch(() => {});
|
||
PagerAPI.get('/api/health').then((r) => {
|
||
const h2 = r.data || {};
|
||
liveCards.health.textContent = (h2.pineap_up ? 'pineapd up' : 'pineapd DOWN') +
|
||
' · wlan0mon ' + (h2.wlan0mon_up ? 'up' : 'down') +
|
||
' · wlan1mon ' + (h2.wlan1mon_up ? 'up' : 'down') +
|
||
(h2.pool_disabled ? ' · pool off' : '') +
|
||
((h2.env || {}).overall ? ' · env ' + h2.env.overall : '');
|
||
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
|
||
const rel = h2.reliability || {};
|
||
Object.keys(counterVals).forEach((k) => {
|
||
counterVals[k].textContent = rel[k] == null ? '—' : String(rel[k]);
|
||
});
|
||
const g = h2.guard || {};
|
||
if (typeof g.in_sync !== 'boolean') {
|
||
guardChip.className = 'health-chip';
|
||
guardChip.textContent = 'GUARD —';
|
||
} else if (g.in_sync) {
|
||
guardChip.className = 'health-chip good';
|
||
guardChip.textContent = 'GUARD IN SYNC';
|
||
} else {
|
||
guardChip.className = 'health-chip warn';
|
||
guardChip.textContent = 'GUARD PENDING' + (g.pending != null ? ' · ' + g.pending : '');
|
||
}
|
||
renderEvents(h2.events);
|
||
}).catch(() => {});
|
||
PagerAPI.get('/api/recon/status').then((r) => {
|
||
const s = r.data || {};
|
||
const last = s.last_activity || s.last_scan;
|
||
liveCards.recon.textContent = (s.scanning ? 'scanning' : 'idle') +
|
||
(last ? ' · last activity ' + fmtTime(last) : '');
|
||
}).catch(() => {});
|
||
}
|
||
loadLive();
|
||
const liveIv = setInterval(loadLive, 10000);
|
||
|
||
const chartBox = h('div', { class: 'section' },
|
||
h('h2', {}, 'Clients'),
|
||
h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' }));
|
||
root.appendChild(chartBox);
|
||
const canvas = chartBox.querySelector('#dash-chart');
|
||
|
||
const update = (msg) => {
|
||
const s = msg.status || {};
|
||
const n = (msg.clients || []).length;
|
||
history.clients.push(n);
|
||
if (history.clients.length > max) { history.clients.shift(); }
|
||
cards.clients.textContent = n;
|
||
cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime);
|
||
cards.disk.textContent = s.disk && s.disk.size != null
|
||
? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable';
|
||
if (typeof MiniChart !== 'undefined') {
|
||
MiniChart.draw(canvas, [
|
||
{ label: 'Clients', color: '#1976d2', points: history.clients }
|
||
]);
|
||
}
|
||
};
|
||
const unsubscribe = Live.onTick(update);
|
||
PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients }))
|
||
.catch(() => {
|
||
cards.clients.textContent = '0';
|
||
cards.disk.textContent = '—';
|
||
cards.uptime.textContent = '—';
|
||
});
|
||
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: (c) => {
|
||
const deauthBtn = btn('Deauth', () => {
|
||
if (!confirm('Deauthenticate ' + c.mac + '?')) return;
|
||
return PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac })
|
||
.then(() => App.toast('Deauthenticated'))
|
||
.then(loadClients);
|
||
}, 'danger');
|
||
return deauthBtn;
|
||
} }],
|
||
r.data.clients));
|
||
const cols = ['MAC', 'Interface', 'RSSI'];
|
||
clBody.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; });
|
||
}).catch(() => { clBody.textContent = 'Unable to load connected clients.'; });
|
||
}
|
||
function loadHandshakes() {
|
||
PagerAPI.get('/api/pineap/handshakes').then((r) => {
|
||
hsBody.innerHTML = '';
|
||
hsBody.appendChild(table(
|
||
[{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }],
|
||
(r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) }))));
|
||
}).catch(() => { hsBody.textContent = 'Unable to load captured handshakes.'; });
|
||
}
|
||
loadClients();
|
||
loadHandshakes();
|
||
const iv = setInterval(loadClients, 10000);
|
||
return { destroy: () => { clearInterval(iv); clearInterval(liveIv); unsubscribe(); } };
|
||
};
|
||
|
||
const PINEAP_TABS = [
|
||
{ label: 'PineAP', hash: '#/pineap' },
|
||
{ label: 'OpenAP', hash: '#/pineap/open' },
|
||
{ label: 'Evil WPA', hash: '#/pineap/evilwpa' },
|
||
{ label: 'Evil Enterprise', hash: '#/pineap/enterprise' },
|
||
{ label: 'Impersonation', hash: '#/pineap/impersonation' },
|
||
{ label: 'Clients', hash: '#/pineap/clients' },
|
||
{ label: 'Filtering', hash: '#/pineap/filtering' }
|
||
];
|
||
|
||
// The Pager daemon can change these states but cannot read them back. Keep
|
||
// them explicitly unknown until this WebUI successfully changes them.
|
||
const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null };
|
||
|
||
function setKnownCheckbox(cb, value) {
|
||
cb.indeterminate = value == null;
|
||
if (value != null) cb.checked = !!value;
|
||
}
|
||
|
||
function pineapShell(root, activeHash) {
|
||
root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' }));
|
||
tabBar(root, PINEAP_TABS, activeHash);
|
||
const box = h('div', {});
|
||
root.appendChild(box);
|
||
return box;
|
||
}
|
||
|
||
function pineapTitleCard(titleText, linkHash, valueNode) {
|
||
const title = linkHash
|
||
? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText)
|
||
: titleText;
|
||
return h('div', { class: 'pineap-title-card' },
|
||
h('div', { class: 'pineap-card-title' }, title),
|
||
h('div', { class: 'pineap-card-title-content' }, valueNode));
|
||
}
|
||
|
||
views.pineap = (root) => {
|
||
const box = pineapShell(root, '#/pineap');
|
||
|
||
const stats = {};
|
||
const statWrap = h('div', { class: 'pineap-title-card-container' });
|
||
const statDefs = [
|
||
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
|
||
['clients', 'Clients Connected', '#/pineap/clients'],
|
||
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
|
||
];
|
||
statDefs.forEach(([k, label, hash]) => {
|
||
const val = h('span', { text: '—' });
|
||
stats[k] = val;
|
||
statWrap.appendChild(pineapTitleCard(label, hash, val));
|
||
});
|
||
box.appendChild(statWrap);
|
||
|
||
const mode = h('span', { class: 'badge', text: '—' });
|
||
let selectedMode = 'unknown';
|
||
let modeDirty = false;
|
||
let modePending = false;
|
||
const segBtns = {};
|
||
const modeBar = h('div', { class: 'seg' });
|
||
['passive', 'active', 'advanced'].forEach((m) => {
|
||
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
|
||
b.addEventListener('click', () => selectMode(m, true));
|
||
modeBar.appendChild(b);
|
||
segBtns[m] = b;
|
||
});
|
||
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
|
||
const saveModeBtn = btn('Save Mode', saveMode, 'ghost');
|
||
saveModeBtn.disabled = true;
|
||
const modeCard = h('div', { class: 'pineap-title-card' },
|
||
h('div', { class: 'pineap-card-title-flex' }, mode),
|
||
h('div', { class: 'pineap-card-button-group' }, modeBar),
|
||
modeInfo,
|
||
h('div', { class: 'pineap-mode-save' }, saveModeBtn));
|
||
|
||
const quick = {
|
||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||
};
|
||
const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||
h('div', { class: 'pineap-card-title' }, 'Quick Settings'));
|
||
quickCard.appendChild(h('label', { class: 'switch' }, quick.collect, h('span', { class: 'track' }), 'Capture SSIDs to Pool'));
|
||
quickCard.appendChild(h('label', { class: 'switch' }, quick.advertise, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool'));
|
||
const poolNotice = h('div', { class: 'pineap-infobox warn', style: 'margin-top:8px',
|
||
text: 'Pool broadcast disabled: it segfaults pineapd on this firmware (crash-loop fix).' });
|
||
quickCard.appendChild(poolNotice);
|
||
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
|
||
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
|
||
|
||
const modeRow = h('div', { class: 'pineap-title-card-container' });
|
||
modeRow.appendChild(modeCard);
|
||
modeRow.appendChild(quickCard);
|
||
box.appendChild(modeRow);
|
||
|
||
const rfSel = h('select', {},
|
||
h('option', { value: 'uplink', text: 'Uplink (station)' }),
|
||
h('option', { value: 'attack', text: 'Attack' }),
|
||
h('option', { value: 'idle', text: 'Idle' }));
|
||
const rfSsid = h('input', { placeholder: 'Uplink network SSID', autocomplete: 'off' });
|
||
const rfPsk = h('input', { type: 'password', placeholder: 'Leave blank for an open network',
|
||
autocomplete: 'new-password' });
|
||
const rfStatusBadge = h('span', { class: 'badge unknown', text: '—' });
|
||
const rfStatusInfo = h('span', { class: 'muted', text: '' });
|
||
const rfResult = h('div', { class: 'mk8-rf-result', text: '' });
|
||
function renderRfStatus(d) {
|
||
const role = d.role || 'idle';
|
||
rfStatusBadge.textContent = role.toUpperCase();
|
||
rfStatusBadge.className = 'badge ' +
|
||
(role === 'uplink' ? (d.assoc ? 'on' : 'warn') : role === 'attack' ? 'warn' : 'off');
|
||
const parts = [];
|
||
if (role === 'uplink') parts.push(d.assoc ? 'associated to ' + d.assoc : 'not associated');
|
||
if (d.hop_paused != null) parts.push('hop ' + (d.hop_paused ? 'paused' : 'running'));
|
||
rfStatusInfo.textContent = parts.join(' · ');
|
||
}
|
||
const rfApply = btn('Apply Role', () => {
|
||
const role = rfSel.value;
|
||
if (role === 'uplink' && !rfSsid.value.trim()) {
|
||
rfResult.textContent = 'SSID is required for the uplink role.';
|
||
rfResult.className = 'mk8-rf-result error';
|
||
return;
|
||
}
|
||
return PagerAPI.post('/api/rfplan/role', { role, ssid: rfSsid.value.trim(), psk: rfPsk.value })
|
||
.then((r) => {
|
||
const d = r.data || {};
|
||
rfResult.textContent = 'Role applied: ' + (d.role || role) +
|
||
(d.assoc ? ' — associated to ' + d.assoc : '');
|
||
rfResult.className = 'mk8-rf-result ok';
|
||
rfPsk.value = '';
|
||
return PagerAPI.get('/api/rfplan').then((s) => renderRfStatus(s.data || {}));
|
||
})
|
||
.catch((e) => {
|
||
rfResult.textContent = (e && e.message) || 'Role change failed.';
|
||
rfResult.className = 'mk8-rf-result error';
|
||
});
|
||
});
|
||
const rfCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||
h('div', { class: 'pineap-card-title' }, 'RF Role (radio1)'),
|
||
h('p', { class: 'pineap-card-subtitle',
|
||
text: 'Radio1 is shared between an uplink client and attack work; applying a role makes them mutually exclusive.' }));
|
||
rfCard.appendChild(h('div', { class: 'rf-role-status' }, rfStatusBadge, rfStatusInfo));
|
||
rfCard.appendChild(h('label', {}, 'Role', rfSel));
|
||
rfCard.appendChild(h('div', { class: 'rf-role-grid' },
|
||
h('label', {}, 'Uplink SSID', rfSsid),
|
||
h('label', {}, 'Uplink Password', rfPsk)));
|
||
rfCard.appendChild(rfApply);
|
||
rfCard.appendChild(rfResult);
|
||
const rfRow = h('div', { class: 'pineap-title-card-container' });
|
||
rfRow.appendChild(rfCard);
|
||
box.appendChild(rfRow);
|
||
|
||
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', () => {
|
||
if (cb.disabled) return;
|
||
const requested = cb.checked;
|
||
cb.indeterminate = false;
|
||
cb.disabled = true;
|
||
on(requested).then(() => {
|
||
if (remember) remember(requested);
|
||
load();
|
||
}).catch((e) => {
|
||
cb.checked = !requested;
|
||
load();
|
||
App.toast((e && e.message) || 'Failed', 'error');
|
||
}).finally(() => { cb.disabled = false; });
|
||
});
|
||
}
|
||
function rememberAdvanced(key, value) {
|
||
PINEAP_SESSION[key] = value;
|
||
PINEAP_SESSION.mode = 'advanced';
|
||
modeDirty = false;
|
||
selectMode('advanced', false);
|
||
}
|
||
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }),
|
||
(v) => rememberAdvanced('collect', v));
|
||
bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }),
|
||
(v) => rememberAdvanced('advertise', v));
|
||
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
|
||
|
||
function renderModeInfo(m) {
|
||
modeInfo.innerHTML = '';
|
||
if (m === 'unknown') {
|
||
modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.';
|
||
return;
|
||
}
|
||
const descriptions = {
|
||
passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'],
|
||
active: ['Capture SSIDs to the impersonation pool', 'Enable the PineAP response engine', 'Pool broadcast stays disabled (firmware crash fix)']
|
||
};
|
||
if (m === 'advanced') {
|
||
modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.';
|
||
return;
|
||
}
|
||
modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' }));
|
||
const list = h('ul', { class: 'pineap-mode-features' });
|
||
descriptions[m].forEach((text) => list.appendChild(h('li', { text })));
|
||
modeInfo.appendChild(list);
|
||
}
|
||
|
||
function selectMode(m, dirty) {
|
||
selectedMode = m;
|
||
if (dirty) modeDirty = true;
|
||
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
|
||
mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1);
|
||
mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on');
|
||
renderModeInfo(m);
|
||
saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown';
|
||
}
|
||
|
||
function saveMode() {
|
||
if (!modeDirty || modePending || selectedMode === 'unknown') return;
|
||
modePending = true;
|
||
saveModeBtn.disabled = true;
|
||
saveModeBtn.classList.add('busy');
|
||
saveModeBtn.setAttribute('aria-busy', 'true');
|
||
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.removeAttribute('aria-busy');
|
||
saveModeBtn.disabled = !modeDirty;
|
||
});
|
||
}
|
||
|
||
let loadPending = false;
|
||
function load() {
|
||
if (loadPending) return;
|
||
loadPending = true;
|
||
const stateRequest = Promise.all([
|
||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
|
||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
|
||
PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} }))
|
||
]).then(([cfg, host, ap, preset]) => {
|
||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {};
|
||
const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null;
|
||
const wpa = a.wpa || {};
|
||
PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown';
|
||
['karma', 'advertise', 'collect'].forEach((key) => {
|
||
if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key];
|
||
});
|
||
if (!modeDirty) selectMode(PINEAP_SESSION.mode, false);
|
||
mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown');
|
||
const collect = typeof PINEAP_SESSION.collect === 'boolean'
|
||
? PINEAP_SESSION.collect
|
||
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
|
||
setKnownCheckbox(quick.collect, collect);
|
||
const pool = a.pool || {};
|
||
const poolDisabled = pool.disabled === true || pool.broadcast_blocked === true;
|
||
poolNotice.classList.toggle('hidden', !poolDisabled);
|
||
quick.advertise.disabled = poolDisabled;
|
||
quick.advertise.title = poolDisabled ? 'Disabled by firmware crash fix' : '';
|
||
if (pool.broadcast_blocked) PINEAP_SESSION.advertise = false;
|
||
else 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 : '0';
|
||
}).catch(() => { stats.clients.textContent = '0'; }),
|
||
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'; }),
|
||
PagerAPI.get('/api/rfplan').then((rf) => {
|
||
renderRfStatus(rf.data || {});
|
||
}).catch(() => {
|
||
rfStatusBadge.textContent = '—';
|
||
rfStatusBadge.className = 'badge unknown';
|
||
rfStatusInfo.textContent = 'RF plan unavailable';
|
||
})
|
||
];
|
||
Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; });
|
||
}
|
||
load();
|
||
const iv = setInterval(load, 5000);
|
||
return { destroy: () => clearInterval(iv) };
|
||
};
|
||
|
||
const EVIL_ENC = [
|
||
['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE']
|
||
];
|
||
|
||
const BAND_GROUPS = [
|
||
{ band: '2.4', label: '2.4 GHz', dfs: false,
|
||
channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
|
||
{ band: '5', label: '5 GHz', dfs: true,
|
||
channels: [36, 40, 44, 48, 52, 56, 60, 64,
|
||
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
|
||
149, 153, 157, 161, 165] },
|
||
{ band: '6', label: '6 GHz (WPA3/OWE only)', dfs: false,
|
||
channels: Array.from({ length: 14 }, (_, i) => 181 + i * 4) }
|
||
];
|
||
const DFS_SET = new Set([52, 56, 60, 64, 100, 104, 108, 112, 116, 120,
|
||
124, 128, 132, 136, 140, 144]);
|
||
|
||
function chanFreq(band, ch) {
|
||
if (band === '2.4') return 2412 + (ch - 1) * 5;
|
||
if (band === '5') return 5180 + (ch - 36) * 5;
|
||
return 5955 + (ch - 1) * 5; // 6 GHz
|
||
}
|
||
|
||
function chanLabel(band, ch) {
|
||
return 'Channel ' + ch + ' (' + chanFreq(band, ch) + ' MHz)'
|
||
+ (band === '5' && DFS_SET.has(ch) ? ' (DFS)' : '');
|
||
}
|
||
|
||
function chanSelect(sel, value) {
|
||
sel.appendChild(h('option', { value: '', text: 'Auto — best channel for the target' }));
|
||
BAND_GROUPS.forEach((g) => {
|
||
const og = h('optgroup', { label: g.label });
|
||
g.channels.forEach((ch) => {
|
||
og.appendChild(h('option', { value: ch, text: chanLabel(g.band, ch) }));
|
||
});
|
||
sel.appendChild(og);
|
||
});
|
||
if (value == null || value === '') {
|
||
sel.value = '';
|
||
} else {
|
||
const opts = Array.prototype.slice.call(sel.options);
|
||
const hit = opts.find((o) => Number(o.value) === Number(value));
|
||
if (hit) sel.value = hit.value;
|
||
}
|
||
return sel;
|
||
}
|
||
|
||
function bandOfChannel(ch) {
|
||
if (ch == null || ch === '') return '2.4';
|
||
ch = Number(ch);
|
||
if (ch >= 1 && ch <= 14) return '2.4';
|
||
if (ch >= 36 && ch <= 177) return '5';
|
||
return '6';
|
||
}
|
||
const OPEN_COUNTRIES = [
|
||
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
|
||
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
|
||
['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'],
|
||
['CR', 'Costa Rica'], ['CY', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'],
|
||
['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'],
|
||
['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'],
|
||
['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'],
|
||
['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'],
|
||
['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'],
|
||
['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'],
|
||
['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'],
|
||
['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'],
|
||
['PL', 'Poland'], ['PT', 'Portugal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'],
|
||
['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'],
|
||
['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'],
|
||
['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'],
|
||
['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'],
|
||
['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uruguay'],
|
||
['VE', 'Venezuela'], ['VN', 'Vietnam']
|
||
];
|
||
|
||
views.pineap_evilwpa = attackLauncher('wpa', {
|
||
title: 'Evil WPA',
|
||
passphrase: true,
|
||
encodings: EVIL_ENC,
|
||
handshakes: true,
|
||
export: true,
|
||
deauth: true,
|
||
tabHash: '#/pineap/evilwpa'
|
||
});
|
||
|
||
views.pineap_open = attackLauncher('open', {
|
||
title: 'OpenAP',
|
||
bssid: true,
|
||
country: true,
|
||
tabHash: '#/pineap/open'
|
||
});
|
||
|
||
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'));
|
||
const addBtn = btn('Add', () => {
|
||
const v = input.value.trim(); if (!v) return;
|
||
return runAction(addBtn, () => PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => {
|
||
input.value = ''; render(r.data.ssids); App.toast('Added');
|
||
}), 'Adding…');
|
||
});
|
||
const clearBtn = btn('Clear', () => {
|
||
if (!confirm('Clear the entire SSID pool?')) return;
|
||
return runAction(clearBtn, () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => {
|
||
render(r.data.ssids); App.toast('Pool cleared');
|
||
}), 'Clearing…');
|
||
}, 'danger');
|
||
poolBox.appendChild(h('div', { class: 'row' },
|
||
h('div', {}, h('label', {}, 'SSID', input)),
|
||
h('div', {}, addBtn),
|
||
h('div', {}, clearBtn)));
|
||
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', () => {
|
||
if (advCb.disabled) return;
|
||
const requested = advCb.checked;
|
||
advCb.indeterminate = false;
|
||
advCb.disabled = true;
|
||
PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => {
|
||
PINEAP_SESSION.advertise = requested;
|
||
load();
|
||
}).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); })
|
||
.finally(() => { advCb.disabled = false; });
|
||
});
|
||
colCb.addEventListener('change', () => {
|
||
if (colCb.disabled) return;
|
||
colCb.disabled = true;
|
||
PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load)
|
||
.catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); })
|
||
.finally(() => { colCb.disabled = false; });
|
||
});
|
||
|
||
function render(ssids) {
|
||
poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0;
|
||
list.innerHTML = '';
|
||
list.appendChild(table(
|
||
[{ label: 'SSID', key: 'ssid' },
|
||
{ label: '', render: (r) => btn('Remove', () => {
|
||
if (!confirm('Remove ' + r.ssid + '?')) return;
|
||
return PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid })
|
||
.then((x) => render(x.data.ssids));
|
||
}, 'danger') }],
|
||
(ssids || []).map((s) => ({ ssid: s }))));
|
||
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((e) => {
|
||
list.innerHTML = '';
|
||
list.appendChild(h('div', { class: 'empty', text: 'SSID pool unavailable: ' + ((e && e.message) || 'error') }));
|
||
});
|
||
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
|
||
const p = (r.data || {}).pool || {};
|
||
if (p.broadcast_blocked) {
|
||
PINEAP_SESSION.advertise = false;
|
||
advCb.disabled = true;
|
||
advCb.title = 'Disabled by firmware crash fix';
|
||
} else {
|
||
advCb.disabled = false;
|
||
advCb.title = '';
|
||
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: 'Source', key: 'source' },
|
||
{ label: '', render: (r) => btn('Kick', () => {
|
||
if (!confirm('Kick ' + r.mac + '?')) return;
|
||
return PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac })
|
||
.then(() => App.toast('Kicked'))
|
||
.then(load);
|
||
}, 'danger') }],
|
||
state.clients));
|
||
const cols = ['MAC', 'Interface', 'RSSI', 'Source'];
|
||
body.querySelectorAll('.tbl th').forEach((th, i) => { if (i === cols.length) th.textContent = 'Kick'; });
|
||
}
|
||
let pending = false;
|
||
function load() {
|
||
if (pending) return Promise.resolve();
|
||
pending = true;
|
||
return PagerAPI.get('/api/pineap/clients').then((r) => {
|
||
state.clients = (r.data && r.data.clients) || [];
|
||
count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : String(state.clients.length);
|
||
render();
|
||
if (r.data && r.data.error) {
|
||
body.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px',
|
||
text: r.data.error }));
|
||
}
|
||
if (!state.clients.length) {
|
||
body.appendChild(h('div', { class: 'empty',
|
||
text: 'No associated clients on AP interfaces. Evil Enterprise stations appear here and on the Evil Enterprise page (wlan1ent is not shown in the stock Pager UI).' }));
|
||
}
|
||
}).catch((e) => {
|
||
count.textContent = '0';
|
||
state.clients = [];
|
||
render();
|
||
body.appendChild(h('div', { class: 'empty',
|
||
text: 'Client listing failed (' + (e.message || 'error') + '). Try Evil Enterprise for wlan1ent stations.' }));
|
||
})
|
||
.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, error: '' };
|
||
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;
|
||
if (el.classList) el.classList.toggle('busy', value);
|
||
});
|
||
}
|
||
|
||
function applyResponse(r) {
|
||
const data = (r && r.data) || {};
|
||
state.mode = data.mode === 'allow' ? 'allow' : 'deny';
|
||
state.entries = Array.isArray(data.entries) ? data.entries : [];
|
||
state.error = data.error || '';
|
||
render();
|
||
}
|
||
|
||
function fail(message) {
|
||
App.toast(message, 'error');
|
||
}
|
||
|
||
function mutate(payload, success) {
|
||
if (state.pending) return Promise.resolve();
|
||
setPending(true);
|
||
return 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 Promise.resolve();
|
||
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; }
|
||
valueIn.value = '';
|
||
return mutate({ action: 'add', value }, singular + ' added');
|
||
}
|
||
|
||
function removeEntry(value) {
|
||
if (!confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) return;
|
||
return mutate({ action: 'delete', value }, singular + ' removed');
|
||
}
|
||
|
||
function clearCurrent() {
|
||
if (!confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) return;
|
||
return 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.')) return;
|
||
return 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.';
|
||
if (state.error) {
|
||
explanation.textContent += ' Live list is temporarily unavailable.';
|
||
}
|
||
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: (row) => btn('Remove', () => removeEntry(row.value), 'danger') }],
|
||
state.entries.map((e) => ({ value: e }))));
|
||
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((e) => {
|
||
applyResponse({ data: { mode: 'deny', entries: [], error: (e && e.message) || 'unavailable' } });
|
||
});
|
||
}
|
||
filterCard('Client Filter', 'client');
|
||
filterCard('SSID Filter', 'ssid');
|
||
return { destroy: () => {} };
|
||
};
|
||
|
||
const RECON_TABS = [
|
||
{ label: 'Scanning', hash: '#/recon' },
|
||
{ label: 'Reports', hash: '#/recon/reports' },
|
||
{ label: 'Handshakes', hash: '#/recon/handshakes' }
|
||
];
|
||
|
||
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
|
||
const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e'];
|
||
const RECON_ENC_ORDER = ['Open', 'WEP', 'WPA', 'WPA2-PSK', 'WPA2-Enterprise', 'WPA3-Personal', 'WPA3-PSK', 'WPA3-Enterprise', 'Unknown'];
|
||
const RECON_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
|
||
const RECON_MAX_HISTORY = 90;
|
||
const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91'];
|
||
const RECON_AP_COLS = [
|
||
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
|
||
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
|
||
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
|
||
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
|
||
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
|
||
{ key: 'identity', label: 'Identity', search: (a) => displayIdentity(a.device_identity), render: (a) => displayIdentity(a.device_identity) },
|
||
{ key: 'clients', label: 'Clients', search: (a) => a.client_count == null ? (a.clients || []).length : a.client_count, render: (a) => a.client_count == null ? (a.clients || []).length : a.client_count },
|
||
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
|
||
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
|
||
{ key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_seen) },
|
||
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
|
||
];
|
||
const RECON_CLIENT_COLS = [
|
||
{ key: 'mac', label: 'Client MAC', render: (c) => c.mac },
|
||
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
|
||
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
|
||
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 },
|
||
{ key: 'vendor', label: 'Vendor', search: (c) => displayIdentity(c.vendor), render: (c) => displayIdentity(c.vendor) },
|
||
{ key: 'associated_ssid', label: 'Associated SSID', search: (c) => (c.associations || []).map((a) => a.ssid || '').join(', '), render: (c) => associationCell(c.associations) }
|
||
];
|
||
|
||
function displayIdentity(identity) {
|
||
if (typeof identity === 'string') return identity || '--';
|
||
const value = identity || {};
|
||
const manufacturer = value.manufacturer || 'Unknown';
|
||
if (value.model) return manufacturer + ' ' + value.model;
|
||
if (manufacturer === 'Unknown' && value.oui) return manufacturer + ' (' + value.oui + ')';
|
||
return manufacturer;
|
||
}
|
||
|
||
function associationSummary(associations) {
|
||
const ssids = [];
|
||
(associations || []).forEach((association) => {
|
||
const ssid = association && association.ssid;
|
||
if (ssid && ssids.indexOf(ssid) === -1) ssids.push(ssid);
|
||
});
|
||
if (!ssids.length) return '--';
|
||
return ssids.length === 1 ? ssids[0] : ssids[0] + ' +' + (ssids.length - 1);
|
||
}
|
||
|
||
function associationCell(associations) {
|
||
const summary = associationSummary(associations);
|
||
const full = (associations || []).map((a) => {
|
||
if (!a || !a.ssid) return null;
|
||
const parts = [a.ssid];
|
||
if (a.bssid) parts.push(a.bssid);
|
||
if (a.sources && a.sources.length) parts.push('[' + a.sources.join(', ') + ']');
|
||
return parts.join(' ');
|
||
}).filter(Boolean).join('; ');
|
||
return h('span', { title: full || summary, text: summary });
|
||
}
|
||
|
||
function reconBandOf(freq) {
|
||
if (freq == null) return null;
|
||
if (freq >= 2400 && freq < 2500) return '2.4';
|
||
if (freq >= 4900 && freq < 5900) return '5';
|
||
if (freq >= 5900 && freq < 7125) return '6';
|
||
return null;
|
||
}
|
||
|
||
function reconSigColor(dbm) {
|
||
if (dbm == null) return '#9e9e9e';
|
||
if (dbm >= -50) return '#2e7d32';
|
||
if (dbm >= -67) return '#f9a825';
|
||
if (dbm >= -80) return '#ef6c00';
|
||
return '#c62828';
|
||
}
|
||
|
||
function dbmCell(dbm) {
|
||
const pct = dbm == null ? 0 : Math.max(0, Math.min(100, ((dbm + 100) / 60) * 100));
|
||
const color = reconSigColor(dbm);
|
||
const bar = h('span', { class: 'recon-dbm-bar' },
|
||
h('span', { class: 'recon-dbm-fill', style: 'width:' + pct.toFixed(0) + '%;background:' + color }));
|
||
return h('span', { class: 'recon-dbm-cell' },
|
||
bar, h('span', { class: 'recon-dbm-val', style: 'color:' + color, text: (dbm == null ? '--' : dbm + ' dBm') }));
|
||
}
|
||
|
||
function reconBandLabel(band) {
|
||
return band == null ? '--' : band + ' GHz';
|
||
}
|
||
|
||
function reconDefaultCols() {
|
||
return {
|
||
ap: { compare: true, ssid: true, bssid: true, band: true, channel: true, signal: true, identity: true, clients: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
|
||
client: { mac: true, signal: true, freq: true, packets: true, vendor: true, associated_ssid: true }
|
||
};
|
||
}
|
||
|
||
function reconLoadCols() {
|
||
try {
|
||
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
|
||
if (v && v.ap && v.client) {
|
||
const defaults = reconDefaultCols();
|
||
v.ap = Object.assign(defaults.ap, v.ap);
|
||
v.client = Object.assign(defaults.client, v.client);
|
||
if (typeof v.ap.compare !== 'boolean') v.ap.compare = true;
|
||
return v;
|
||
}
|
||
} catch (e) {}
|
||
return reconDefaultCols();
|
||
}
|
||
|
||
function reconFiltered(rows, q, colsArr) {
|
||
const ql = (q || '').toLowerCase();
|
||
if (!ql) return rows;
|
||
return rows.filter((r) => colsArr.some((c) => {
|
||
const value = c.search ? c.search(r) : r[c.key];
|
||
return String(value == null ? '' : value).toLowerCase().indexOf(ql) !== -1;
|
||
}));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Attacks: one-click Evil WPA / Open / Enterprise launchers.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
|
||
function attackBadge(ap) {
|
||
if (!ap) return badge(false);
|
||
if (!ap.enabled) return h('span', { class: 'badge off', text: 'OFF' });
|
||
return h('span', { class: 'badge' + (ap.live ? ' on' : ' warn'), text: ap.live ? 'LIVE' : 'CONFIGURED' });
|
||
}
|
||
|
||
function attackStatusCard() {
|
||
const card = h('div', { class: 'pineap-title-card' });
|
||
card.appendChild(h('div', { class: 'pineap-card-title' }, 'Status'));
|
||
const rows = h('div', { style: 'font-size:13px;line-height:1.9' });
|
||
card.appendChild(rows);
|
||
const append = (label, node) => rows.appendChild(
|
||
h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: label }), node));
|
||
return { card, rows, append };
|
||
}
|
||
|
||
function verifiedToast(result) {
|
||
if (result && result.verified) App.toast('Applied and verified on device');
|
||
else if (result && result.ok) App.toast('Applied (verification pending — radio reloading)', 'error');
|
||
else App.toast('Deploy failed', 'error');
|
||
}
|
||
|
||
function attackLauncher(kind, opts) {
|
||
return (root) => {
|
||
const box = pineapShell(root, opts.tabHash || '#/pineap/evilwpa');
|
||
const form = h('div', { class: 'pineap-title-card' });
|
||
form.appendChild(h('div', { class: 'pineap-card-title' }, opts.title));
|
||
box.appendChild(form);
|
||
const f = h('div', {});
|
||
form.appendChild(f);
|
||
|
||
const ssidIn = h('input', { id: 'atk-ssid' });
|
||
const hiddenCb = h('input', { type: 'checkbox', id: 'atk-hidden' });
|
||
const chanSel = h('select', { id: 'atk-channel' });
|
||
chanSelect(chanSel, null);
|
||
const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
|
||
let pskIn = null, encSel = null;
|
||
chanSel.addEventListener('change', () => {
|
||
const b = bandOfChannel(chanSel.value);
|
||
bandHint.textContent = b === '6' ? '6 GHz requires WPA3 (SAE/OWE).' : '';
|
||
if (encSel) {
|
||
Array.prototype.forEach.call(encSel.options, (o) => {
|
||
o.disabled = b === '6' && o.value !== 'sae' && o.value !== 'owe';
|
||
});
|
||
if (b === '6' && encSel.value === 'psk2') encSel.value = 'sae';
|
||
}
|
||
});
|
||
f.appendChild(h('label', {}, 'SSID (target network)', ssidIn));
|
||
if (opts.passphrase) {
|
||
pskIn = h('input', { id: 'atk-psk', type: 'password', autocomplete: 'new-password' });
|
||
encSel = h('select', { id: 'atk-enc' });
|
||
(opts.encodings || EVIL_ENC).forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
|
||
f.appendChild(h('label', {}, 'Passphrase', pskIn));
|
||
f.appendChild(h('label', {}, 'Encryption', encSel));
|
||
}
|
||
let bssidIn = null, coSel = null;
|
||
if (opts.bssid) {
|
||
bssidIn = h('input', { id: 'atk-bssid', placeholder: 'AA:BB:CC:DD:EE:FF' });
|
||
f.appendChild(h('label', {}, 'BSSID (spoof, optional)', bssidIn));
|
||
}
|
||
if (opts.country) {
|
||
coSel = h('select', { id: 'atk-country' });
|
||
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
|
||
f.appendChild(h('label', {}, 'Country', coSel));
|
||
}
|
||
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
|
||
f.appendChild(h('label', {}, 'Channel', chanSel));
|
||
f.appendChild(bandHint);
|
||
|
||
// Consume a Recon -> PineAP prefill (set by the Recon focus sidebar's
|
||
// "Send to PineAP" button). The form is filled but never auto-deploys:
|
||
// an attack always requires an explicit user action.
|
||
const prefill = PineAPPrefill.consume();
|
||
if (prefill && prefill.ssid) {
|
||
ssidIn.value = prefill.ssid;
|
||
hiddenCb.checked = !!prefill.hidden;
|
||
if (prefill.channel != null && prefill.channel !== '') {
|
||
const chanOpts = Array.prototype.slice.call(chanSel.options);
|
||
const hit = chanOpts.find((o) => Number(o.value) === Number(prefill.channel));
|
||
if (hit) chanSel.value = hit.value;
|
||
chanSel.dispatchEvent(new Event('change'));
|
||
}
|
||
if (encSel && prefill.enctype) {
|
||
const encOpts = Array.prototype.slice.call(encSel.options);
|
||
const hit = encOpts.find((o) => o.value === prefill.enctype);
|
||
if (hit) encSel.value = hit.value;
|
||
}
|
||
if (pskIn) pskIn.placeholder = 'Passphrase for ' + prefill.ssid;
|
||
if (bssidIn && prefill.bssid) bssidIn.value = prefill.bssid;
|
||
f.appendChild(h('div', { class: 'pineap-infobox info', style: 'margin:10px 0 0;font-size:12px',
|
||
text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' }));
|
||
}
|
||
|
||
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
|
||
h('div', {}, (function () {
|
||
const deployBtn = btn('Deploy Attack', () => {
|
||
const body = {
|
||
kind, ssid: ssidIn.value.trim(),
|
||
hidden: hiddenCb.checked,
|
||
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
|
||
};
|
||
if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; }
|
||
if (bssidIn) body.bssid = bssidIn.value.trim();
|
||
if (coSel) body.country = coSel.value;
|
||
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body)
|
||
.then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
|
||
});
|
||
return deployBtn;
|
||
})()),
|
||
h('div', {}, (function () {
|
||
const stopBtn = btn('Stop Attack', () => {
|
||
runAction(stopBtn, () => PagerAPI.post('/api/attacks/stop', { kind })
|
||
.then(() => { App.toast('Attack stopped'); load(); }), 'Stopping…');
|
||
}, 'danger');
|
||
return stopBtn;
|
||
})()),
|
||
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' },
|
||
'Deploy reconfigures the radio — you may be disconnected briefly.')));
|
||
|
||
const status = attackStatusCard();
|
||
box.appendChild(status.card);
|
||
|
||
const hsBox = h('div', {});
|
||
const captureBox = h('div', { class: 'pineap-title-card' });
|
||
captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture'));
|
||
const capBody = h('div', { style: 'font-size:13px' });
|
||
captureBox.appendChild(capBody);
|
||
box.appendChild(captureBox);
|
||
|
||
let capIface = 'wlan0mon';
|
||
function capRow(st) {
|
||
capBody.innerHTML = '';
|
||
const run = !!(st && st.running);
|
||
capBody.appendChild(h('div', { class: 'row' },
|
||
h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }),
|
||
h('div', {}, (function () {
|
||
const capBtn = btn(run ? 'Stop Capture' : 'Start Capture', () => {
|
||
runAction(capBtn, () => PagerAPI.post('/api/attacks/capture', {
|
||
iface: capIface, action: run ? 'stop' : 'start'
|
||
}).then((r) => capRow(r.data || {})), run ? 'Stopping…' : 'Starting…');
|
||
});
|
||
return capBtn;
|
||
})())));
|
||
capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
|
||
text: run && st.path ? st.path : '' }));
|
||
}
|
||
|
||
if (opts.export) {
|
||
const exp = h('div', { class: 'pineap-title-card' });
|
||
exp.appendChild(h('div', { class: 'pineap-card-title' }, 'Hashcat Export'));
|
||
const expBody = h('div', { style: 'font-size:13px;line-height:1.8' });
|
||
exp.appendChild(expBody);
|
||
expBody.appendChild(h('div', {}, (function () {
|
||
const expBtn = btn('Export .hc22000', () => {
|
||
runAction(expBtn, () => PagerAPI.get('/api/attacks/export/hc22000').then((r) => {
|
||
const d = r.data || {};
|
||
if (d.file) {
|
||
expBody.appendChild(h('div', { class: 'row', style: 'margin-top:8px' },
|
||
h('a', { class: 'btn ghost', href: '/api/attacks/export/hc22000/' + d.name,
|
||
style: 'text-decoration:none', download: d.name }, 'Download ' + d.name),
|
||
h('code', { style: 'font-size:12px', text: d.hashcat || '' })));
|
||
App.toast('Export ready');
|
||
} else App.toast('No captures to export', 'error');
|
||
}), 'Exporting…');
|
||
});
|
||
return expBtn;
|
||
})()));
|
||
box.appendChild(exp);
|
||
}
|
||
|
||
let deauthCard = null;
|
||
const ssidRef = { current: '' };
|
||
if (opts.deauth) {
|
||
deauthCard = deauthPanel(ssidRef);
|
||
box.appendChild(deauthCard);
|
||
}
|
||
|
||
let loadPending = false;
|
||
function load() {
|
||
if (loadPending) return;
|
||
loadPending = true;
|
||
const statusReq = PagerAPI.get('/api/attacks/status').then((r) => {
|
||
const s = r.data || {};
|
||
const ap = s[kind] || {};
|
||
const which = (ap.radio0 && ap.radio0.enabled) ? ap.radio0
|
||
: (ap.radio1 && ap.radio1.enabled) ? ap.radio1 : null;
|
||
status.rows.innerHTML = '';
|
||
status.append('Attack', attackBadge(which));
|
||
status.append('SSID', h('span', { text: which && which.ssid ? which.ssid : '—' }));
|
||
status.append('Interface', h('span', { text: which ? which.iface : '—' }));
|
||
status.append('Band', h('span', { text: which && which.band ? which.band + ' GHz' : '—' }));
|
||
status.append('Channel', h('span', { text: which && which.channel != null ? which.channel : '—' }));
|
||
if (which && !which.live) {
|
||
status.append('Note', h('span', { class: 'pineap-infobox warn',
|
||
text: 'Configured but interface not live yet — radio reload may still be in progress.' }));
|
||
}
|
||
capIface = (which && (which.band === '5' || which.band === '6')) ? 'wlan1mon' : 'wlan0mon';
|
||
if (ssidRef) {
|
||
ssidRef.current = which && which.ssid ? which.ssid : '';
|
||
if (ssidRef.tick) ssidRef.tick();
|
||
}
|
||
if (opts.handshakes) {
|
||
hsBox.innerHTML = '';
|
||
hsBox.appendChild(h('div', { class: 'pineap-card-title', text: 'Handshakes Captured (' + (s.handshakes || 0) + ')' }));
|
||
PagerAPI.get('/api/pineap/handshakes').then((hr) => {
|
||
const hd = (hr.data || {}).parsed || [];
|
||
const rows = hd.slice(-10).reverse().map((x) => ({
|
||
ssid: x.ssid || '—', bssid: x.bssid || '—',
|
||
when: fmtTime(x.ts || 0), type: x.type || ''
|
||
}));
|
||
hsBox.appendChild(rows.length ? table(
|
||
[{ key: 'when', label: 'When' }, { key: 'ssid', label: 'SSID' },
|
||
{ key: 'bssid', label: 'BSSID' }, { key: 'type', label: 'Type' }], rows)
|
||
: h('div', { class: 'empty', text: 'No handshakes yet.' }));
|
||
}).catch(() => {});
|
||
box.appendChild(hsBox);
|
||
}
|
||
}).catch(() => {});
|
||
const capReq = PagerAPI.post('/api/attacks/capture', { action: 'status' }).then((r) => capRow(r.data || {})).catch(() => {});
|
||
Promise.allSettled([statusReq, capReq]).finally(() => { loadPending = false; });
|
||
}
|
||
load();
|
||
const iv = setInterval(load, 5000);
|
||
return { destroy: () => clearInterval(iv) };
|
||
};
|
||
}
|
||
|
||
views.harness = (root) => {
|
||
root.appendChild(h('h1', { class: 'page-title', text: 'Harness' }));
|
||
const box = h('div', {});
|
||
root.appendChild(box);
|
||
|
||
const info = h('div', { class: 'pineap-title-card' });
|
||
info.appendChild(h('div', { class: 'pineap-card-title' }, 'Local Harness (MCP)'));
|
||
const infoBody = h('div', { style: 'font-size:13px;line-height:1.9' });
|
||
info.appendChild(infoBody);
|
||
box.appendChild(info);
|
||
|
||
const tok = h('code', { style: 'font-size:12px', text: '…' });
|
||
const endpoint = h('code', { style: 'font-size:12px', text: location.origin + '/mcp' });
|
||
infoBody.appendChild(h('div', { class: 'row' }, h('div', { style: 'min-width:130px', text: 'Endpoint' }), endpoint));
|
||
infoBody.appendChild(h('div', { class: 'row' },
|
||
h('div', { style: 'min-width:130px', text: 'Bearer token' }), tok,
|
||
h('div', {}, btn('Copy Token', () => {
|
||
return navigator.clipboard.writeText(tok.textContent).then(() => App.toast('Token copied'));
|
||
}))));
|
||
infoBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px', text: 'Agents call POST /mcp with JSON-RPC 2.0 (MCP Streamable HTTP). The token is the current session token.' }));
|
||
|
||
const snippet = h('pre', { style: 'font-size:12px;overflow:auto;background:rgba(127,127,127,.12);padding:10px;border-radius:4px;white-space:pre-wrap' });
|
||
const capBox = h('div', { class: 'pineap-title-card' });
|
||
capBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Capabilities'));
|
||
const capBody = h('div', { style: 'font-size:13px' });
|
||
capBox.appendChild(capBody);
|
||
box.appendChild(capBox);
|
||
|
||
function load() {
|
||
PagerAPI.get('/api/harness/capabilities').then((r) => {
|
||
const d = r.data || {};
|
||
capBody.innerHTML = '';
|
||
const tools = d.tools || [];
|
||
const prompts = d.prompts || [];
|
||
capBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px',
|
||
text: tools.length + ' tools, ' + prompts.length + ' playbooks, ' +
|
||
((d.resources || []).length) + ' resources' }));
|
||
const list = h('ul', { style: 'font-size:12px;padding-left:18px' });
|
||
tools.forEach((t) => list.appendChild(h('li', { text: t.name + ' — ' + t.description })));
|
||
capBody.appendChild(list);
|
||
}).catch(() => {});
|
||
PagerAPI.get('/api/harness/token').then((r) => {
|
||
const t = (r.data || {}).token || '';
|
||
tok.textContent = t ? t.slice(0, 12) + '…' : '(none)';
|
||
snippet.textContent = 'curl -s -X POST ' + location.origin + '/mcp \\\n' +
|
||
' -H "Content-Type: application/json" \\\n' +
|
||
' -H "Authorization: Bearer ' + t + '" \\\n' +
|
||
' -d \'{"jsonrpc":"2.0","id":1,"method":"tools/list"}\'';
|
||
infoBody.appendChild(snippet);
|
||
}).catch(() => {});
|
||
}
|
||
load();
|
||
return { destroy: () => {} };
|
||
};
|
||
|
||
function deauthPanel(ssidRef) {
|
||
const wrap = h('div', { class: 'pineap-title-card' });
|
||
wrap.appendChild(h('div', { class: 'pineap-card-title' }, 'Deauth Targeting'));
|
||
const body = h('div', { style: 'font-size:13px' });
|
||
wrap.appendChild(body);
|
||
const ssidBox = h('input', { placeholder: 'SSID to find clients for' });
|
||
const apSel = h('select', {});
|
||
const clTable = h('div', {});
|
||
let lastLookup = '';
|
||
function lookup(q) {
|
||
if (!q || q === lastLookup) return Promise.resolve();
|
||
lastLookup = q;
|
||
return PagerAPI.get('/api/attacks/clients?ssid=' + encodeURIComponent(q)).then((r) => {
|
||
const d = r.data || {};
|
||
apSel.innerHTML = '';
|
||
(d.aps || []).forEach((a) => apSel.appendChild(h('option', {
|
||
value: (a.bssid || '') + '|' + (a.channel || 1),
|
||
text: (a.ssid || q) + ' — ' + (a.bssid || '?') + ' ch' + (a.channel || 1)
|
||
})));
|
||
if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' }));
|
||
clTable.innerHTML = '';
|
||
const cl = (d.clients || []).slice(0, 30);
|
||
if (!cl.length) {
|
||
clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' }));
|
||
return;
|
||
}
|
||
clTable.appendChild(table(
|
||
[{ key: 'mac', label: 'MAC' }, { key: 'freq', label: 'Freq (MHz)' },
|
||
{ key: 'signal', label: 'Signal' }, { key: 'packets', label: 'Packets' },
|
||
{ key: '_deauth', label: '' }],
|
||
cl.map((c) => {
|
||
const mac = c.mac || c.client_mac || '';
|
||
return { mac: mac || '—', freq: c.freq || '', signal: c.signal || '',
|
||
packets: c.packets || '', _deauth: (function () {
|
||
const deauthBtn = btn('Deauth', () => {
|
||
const parts = apSel.value.split('|');
|
||
if (!mac || !parts[0]) { App.toast('Pick an AP and device first', 'error'); return; }
|
||
runAction(deauthBtn, () => PagerAPI.post('/api/attacks/deauth', {
|
||
bssid: parts[0], client: mac, channel: parseInt(parts[1], 10)
|
||
}).then(() => App.toast('Deauth frames sent')), 'Sending…');
|
||
}, 'danger');
|
||
return deauthBtn;
|
||
})() };
|
||
})));
|
||
}).catch(() => App.toast('Lookup failed', 'error'));
|
||
}
|
||
body.appendChild(h('div', { class: 'row' }, ssidBox,
|
||
h('div', {}, (function () {
|
||
const findBtn = btn('Find', () => {
|
||
lastLookup = '';
|
||
runAction(findBtn, () => lookup(ssidBox.value.trim()), 'Finding…');
|
||
});
|
||
return findBtn;
|
||
})())));
|
||
body.appendChild(apSel);
|
||
body.appendChild(clTable);
|
||
body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
|
||
text: 'Only deauth targets you are authorized to test.' }));
|
||
if (ssidRef) {
|
||
ssidRef.tick = () => {
|
||
const liveSsid = ssidRef.current && ssidRef.current.trim();
|
||
if (liveSsid && ssidBox.value.trim() !== liveSsid) {
|
||
ssidBox.value = liveSsid;
|
||
lastLookup = '';
|
||
lookup(liveSsid);
|
||
}
|
||
};
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
views.pineap_enterprise = (root) => {
|
||
const box = pineapShell(root, '#/pineap/enterprise');
|
||
const form = h('div', { class: 'pineap-title-card' });
|
||
form.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Enterprise'));
|
||
box.appendChild(form);
|
||
const f = h('div', {});
|
||
form.appendChild(f);
|
||
const ssidIn = h('input', { id: 'ent-ssid' });
|
||
const encSel = h('select', { id: 'ent-enc' });
|
||
[['wpa2', 'WPA2 Enterprise'], ['wpa3', 'WPA3 Enterprise']]
|
||
.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
|
||
const methodSel = h('select', { id: 'ent-method' });
|
||
[['any', 'Any (MSCHAPv2 + GTC)'], ['mschapv2', 'MSCHAPv2 hashes'], ['gtc', 'GTC (plaintext)']]
|
||
.forEach(([v, l]) => methodSel.appendChild(h('option', { value: v, text: l })));
|
||
const pskIn = h('input', { id: 'ent-pass', type: 'password', autocomplete: 'new-password' });
|
||
const hiddenCb = h('input', { type: 'checkbox', id: 'ent-hidden' });
|
||
const chanSel = h('select', { id: 'ent-channel' });
|
||
chanSelect(chanSel, null);
|
||
f.appendChild(h('label', {}, 'SSID', ssidIn));
|
||
f.appendChild(h('label', {}, 'Encryption', encSel));
|
||
f.appendChild(h('label', {}, 'Inner EAP', methodSel));
|
||
f.appendChild(h('label', {}, 'EAP user password (optional \u2014 not a RADIUS shared secret)', pskIn));
|
||
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
|
||
f.appendChild(h('label', {}, 'Channel (5 GHz only \u2014 Auto uses the target SSID\u2019s recon channel)', chanSel));
|
||
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
|
||
h('div', {}, (function () {
|
||
const deployBtn = btn('Deploy Attack', () => {
|
||
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', {
|
||
kind: 'enterprise', ssid: ssidIn.value.trim(),
|
||
enctype: encSel.value, passphrase: pskIn.value,
|
||
hidden: hiddenCb.checked, auth_method: methodSel.value,
|
||
channel: chanSel.value ? parseInt(chanSel.value, 10) : null
|
||
}).then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
|
||
});
|
||
return deployBtn;
|
||
})()),
|
||
h('div', {}, (function () {
|
||
const stopBtn = btn('Stop Attack', () => {
|
||
runAction(stopBtn, () => PagerAPI.post('/api/attacks/stop', { kind: 'enterprise' })
|
||
.then(() => { App.toast('Attack stopped'); load(); }), 'Stopping…');
|
||
}, 'danger');
|
||
return stopBtn;
|
||
})())));
|
||
|
||
const status = attackStatusCard();
|
||
box.appendChild(status.card);
|
||
|
||
const pApe = h('div', { class: 'pineap-title-card' });
|
||
pApe.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAPE'));
|
||
const pApeBody = h('div', {});
|
||
pApe.appendChild(pApeBody);
|
||
box.appendChild(pApe);
|
||
const authCb = h('input', { type: 'checkbox', id: 'ent-auth' });
|
||
authCb.addEventListener('change', () => {
|
||
if (authCb.disabled) return;
|
||
authCb.disabled = true;
|
||
PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked })
|
||
.then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })
|
||
.finally(() => { authCb.disabled = false; });
|
||
});
|
||
|
||
const radiusCard = h('div', { class: 'pineap-title-card' });
|
||
radiusCard.appendChild(h('div', { class: 'pineap-card-title-flex' },
|
||
h('span', { text: 'RADIUS / EAP captures' }),
|
||
h('span', { class: 'toolbar-spacer' }),
|
||
btn('Export hashcat -m 5500', () => {
|
||
return PagerAPI.get('/api/pineap/enterprise/export/hashcat').then((r) => {
|
||
downloadText('pineape-mschapv2.5500', typeof r.data === 'string' ? r.data : '');
|
||
App.toast('Exported hashcat -m 5500');
|
||
});
|
||
}, 'ghost'),
|
||
btn('Export john', () => {
|
||
return PagerAPI.get('/api/pineap/enterprise/export/john').then((r) => {
|
||
downloadText('pineape-mschapv2.john', typeof r.data === 'string' ? r.data : '');
|
||
App.toast('Exported john netntlm');
|
||
});
|
||
}, 'ghost'),
|
||
(function () {
|
||
const clearAll = btn('Clear all', () => {
|
||
if (!confirm('Clear all enterprise captures?')) return;
|
||
runAction(clearAll, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'all' }).then(load), 'Clearing…');
|
||
}, 'danger');
|
||
return clearAll;
|
||
})()));
|
||
const radiusNote = h('div', { class: 'muted', style: 'font-size:12px;margin-bottom:8px' });
|
||
const radiusBody = h('div', {});
|
||
radiusCard.appendChild(radiusNote);
|
||
radiusCard.appendChild(radiusBody);
|
||
box.appendChild(radiusCard);
|
||
|
||
const identCard = h('div', { class: 'pineap-title-card' });
|
||
identCard.appendChild(h('div', { class: 'pineap-card-title-flex' },
|
||
h('span', { text: 'EAP identities' }),
|
||
h('span', { class: 'toolbar-spacer' }),
|
||
(function () {
|
||
const clearIdent = btn('Clear', () => {
|
||
if (!confirm('Clear EAP identities?')) return;
|
||
runAction(clearIdent, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'basic' }).then(load), 'Clearing…');
|
||
}, 'danger');
|
||
return clearIdent;
|
||
})()));
|
||
const identBody = h('div', {});
|
||
identCard.appendChild(identBody);
|
||
box.appendChild(identCard);
|
||
|
||
const mschapCard = h('div', { class: 'pineap-title-card' });
|
||
mschapCard.appendChild(h('div', { class: 'pineap-card-title-flex' },
|
||
h('span', { text: 'MSCHAPv2 / RADIUS inner auth' }),
|
||
h('span', { class: 'toolbar-spacer' }),
|
||
(function () {
|
||
const clearMschap = btn('Clear', () => {
|
||
if (!confirm('Clear MSCHAPv2 captures?')) return;
|
||
runAction(clearMschap, () => PagerAPI.post('/api/pineap/enterprise/clear', { table: 'challenge' }).then(load), 'Clearing…');
|
||
}, 'danger');
|
||
return clearMschap;
|
||
})()));
|
||
const mschapBody = h('div', {});
|
||
mschapCard.appendChild(mschapBody);
|
||
box.appendChild(mschapCard);
|
||
|
||
const clientCard = h('div', { class: 'pineap-title-card' });
|
||
clientCard.appendChild(h('div', { class: 'pineap-card-title' }, 'Associated enterprise clients'));
|
||
const clientBody = h('div', {});
|
||
clientCard.appendChild(clientBody);
|
||
box.appendChild(clientCard);
|
||
|
||
const logCard = h('div', { class: 'pineap-title-card' });
|
||
logCard.appendChild(h('div', { class: 'pineap-card-title' }, 'hostapd / PineAPE log'));
|
||
const logBody = h('pre', { class: 'muted', style: 'font-size:11px;white-space:pre-wrap;max-height:240px;overflow:auto' });
|
||
logCard.appendChild(logBody);
|
||
box.appendChild(logCard);
|
||
|
||
const hashCol = {
|
||
label: 'hashcat -m 5500',
|
||
key: 'hashcat',
|
||
render: (r) => {
|
||
const val = r.hashcat || r.hashcat || '';
|
||
const shown = val.length > 42 ? val.slice(0, 38) + '\u2026' : val;
|
||
return h('div', { class: 'row' },
|
||
h('span', { text: shown || '\u2014', title: val }),
|
||
val ? btn('Copy', () => copyText(val, 'Hash copied'), 'ghost') : null);
|
||
}
|
||
};
|
||
|
||
function load() {
|
||
PagerAPI.get('/api/attacks/status').then((r) => {
|
||
const s = r.data || {};
|
||
const ent = s.enterprise || {};
|
||
const ap = ent.ap || null;
|
||
status.rows.innerHTML = '';
|
||
status.append('Attack', attackBadge(ap));
|
||
status.append('SSID', h('span', { text: ap && ap.ssid ? ap.ssid : '\u2014' }));
|
||
status.append('Interface', h('span', { text: ap ? ap.iface : '\u2014' }));
|
||
status.append('Band', h('span', { text: ap && ap.band ? ap.band + ' GHz' : '5 GHz' }));
|
||
status.append('Channel', h('span', { text: ap && ap.channel != null ? ap.channel : '\u2014' }));
|
||
status.append('Identities', h('span', { text: String(ent.identities != null ? ent.identities : 0) }));
|
||
status.append('MSCHAPv2', h('span', { text: String(ent.mschapv2 != null ? ent.mschapv2 : 0) }));
|
||
status.append('Credentials', h('span', { text: String(ent.creds || 0) }));
|
||
const stations = ent.stations || (ap && ap.stations) || [];
|
||
status.append('Stations', h('span', { text: stations.length ? stations.join(', ') : '\u2014' }));
|
||
status.append('Inner EAP', h('span', { text: ent.auth_method || (ap && ap.auth_method) || '\u2014' }));
|
||
status.append('TLS certs', h('span', { text: (ap && ap.certs) || ent.certs ? 'installed' : '\u2014' }));
|
||
status.append('Log captures', h('span', { text: String((ap && ap.captures != null) ? ap.captures : (ent.captures != null ? ent.captures : 0)) }));
|
||
pApeBody.innerHTML = '';
|
||
authCb.checked = !!(ent.pineape && ent.pineape.enabled) || !!(ap && ap.live);
|
||
pApeBody.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture'));
|
||
if (ent.pineape) {
|
||
pApeBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px',
|
||
text: 'PineAPE ' + (ent.pineape.enabled ? 'enabled' : 'disabled')
|
||
+ ((ap && ap.ctrl_linked) || ent.ctrl_linked ? ' · pineapd linked' : '') }));
|
||
}
|
||
}).catch((e) => { status.rows.innerHTML = ''; status.append('Status', h('span', { text: e.message || 'Failed to load' })); });
|
||
PagerAPI.get('/api/pineap/enterprise/radius').then((r) => {
|
||
const d = r.data || {};
|
||
radiusNote.textContent = d.note || d.note || '';
|
||
const rows = (d.captures || []).slice();
|
||
radiusBody.innerHTML = '';
|
||
radiusBody.appendChild(table([
|
||
{ label: 'When', key: 'time', render: (x) => fmtTime(x.time) },
|
||
{ label: 'Kind', key: 'kind' },
|
||
{ label: 'Username', key: 'username' },
|
||
{ label: 'Password', key: 'password' },
|
||
{ label: 'Challenge', key: 'challenge' },
|
||
{ label: 'Response', key: 'response' },
|
||
{ label: 'Source', key: 'source' },
|
||
hashCol
|
||
], rows));
|
||
if (!rows.length) radiusBody.appendChild(h('div', { class: 'empty', text: 'No EAP/RADIUS captures yet. Accept the client certificate warning, then retry login.' }));
|
||
const liveMacs = d.stations || [];
|
||
const byMac = {};
|
||
(d.clients || []).forEach((c) => { if (c && c.mac) byMac[String(c.mac).toUpperCase()] = c; });
|
||
liveMacs.forEach((mac) => {
|
||
const key = String(mac || '').toUpperCase();
|
||
if (!key) return;
|
||
if (!byMac[key]) byMac[key] = { mac: key, ssid: '', source: 'hostapd' };
|
||
});
|
||
const clients = Object.keys(byMac).map((k) => byMac[k]);
|
||
clientBody.innerHTML = '';
|
||
clientBody.appendChild(table([
|
||
{ label: 'MAC', key: 'mac' },
|
||
{ label: 'SSID', key: 'ssid' },
|
||
{ label: 'Source', key: 'source' },
|
||
{ label: 'Connected', key: 'connected_time', render: (x) => fmtTime(x.connected_time || x.connected_time) },
|
||
{ label: 'Disconnected', key: 'disconnected_time', render: (x) => fmtTime(x.disconnected_time || x.disconnected_time) }
|
||
], clients));
|
||
if (!clients.length) clientBody.appendChild(h('div', { class: 'empty', text: 'No associated enterprise clients yet.' }));
|
||
}).catch((e) => {
|
||
radiusBody.innerHTML = '';
|
||
radiusBody.appendChild(h('div', { class: 'empty', text: 'Capture list failed: ' + (e.message || 'error') }));
|
||
});
|
||
PagerAPI.get('/api/pineap/enterprise/log').then((r) => {
|
||
const lines = ((r.data || {}).lines || []);
|
||
logBody.textContent = lines.length ? lines.join('\n') : '(no hostapd log yet)';
|
||
}).catch((e) => { logBody.textContent = e.message || 'log unavailable'; });
|
||
PagerAPI.get('/api/pineap/enterprise/basic').then((r) => {
|
||
const rows = (r.data.rows || []).slice();
|
||
identBody.innerHTML = '';
|
||
identBody.appendChild(table([
|
||
{ label: 'When', key: 'time', render: (x) => fmtTime(x.time) },
|
||
{ label: 'Identity', key: 'identity' },
|
||
{ label: 'Password', key: 'password' },
|
||
{ label: 'Type', key: 'type' },
|
||
{ label: 'Verified', key: 'verified' }
|
||
], rows));
|
||
if (!rows.length) identBody.appendChild(h('div', { class: 'empty', text: 'No identities captured.' }));
|
||
}).catch(() => {});
|
||
PagerAPI.get('/api/pineap/enterprise/challenge').then((r) => {
|
||
const rows = (r.data.rows || []).slice();
|
||
mschapBody.innerHTML = '';
|
||
mschapBody.appendChild(table([
|
||
{ label: 'When', key: 'time', render: (x) => fmtTime(x.time) },
|
||
{ label: 'Username', key: 'username' },
|
||
{ label: 'Challenge', key: 'challenge' },
|
||
{ label: 'Response', key: 'response' },
|
||
hashCol
|
||
], rows));
|
||
if (!rows.length) mschapBody.appendChild(h('div', { class: 'empty', text: 'No MSCHAPv2 captures yet.' }));
|
||
}).catch(() => {});
|
||
}
|
||
load();
|
||
const iv = setInterval(load, 5000);
|
||
return { destroy: () => clearInterval(iv) };
|
||
};
|
||
function chartLegendEntry(s) {
|
||
const entry = h('div', { class: 'recon-chart-entry' });
|
||
const dot = h('span', { class: 'recon-chart-dot' });
|
||
dot.style.background = s.color;
|
||
entry.appendChild(dot);
|
||
entry.appendChild(h('span', { class: 'recon-chart-label', text: s.label }));
|
||
entry.appendChild(h('span', { class: 'recon-chart-count', text: String(s.value) }));
|
||
return entry;
|
||
}
|
||
|
||
function reconEncBucket(enc) {
|
||
const s = (enc || '').trim();
|
||
if (!s || s === 'Open') return 'Open';
|
||
if (s.indexOf('WEP') !== -1) return 'WEP';
|
||
if (s.indexOf('Enterprise') !== -1) {
|
||
return s.indexOf('WPA3') !== -1 ? 'WPA3-Enterprise' : 'WPA2-Enterprise';
|
||
}
|
||
if (s.indexOf('SAE') !== -1 || s.indexOf('OWE') !== -1) return 'WPA3-Personal';
|
||
if (s.indexOf('WPA3') !== -1 && s.indexOf('WPA2') !== -1) return 'WPA2-PSK';
|
||
if (s.indexOf('WPA3') !== -1) return 'WPA3-PSK';
|
||
if (s.indexOf('WPA2') !== -1) return 'WPA2-PSK';
|
||
if (s.indexOf('WPA') !== -1) return 'WPA';
|
||
return s || 'Unknown';
|
||
}
|
||
|
||
// Map a recon AP's encryption string to the Evil WPA form's enctype.
|
||
function reconPrefillEnc(enc) {
|
||
const s = (enc || '').toLowerCase();
|
||
if (s.indexOf('owe') !== -1) return 'owe';
|
||
if (s.indexOf('sae') !== -1) return 'sae';
|
||
return 'psk2';
|
||
}
|
||
|
||
function reconPer(key, def) {
|
||
const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10);
|
||
return [10, 25, 50].indexOf(v) !== -1 ? v : def;
|
||
}
|
||
|
||
function reconCmp(a, b, col, dir) {
|
||
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' || col.key === 'clients' ||
|
||
col.key === 'first_seen' || col.key === 'last_seen';
|
||
if (numeric) {
|
||
const value = (row) => col.key === 'clients'
|
||
? (row.client_count == null ? (row.clients || []).length : row.client_count)
|
||
: row[col.key];
|
||
const x = value(a) == null ? -Infinity : Number(value(a));
|
||
const y = value(b) == null ? -Infinity : Number(value(b));
|
||
return (x - y) * dir;
|
||
}
|
||
const xs = String(col.search ? col.search(a) : a[col.key] == null ? '' : a[col.key]).toLowerCase();
|
||
const ys = String(col.search ? col.search(b) : b[col.key] == null ? '' : b[col.key]).toLowerCase();
|
||
return xs.localeCompare(ys) * dir;
|
||
}
|
||
|
||
views.recon = (root) => {
|
||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||
tabBar(root, RECON_TABS, '#/recon');
|
||
|
||
const state = { scans: [], selected: null, detail: null,
|
||
apPage: 0, apSearch: '', clientPage: 0, clientSearch: '',
|
||
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
|
||
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
|
||
scanActive: false, detailLoading: false, detailLoadingId: null,
|
||
detailQueued: false, detailId: null, detailArchive: null,
|
||
apBand: 'all', apEnc: 'all', gps: null, wigle: null,
|
||
compare: [], history: {}, mapBand: null,
|
||
archive: null, archives: [], scanRemaining: null,
|
||
hopperOnline: null, historyReset: false, scanErr: null,
|
||
hopperWarning: null, hopperHint: null, hopperIfaces: [],
|
||
wlan0Pinned: false, wlan0Sta: false };
|
||
const cols = reconLoadCols();
|
||
|
||
// ---- title cards (stat cards with optional mini charts) ----
|
||
const cardWrap = h('div', { class: 'recon-title-card-container' });
|
||
root.appendChild(cardWrap);
|
||
|
||
function titleCard(titleText, link) {
|
||
const card = h('div', { class: 'recon-card' });
|
||
const head = h('div', { class: 'recon-card-title' });
|
||
head.appendChild(link
|
||
? h('a', { class: 'recon-card-title-link', href: link, text: titleText })
|
||
: h('span', { text: titleText }));
|
||
card.appendChild(head);
|
||
const body = h('div', { class: 'recon-card-body' });
|
||
card.appendChild(body);
|
||
cardWrap.appendChild(card);
|
||
return body;
|
||
}
|
||
|
||
const landBody = titleCard('Wireless Landscape', null);
|
||
const landBox = h('div', { class: 'recon-chart-box' });
|
||
landBody.appendChild(landBox);
|
||
const landCanvas = h('canvas', { id: 'recon-landscape' });
|
||
landBox.appendChild(landCanvas);
|
||
const landEmpty = h('div', { class: 'recon-no-data', text: 'No landscape data yet — run a scan.' });
|
||
landBox.appendChild(landEmpty);
|
||
const landLegend = h('div', { class: 'recon-chart-legend', id: 'recon-landscape-legend' });
|
||
landBody.appendChild(landLegend);
|
||
|
||
const chanBody = titleCard('Channel Distribution', null);
|
||
const chanValue = h('div', { class: 'recon-card-value', text: '—' });
|
||
const chanSub = h('div', { class: 'recon-card-sub', text: '' });
|
||
chanBody.appendChild(chanValue);
|
||
chanBody.appendChild(chanSub);
|
||
const chanBox = h('div', { class: 'recon-chart-box' });
|
||
chanBody.appendChild(chanBox);
|
||
const chanCanvas = h('canvas', { id: 'recon-channel' });
|
||
chanBox.appendChild(chanCanvas);
|
||
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel data yet — run a scan.' });
|
||
chanBox.appendChild(chanEmpty);
|
||
|
||
const encBody = titleCard('Encryption Landscape', null);
|
||
const encBox = h('div', { class: 'recon-chart-box' });
|
||
encBody.appendChild(encBox);
|
||
const encCanvas = h('canvas', { id: 'recon-encryption' });
|
||
encBox.appendChild(encCanvas);
|
||
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
|
||
encBox.appendChild(encEmpty);
|
||
const encLegend = h('div', { class: 'recon-chart-legend', id: 'recon-enc-legend' });
|
||
encBody.appendChild(encLegend);
|
||
|
||
const hsBody = titleCard('Handshakes', '#/recon/handshakes');
|
||
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
|
||
hsBody.appendChild(hsCount);
|
||
hsBody.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' }), ' Auto-collect handshakes');
|
||
hsAuto.querySelector('input').addEventListener('change', () => {
|
||
const cb = hsAuto.querySelector('input');
|
||
if (cb.disabled) return;
|
||
const requested = cb.checked;
|
||
cb.disabled = true;
|
||
PagerAPI.post('/api/pineap/set_config', { loghandshake: requested })
|
||
.then(() => App.toast('Settings saved'))
|
||
.catch(() => {
|
||
cb.checked = !requested;
|
||
App.toast('Failed to save', 'error');
|
||
})
|
||
.finally(() => { cb.disabled = false; });
|
||
});
|
||
hsBody.appendChild(hsAuto);
|
||
|
||
const psBody = titleCard('Previous Scans', null);
|
||
const psValue = h('div', { class: 'recon-card-value', text: '—' });
|
||
const psSub = h('div', { class: 'recon-card-sub', text: '' });
|
||
psBody.appendChild(psValue);
|
||
psBody.appendChild(psSub);
|
||
const psRow = h('div', { class: 'recon-ps-row' });
|
||
psBody.appendChild(psRow);
|
||
let pickerOptions = [];
|
||
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
|
||
sel.addEventListener('change', () => {
|
||
const meta = pickerOptions[parseInt(sel.value, 10)];
|
||
if (!meta) return;
|
||
state.archive = meta.archive;
|
||
state.selected = meta.scanId;
|
||
state.apPage = 0; state.clientPage = 0;
|
||
state.detailId = null; state.detailArchive = null;
|
||
loadDetail();
|
||
});
|
||
psRow.appendChild(sel);
|
||
const psActions = h('div', { class: 'recon-ps-actions' });
|
||
psBody.appendChild(psActions);
|
||
function dlBase() {
|
||
if (state.selected == null) return null;
|
||
return state.archive
|
||
? App.apiBase + '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
|
||
: App.apiBase + '/api/recon/scans/' + state.selected;
|
||
}
|
||
const dlJson = iconBtn('file_download', 'Download scan JSON', () => {
|
||
const base = dlBase();
|
||
if (base) window.location = base + '/download/json';
|
||
});
|
||
const dlCsv = iconBtn('table_chart', 'Download scan CSV', () => {
|
||
const base = dlBase();
|
||
if (base) window.location = base + '/download/csv';
|
||
});
|
||
const dlHtml = iconBtn('description', 'Download scan HTML report', () => {
|
||
const base = dlBase();
|
||
if (base) window.location = base + '/download/html';
|
||
});
|
||
const delBtn = iconBtn('delete', 'Delete scan', () => {
|
||
if (state.selected == null || state.archive) return;
|
||
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
|
||
return PagerAPI.del('/api/recon/scans/' + state.selected)
|
||
.then(() => { App.toast('Scan deleted'); load(); });
|
||
});
|
||
const delAllBtn = iconBtn('delete_forever', 'Delete all scans', () => {
|
||
if (!confirm('Delete ALL recorded scans? This cannot be undone.')) return;
|
||
return PagerAPI.del('/api/recon/scans')
|
||
.then((r) => {
|
||
App.toast('All scans deleted' + (r.data && r.data.deleted ? ' (' + r.data.deleted + ')' : ''));
|
||
state.selected = null; state.archive = null;
|
||
load();
|
||
});
|
||
});
|
||
psActions.appendChild(dlJson);
|
||
psActions.appendChild(dlCsv);
|
||
psActions.appendChild(dlHtml);
|
||
psActions.appendChild(delBtn);
|
||
psActions.appendChild(delAllBtn);
|
||
|
||
// ---- scan bar ----
|
||
const scanBar = h('div', { class: 'section recon-scan-bar' });
|
||
root.appendChild(scanBar);
|
||
const scanToggle = h('input', { type: 'checkbox', id: 'recon-scan-toggle' });
|
||
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
|
||
scanBar.appendChild(scanLabel);
|
||
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
|
||
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes']]
|
||
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
|
||
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
|
||
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
|
||
scanBar.appendChild(durSel);
|
||
const scanStatus = h('span', { class: 'recon-scan-status muted', text: '' });
|
||
scanBar.appendChild(scanStatus);
|
||
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
|
||
const gpsPill = h('button', { class: 'recon-pill recon-pill-gps', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
|
||
const wiglePill = h('button', { class: 'recon-pill recon-pill-wigle', title: 'WiGLE logging (click to toggle)' });
|
||
scanBar.appendChild(gpsPill);
|
||
scanBar.appendChild(wiglePill);
|
||
scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
|
||
function renderPills() {
|
||
const g = state.gps || {};
|
||
if (g.lock) {
|
||
gpsPill.className = 'recon-pill recon-pill-gps on';
|
||
gpsPill.textContent = 'GPS ' + (g.lat != null ? Number(g.lat).toFixed(5) : '--') + ', ' + (g.lon != null ? Number(g.lon).toFixed(5) : '--') + (g.satellites ? ' · ' + g.satellites + ' sats' : '');
|
||
} else if (g.present) {
|
||
gpsPill.className = 'recon-pill recon-pill-gps';
|
||
gpsPill.textContent = 'GPS no fix';
|
||
} else if (g.gpsd_running) {
|
||
gpsPill.className = 'recon-pill recon-pill-gps';
|
||
gpsPill.textContent = 'GPS no device';
|
||
} else {
|
||
gpsPill.className = 'recon-pill recon-pill-gps';
|
||
gpsPill.textContent = 'GPS off';
|
||
}
|
||
wiglePill.className = 'recon-pill recon-pill-wigle ' + (state.wigle ? 'on' : '');
|
||
wiglePill.textContent = state.wigle ? 'WiGLE on' : 'WiGLE off';
|
||
}
|
||
gpsPill.addEventListener('click', () => {
|
||
if (gpsPill.disabled) return;
|
||
gpsPill.disabled = true;
|
||
gpsPill.classList.add('busy');
|
||
PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
|
||
state.gps = r.data;
|
||
renderPills();
|
||
if (r.data.error) App.toast(r.data.error, 'error');
|
||
else if (r.data.lock) App.toast('GPS locked: ' + Number(r.data.lat).toFixed(5) + ', ' + Number(r.data.lon).toFixed(5));
|
||
else App.toast((r.data.note || 'GPS bound, waiting for a fix'));
|
||
}).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'))
|
||
.finally(() => { gpsPill.disabled = false; gpsPill.classList.remove('busy'); });
|
||
});
|
||
wiglePill.addEventListener('click', () => {
|
||
const next = !state.wigle;
|
||
wiglePill.disabled = true;
|
||
wiglePill.classList.add('busy');
|
||
PagerAPI.post('/api/recon/wigle', { enable: next }).then((r) => {
|
||
state.wigle = next;
|
||
App.toast(next ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
|
||
}).catch((err) => App.toast((err && err.message) || 'WiGLE toggle failed', 'error'))
|
||
.finally(() => { wiglePill.disabled = false; wiglePill.classList.remove('busy'); renderPills(); });
|
||
});
|
||
let pendingScan = false;
|
||
function renderScanBar() {
|
||
const scanning = state.scanActive;
|
||
scanToggle.disabled = scanning || pendingScan;
|
||
durSel.disabled = scanning;
|
||
const bits = [];
|
||
if (scanning) {
|
||
bits.push('Scanning' + (state.scanRemaining != null ? ' · ' + state.scanRemaining + 's left' : ''));
|
||
}
|
||
if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen');
|
||
if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)');
|
||
const hop24 = (state.hopperIfaces || []).indexOf('wlan0mon') >= 0;
|
||
if (state.hopperWarning) bits.push(state.hopperWarning);
|
||
else {
|
||
if (state.wlan0Pinned && !hop24) bits.push('2.4GHz under-sampled — OpenAP/Evil WPA holds wlan0mon');
|
||
if (state.wlan0Sta && !hop24) bits.push('2.4GHz starved — client STA (wlan0) pins phy0');
|
||
}
|
||
if (state.scanErr) bits.push(state.scanErr);
|
||
scanStatus.textContent = bits.join(' · ');
|
||
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || (!hop24 && (state.wlan0Pinned || state.wlan0Sta)) || !!state.scanErr || !!state.hopperWarning);
|
||
}
|
||
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((r) => {
|
||
const data = (r && r.data) || {};
|
||
if (on) {
|
||
state.scanActive = true;
|
||
state.autoFollow = true;
|
||
state.apPage = 0;
|
||
state.clientPage = 0;
|
||
state.hopperWarning = data.warning || null;
|
||
state.hopperHint = data.hint || null;
|
||
state.hopperIfaces = data.hopping || [];
|
||
if (data.warning) App.toast(data.warning, 'error');
|
||
else App.toast('Scan started');
|
||
} else {
|
||
state.scanActive = false;
|
||
state.autoFollow = false;
|
||
state.hopperWarning = null;
|
||
state.hopperHint = null;
|
||
state.hopperIfaces = [];
|
||
App.toast('Scan stopped');
|
||
}
|
||
renderScanBar();
|
||
restartPoll();
|
||
load();
|
||
})
|
||
.catch((err) => {
|
||
const detail = err && err.data || {};
|
||
if (err && err.status === 409) {
|
||
// Timed scans cannot be aborted on this firmware. Keep the toggle
|
||
// on and show remaining time instead of looking like a dead click.
|
||
scanToggle.checked = true;
|
||
state.scanActive = true;
|
||
state.scanRemaining = detail.scan_remaining != null
|
||
? detail.scan_remaining : null;
|
||
renderScanBar();
|
||
const left = state.scanRemaining != null ? ' (' + state.scanRemaining + 's left)' : '';
|
||
App.toast(on
|
||
? ('Scan already running — it will finish automatically' + left)
|
||
: ('Pager cannot abort a timed scan — it will finish automatically' + left));
|
||
} else {
|
||
scanToggle.checked = !on;
|
||
const hint = detail.hint || detail.detail;
|
||
const msg = (err && err.message) || 'Recon control failed';
|
||
state.scanErr = hint || msg;
|
||
renderScanBar();
|
||
App.toast(hint ? msg + ' — ' + hint : msg, 'error');
|
||
}
|
||
})
|
||
.finally(() => { pendingScan = false; scanToggle.disabled = state.scanActive; });
|
||
});
|
||
|
||
// ---- settings sidebar ----
|
||
const sidebar = h('div', { class: 'recon-settings-sidebar hidden' });
|
||
sidebar.appendChild(h('div', { class: 'recon-settings-head' },
|
||
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
|
||
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
|
||
const colDefs = {
|
||
ap: [['compare', 'Show Compare'], ['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['band', 'Show Band'],
|
||
['channel', 'Show Channel'], ['signal', 'Show Signal'], ['vendor', 'Show Vendor'],
|
||
['encryption', 'Show Encryption'], ['first_seen', 'Show First Seen'],
|
||
['last_seen', 'Show Last Seen'], ['hidden', 'Show Hidden']],
|
||
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
|
||
};
|
||
Object.keys(colDefs).forEach((grp) => {
|
||
sidebar.appendChild(h('div', { class: 'recon-settings-section', text: grp === 'ap' ? 'Access Points' : 'Clients' }));
|
||
colDefs[grp].forEach(([key, label]) => {
|
||
const cb = h('input', { type: 'checkbox', id: 'col-' + grp + '-' + key });
|
||
cb.checked = cols[grp][key];
|
||
cb.addEventListener('change', () => { cols[grp][key] = cb.checked; localStorage.setItem('pw_recon_cols', JSON.stringify(cols)); renderTables(); });
|
||
sidebar.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
|
||
});
|
||
});
|
||
root.appendChild(sidebar);
|
||
|
||
// ---- AP focus sidebar ----
|
||
const focusSidebar = h('div', { class: 'recon-focus-sidebar hidden' });
|
||
root.appendChild(focusSidebar);
|
||
|
||
function renderFocus() {
|
||
const ap = state.focusAp;
|
||
if (!ap) return;
|
||
focusSidebar.innerHTML = '';
|
||
const head = h('div', { class: 'recon-focus-header' },
|
||
h('div', { class: 'recon-focus-header-text', text: ap.ssid || 'Hidden SSID' }),
|
||
btn('×', () => { state.focusAp = null; focusSidebar.classList.add('hidden'); renderTables(); }, 'ghost'));
|
||
focusSidebar.appendChild(head);
|
||
focusSidebar.appendChild(h('div', { class: 'recon-focus-bssid', text: ap.bssid || '--' }));
|
||
focusSidebar.appendChild(h('div', { class: 'recon-focus-subtext', text: 'Channel ' + (ap.channel == null ? '--' : ap.channel) + ' · ' + (ap.freq || '--') + ' MHz' }));
|
||
|
||
const actions = h('div', { class: 'recon-focus-body' });
|
||
focusSidebar.appendChild(actions);
|
||
actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' }));
|
||
const twin = h('button', { class: 'btn recon-focus-action-button recon-focus-twin', text: 'Send to PineAP — Twin this network' });
|
||
twin.addEventListener('click', () => {
|
||
const open = reconEncBucket(ap.encryption) === 'Open';
|
||
PineAPPrefill.set({
|
||
ssid: ap.ssid || '',
|
||
hidden: !!ap.hidden,
|
||
channel: ap.channel,
|
||
bssid: open ? (ap.bssid || '') : '',
|
||
enctype: open ? null : reconPrefillEnc(ap.encryption),
|
||
source: ap.ssid || 'hidden network'
|
||
});
|
||
App.go(open ? '#/pineap/open' : '#/pineap/evilwpa');
|
||
App.toast('PineAP form prefilled for ' + (ap.ssid || 'the hidden network') + ' — verify, then Deploy');
|
||
});
|
||
actions.appendChild(twin);
|
||
const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' });
|
||
capture.addEventListener('click', () => runAction(capture, () => PagerAPI.post('/api/pineap/set_config', { loghandshake: true })
|
||
.then(() => {
|
||
App.toast('Handshake capture enabled (device-wide on Pager)');
|
||
if (hsAuto) hsAuto.querySelector('input').checked = true;
|
||
}), 'Enabling…'));
|
||
actions.appendChild(capture);
|
||
const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' });
|
||
stopHs.addEventListener('click', () => runAction(stopHs, () => PagerAPI.post('/api/pineap/set_config', { loghandshake: false })
|
||
.then(() => {
|
||
App.toast('Handshake capture disabled');
|
||
if (hsAuto) hsAuto.querySelector('input').checked = false;
|
||
}), 'Stopping…'));
|
||
actions.appendChild(stopHs);
|
||
const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' });
|
||
exB.addEventListener('click', () => {
|
||
if (!ap.bssid) return;
|
||
runAction(exB, () => PagerAPI.post('/api/recon/examine', { bssid: ap.bssid, seconds: 30 })
|
||
.then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen')), 'Examining…');
|
||
});
|
||
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; }
|
||
runAction(exC, () => PagerAPI.post('/api/recon/examine', { channel: ap.channel, seconds: 30 })
|
||
.then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…');
|
||
});
|
||
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 })));
|
||
});
|
||
|
||
const confirmed = h('div', { class: 'recon-focus-body recon-confirmed-clients' });
|
||
focusSidebar.appendChild(confirmed);
|
||
confirmed.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Confirmed Clients' }));
|
||
const clients = (ap.clients || []).filter((client) => client && client.mac);
|
||
if (!clients.length) {
|
||
confirmed.appendChild(h('div', { class: 'empty', text: 'No confirmed clients' }));
|
||
} else {
|
||
clients.forEach((client) => {
|
||
const sources = (client.associations || []).reduce((all, association) => {
|
||
(association.sources || []).forEach((source) => { if (all.indexOf(source) === -1) all.push(source); });
|
||
return all;
|
||
}, []);
|
||
confirmed.appendChild(h('div', { class: 'recon-focus-client' },
|
||
h('span', { class: 'recon-focus-client-mac', text: client.mac }),
|
||
h('span', { text: displayIdentity(client.vendor) }),
|
||
h('span', { class: 'recon-focus-client-sources', text: sources.join(', ') || '--' })));
|
||
});
|
||
}
|
||
}
|
||
|
||
function toggleFocus(ap) {
|
||
if (state.focusAp && state.focusAp.bssid === ap.bssid) {
|
||
state.focusAp = null;
|
||
focusSidebar.classList.add('hidden');
|
||
} else {
|
||
state.focusAp = ap;
|
||
renderFocus();
|
||
focusSidebar.classList.remove('hidden');
|
||
}
|
||
renderTables();
|
||
}
|
||
|
||
// ---- compare: select up to 6 APs; the rest of the page focuses on them ----
|
||
const cmpCard = h('div', { class: 'section recon-compare-card' });
|
||
cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
|
||
const cmpCanvas = h('canvas', { id: 'recon-compare', style: 'width:100%;height:150px' });
|
||
cmpCard.appendChild(cmpCanvas);
|
||
const cmpLegend = h('div', { class: 'recon-compare-legend' });
|
||
cmpCard.appendChild(cmpLegend);
|
||
const cmpEmpty = h('div', { class: 'empty recon-compare-empty', text: 'Tick the Compare box on an AP below to track its signal here.' });
|
||
cmpCard.appendChild(cmpEmpty);
|
||
|
||
function toggleCompare(a) {
|
||
if (!a || !a.bssid) return false;
|
||
const i = state.compare.indexOf(a.bssid);
|
||
if (i !== -1) {
|
||
state.compare = state.compare.filter((b) => b !== a.bssid);
|
||
} else {
|
||
if (state.compare.length >= 6) {
|
||
App.toast('Compare up to 6 APs', 'error');
|
||
return false;
|
||
}
|
||
state.compare.push(a.bssid);
|
||
const name = (a.ssid || '(hidden)');
|
||
App.toast('Comparing ' + (name.length > 24 ? name.slice(0, 24) + '…' : name));
|
||
}
|
||
renderSelection();
|
||
renderCompare();
|
||
renderTables();
|
||
drawCharts(state.detail || {});
|
||
renderChannelMap();
|
||
return true;
|
||
}
|
||
|
||
function renderCompare() {
|
||
const series = [];
|
||
const legends = [];
|
||
const d = state.detail || {};
|
||
const aps = d.aps || [];
|
||
state.compare.forEach((bssid, i) => {
|
||
const hist = state.history[bssid] || [];
|
||
let pts = hist.map((p) => p.sig).filter((v) => v != null);
|
||
if (pts.length === 1) pts = [pts[0], pts[0]];
|
||
if (pts.length < 2) {
|
||
const ap = aps.find((a) => a.bssid === bssid);
|
||
if (ap && ap.signal != null) pts = [ap.signal, ap.signal];
|
||
}
|
||
const color = RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length];
|
||
if (pts.length >= 2) series.push({ points: pts, color: color });
|
||
const ap = aps.find((a) => a.bssid === bssid);
|
||
const last = pts.length ? pts[pts.length - 1] : null;
|
||
legends.push(h('span', { class: 'recon-compare-legend-item' },
|
||
h('span', { class: 'recon-compare-swatch', style: 'background:' + color }),
|
||
h('span', { text: (ap && ap.ssid) || '(hidden) ' + (bssid || '').slice(0, 8) + '…' }),
|
||
h('span', { class: 'recon-compare-sig', text: last == null ? '--' : last + ' dBm' })));
|
||
});
|
||
cmpLegend.innerHTML = '';
|
||
legends.forEach((l) => cmpLegend.appendChild(l));
|
||
cmpEmpty.classList.toggle('hidden', state.compare.length > 0);
|
||
if (typeof MiniChart !== 'undefined' && MiniChart.draw) {
|
||
MiniChart.draw(cmpCanvas, series, { min: -100, max: -20, grid: '#e0e0e0' });
|
||
}
|
||
}
|
||
|
||
// ---- results tables ----
|
||
const apCard = h('div', { class: 'section recon-scan-results-card' });
|
||
|
||
// ---- channel map (above Access Points) ----
|
||
const mapCard = h('div', { class: 'section recon-map-card' });
|
||
root.appendChild(mapCard);
|
||
root.appendChild(apCard);
|
||
root.appendChild(cmpCard);
|
||
mapCard.appendChild(h('h2', { text: 'Channel Map' }));
|
||
mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz. Hover a lobe (or click to pin) to see the networks under it.' }));
|
||
const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' });
|
||
mapCard.appendChild(mapChips);
|
||
const mapBox = h('div', { class: 'recon-map-box' });
|
||
mapCard.appendChild(mapBox);
|
||
const mapCanvas = h('canvas', { id: 'recon-map', style: 'width:100%;height:180px' });
|
||
mapBox.appendChild(mapCanvas);
|
||
const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' });
|
||
mapBox.appendChild(mapEmpty);
|
||
const mapTip = h('div', { class: 'recon-map-tip hidden' });
|
||
mapBox.appendChild(mapTip);
|
||
let mapTipPinned = false;
|
||
|
||
function renderMapTip(hits, x, y) {
|
||
mapTip.innerHTML = '';
|
||
hits.forEach((a) => {
|
||
mapTip.appendChild(h('div', { class: 'recon-map-tip-row' },
|
||
h('div', { class: 'recon-map-tip-ssid', text: a.ssid || '(hidden SSID)' }),
|
||
h('div', { class: 'recon-map-tip-meta', text:
|
||
(a.bssid || '--') + ' · CH ' + (a.channel == null ? '--' : a.channel) +
|
||
(a.freq ? ' · ' + a.freq + ' MHz' : '') + ' · ' +
|
||
(a.signal == null ? '--' : a.signal + ' dBm') }),
|
||
h('div', { class: 'recon-map-tip-meta', text:
|
||
(a.encryption || '--') + (a.vendor && a.vendor !== 'Unknown' ? ' · ' + a.vendor : '') })));
|
||
});
|
||
const bx = mapBox.getBoundingClientRect();
|
||
const tx = Math.max(4, Math.min(x - bx.left + 14, bx.width - 250));
|
||
const ty = Math.max(4, Math.min(y - bx.top + 14, bx.height - 80));
|
||
mapTip.style.left = tx + 'px';
|
||
mapTip.style.top = ty + 'px';
|
||
mapTip.classList.remove('hidden');
|
||
}
|
||
|
||
function mapHitsAt(e) {
|
||
if (!mapCanvas.__reconLobes || !mapCanvas.__reconLobesHit) return [];
|
||
const rect = mapCanvas.getBoundingClientRect();
|
||
return mapCanvas.__reconLobesHit(e.clientX - rect.left, e.clientY - rect.top, 8);
|
||
}
|
||
|
||
mapCanvas.addEventListener('mousemove', (e) => {
|
||
const hits = mapHitsAt(e);
|
||
if (!hits.length) {
|
||
if (!mapTipPinned) mapTip.classList.add('hidden');
|
||
mapCanvas.style.cursor = 'default';
|
||
return;
|
||
}
|
||
mapCanvas.style.cursor = 'pointer';
|
||
if (!mapTipPinned) renderMapTip(hits, e.clientX, e.clientY);
|
||
});
|
||
mapCanvas.addEventListener('mouseleave', () => {
|
||
if (!mapTipPinned) mapTip.classList.add('hidden');
|
||
mapCanvas.style.cursor = 'default';
|
||
});
|
||
mapCanvas.addEventListener('click', (e) => {
|
||
const hits = mapHitsAt(e);
|
||
if (!hits.length) {
|
||
mapTip.classList.add('hidden');
|
||
mapTipPinned = false;
|
||
return;
|
||
}
|
||
mapTipPinned = !mapTipPinned;
|
||
if (mapTipPinned) renderMapTip(hits, e.clientX, e.clientY);
|
||
else mapTip.classList.add('hidden');
|
||
});
|
||
|
||
function mapAps() {
|
||
const d = state.detail || {};
|
||
const all = d.aps || [];
|
||
const sel = state.compare.length
|
||
? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
|
||
: all;
|
||
return sel.map((a) => {
|
||
const idx = state.compare.length ? state.compare.indexOf(a.bssid) : -1;
|
||
const color = idx !== -1
|
||
? RECON_COMPARE_COLORS[idx % RECON_COMPARE_COLORS.length]
|
||
: reconSigColor(a.signal);
|
||
return Object.assign({}, a, { color: color });
|
||
});
|
||
}
|
||
|
||
function renderChannelMap() {
|
||
const aps = mapAps().filter((a) => a.band === '2.4' || a.band === '5' || a.band === '6');
|
||
const bands = {};
|
||
aps.forEach((a) => { bands[a.band] = (bands[a.band] || 0) + 1; });
|
||
const order = ['2.4', '5', '6'];
|
||
const present = order.filter((b) => bands[b]);
|
||
if (!state.mapBand || !bands[state.mapBand]) {
|
||
state.mapBand = present.length
|
||
? present.slice().sort((a, b) => bands[b] - bands[a])[0]
|
||
: null;
|
||
}
|
||
mapChips.innerHTML = '';
|
||
mapChips.appendChild(h('span', { class: 'recon-chips-label', text: 'Band' }));
|
||
order.forEach((b) => {
|
||
const c = h('button', {
|
||
class: 'recon-chip' + (state.mapBand === b ? ' active' : '') + (bands[b] ? '' : ' disabled'),
|
||
text: b + ' GHz'
|
||
});
|
||
c.addEventListener('click', () => {
|
||
if (!bands[b]) return;
|
||
state.mapBand = b;
|
||
renderChannelMap();
|
||
});
|
||
mapChips.appendChild(c);
|
||
});
|
||
const vis = aps.filter((a) => a.band === state.mapBand);
|
||
const hasChan = vis.some((a) => a.channel != null || a.freq != null);
|
||
if (!vis.length || !hasChan) {
|
||
mapCanvas.classList.add('hidden');
|
||
mapEmpty.classList.remove('hidden');
|
||
mapTip.classList.add('hidden');
|
||
mapTipPinned = false;
|
||
return;
|
||
}
|
||
mapEmpty.classList.add('hidden');
|
||
mapCanvas.classList.remove('hidden');
|
||
if (typeof MiniChart !== 'undefined' && MiniChart.channelMap) {
|
||
MiniChart.channelMap(mapCanvas, vis, { grid: '#e0e0e0' });
|
||
}
|
||
}
|
||
|
||
const cliCard = h('div', { class: 'section recon-scan-results-card' });
|
||
root.appendChild(cliCard);
|
||
|
||
// ---- selection chips (visible while comparing) ----
|
||
const selRow = h('div', { class: 'recon-sel-chips hidden' });
|
||
apCard.appendChild(selRow);
|
||
|
||
// ---- band / encryption filter chips ----
|
||
const chipRow = h('div', { class: 'recon-chips-row' });
|
||
apCard.appendChild(chipRow);
|
||
function renderChips() {
|
||
chipRow.innerHTML = '';
|
||
const groups = [
|
||
['Band', 'apBand', [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']]],
|
||
['Encryption', 'apEnc', [['all', 'All'], ['Open', 'Open'], ['WEP', 'WEP'], ['WPA', 'WPA'],
|
||
['WPA2-PSK', 'WPA2-PSK'], ['WPA2-Enterprise', 'WPA2-Enterprise'],
|
||
['WPA3-PSK', 'WPA3-Personal'], ['WPA3-Enterprise', 'WPA3-Enterprise']]]
|
||
];
|
||
groups.forEach(([label, key, opts]) => {
|
||
chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label }));
|
||
opts.forEach(([v, t]) => {
|
||
const c = h('button', { class: 'recon-chip' + (state[key] === v ? ' active' : ''), text: t });
|
||
c.addEventListener('click', () => {
|
||
state[key] = v;
|
||
state.apPage = 0;
|
||
renderChips();
|
||
renderTables();
|
||
});
|
||
chipRow.appendChild(c);
|
||
});
|
||
});
|
||
}
|
||
renderChips();
|
||
|
||
function renderSelection() {
|
||
selRow.innerHTML = '';
|
||
const d = state.detail || {};
|
||
const aps = d.aps || [];
|
||
if (!state.compare.length) { selRow.classList.add('hidden'); return; }
|
||
selRow.classList.remove('hidden');
|
||
selRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Comparing' }));
|
||
state.compare.forEach((bssid, i) => {
|
||
const ap = aps.find((a) => a.bssid === bssid);
|
||
const chip = h('span', { class: 'recon-sel-chip', style: 'border-color:' + RECON_COMPARE_COLORS[i % RECON_COMPARE_COLORS.length] },
|
||
h('span', { text: (ap && ap.ssid) || '(hidden)' }),
|
||
h('span', { class: 'recon-sel-chip-x', text: '×', title: 'Remove from comparison' }));
|
||
chip.querySelector('.recon-sel-chip-x').addEventListener('click', () => {
|
||
state.compare = state.compare.filter((b) => b !== bssid);
|
||
renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
|
||
});
|
||
selRow.appendChild(chip);
|
||
});
|
||
const clear = h('button', { class: 'btn ghost recon-sel-clear', text: 'Clear selection' });
|
||
clear.addEventListener('click', () => {
|
||
state.compare = [];
|
||
renderSelection(); renderCompare(); renderTables(); drawCharts(state.detail || {}); renderChannelMap();
|
||
});
|
||
selRow.appendChild(clear);
|
||
}
|
||
|
||
function buildPaginator(key) {
|
||
const mk = (id, icon, title, fn) => {
|
||
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title });
|
||
b.innerHTML = PineappleIcons[icon] || '';
|
||
b.addEventListener('click', fn);
|
||
return b;
|
||
};
|
||
return h('div', { class: 'recon-paginator' },
|
||
mk('first', 'first_page', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }),
|
||
mk('prev', 'chevron_left', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }),
|
||
h('span', { class: 'muted', id: key + '-range', text: '' }),
|
||
mk('next', 'chevron_right', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }),
|
||
mk('last', 'last_page', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
|
||
}
|
||
|
||
function filteredRows(key) {
|
||
const d = state.detail || {};
|
||
if (key === 'client') {
|
||
return reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
|
||
}
|
||
// Comparing never hides the list: selection just adds chips, a compare
|
||
// table and charts. The full AP set stays visible so more boxes can be
|
||
// ticked without clearing the selection first.
|
||
const all = d.aps || [];
|
||
let out = reconFiltered(all, state.apSearch, RECON_AP_COLS);
|
||
if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
|
||
if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
|
||
return out;
|
||
}
|
||
|
||
function reconPageCount(key) {
|
||
return Math.max(1, Math.ceil(filteredRows(key).length / state[key + 'Per']));
|
||
}
|
||
|
||
function perSelect(key) {
|
||
const sel = h('select', { class: 'sel recon-per', id: key + '-per' });
|
||
[10, 25, 50].forEach((n) => sel.appendChild(h('option', { value: String(n), text: n + ' / page' })));
|
||
sel.value = String(state[key + 'Per']);
|
||
sel.addEventListener('change', () => {
|
||
state[key + 'Per'] = parseInt(sel.value, 10) || 10;
|
||
localStorage.setItem('pw_recon_per_' + key, String(state[key + 'Per']));
|
||
state[key + 'Page'] = 0;
|
||
renderTables();
|
||
});
|
||
return sel;
|
||
}
|
||
|
||
function tableHead(box, title, key, searchId, onInput) {
|
||
const head = h('div', { class: 'recon-table-head' },
|
||
h('h2', { text: title }),
|
||
h('span', { class: 'toolbar-spacer' }),
|
||
h('input', { class: 'recon-search', id: searchId, placeholder: 'Search' }),
|
||
perSelect(key),
|
||
buildPaginator(key));
|
||
box.appendChild(head);
|
||
head.querySelector('#' + searchId).addEventListener('input', onInput);
|
||
}
|
||
|
||
tableHead(apCard, 'Access Points', 'ap', 'ap-search', () => { state.apSearch = document.getElementById('ap-search').value; state.apPage = 0; renderTables(); });
|
||
const apBody = h('div', { class: 'recon-table-body' });
|
||
apCard.appendChild(apBody);
|
||
|
||
tableHead(cliCard, 'Clients', 'client', 'cli-search', () => { state.clientSearch = document.getElementById('cli-search').value; state.clientPage = 0; renderTables(); });
|
||
const cliBody = h('div', { class: 'recon-table-body' });
|
||
cliCard.appendChild(cliBody);
|
||
|
||
function renderTable(box, key, rows, colsArr, emptyMsg) {
|
||
box.innerHTML = '';
|
||
const vis = colsArr.filter((c) => cols[key][c.key]);
|
||
const per = state[key + 'Per'];
|
||
const page = state[key + 'Page'];
|
||
const start = page * per;
|
||
const slice = rows.slice(start, start + per);
|
||
const rowAttrs = key === 'ap'
|
||
? (r) => ({
|
||
class: (state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '')
|
||
+ (state.compare.indexOf(r.bssid) !== -1 ? ' recon-row-compare' : ''),
|
||
style: 'cursor:pointer',
|
||
onclick: () => toggleFocus(r)
|
||
})
|
||
: undefined;
|
||
const tbl = table(vis, slice, rowAttrs);
|
||
box.appendChild(tbl);
|
||
tbl.querySelectorAll('th').forEach((th, i) => {
|
||
const col = vis[i];
|
||
if (!col) return;
|
||
if (col.key === 'compare') return;
|
||
th.style.cursor = 'pointer';
|
||
th.title = 'Sort by ' + col.label;
|
||
const arrow = h('span', { class: 'recon-sort-arrow' });
|
||
th.appendChild(arrow);
|
||
th.addEventListener('click', () => {
|
||
const cur = state[key + 'Sort'];
|
||
state[key + 'Sort'] = (cur && cur.key === col.key) ? { key: col.key, dir: -cur.dir } : { key: col.key, dir: 1 };
|
||
state[key + 'Page'] = 0;
|
||
renderTables();
|
||
});
|
||
const cur = state[key + 'Sort'];
|
||
if (cur && cur.key === col.key) {
|
||
th.classList.add('recon-sorted');
|
||
arrow.textContent = cur.dir === 1 ? ' ▲' : ' ▼';
|
||
}
|
||
});
|
||
if (!rows.length) box.appendChild(h('div', { class: 'empty', text: emptyMsg }));
|
||
const range = document.getElementById(key + '-range');
|
||
if (range) range.textContent = rows.length ? (start + 1) + '–' + Math.min(start + per, rows.length) + ' of ' + rows.length : '0 of 0';
|
||
const p = reconPageCount(key);
|
||
const first = document.getElementById(key + '-first');
|
||
const prev = document.getElementById(key + '-prev');
|
||
const next = document.getElementById(key + '-next');
|
||
const last = document.getElementById(key + '-last');
|
||
if (first) first.disabled = page === 0;
|
||
if (prev) prev.disabled = page === 0;
|
||
if (next) next.disabled = page >= p - 1;
|
||
if (last) last.disabled = page >= p - 1;
|
||
}
|
||
|
||
function sortRows(rows, key, colsArr) {
|
||
const s = state[key + 'Sort'];
|
||
if (!s) return rows;
|
||
const col = colsArr.find((c) => c.key === s.key);
|
||
if (!col) return rows;
|
||
const copy = rows.slice();
|
||
copy.sort((a, b) => reconCmp(a, b, col, s.dir));
|
||
return copy;
|
||
}
|
||
|
||
const compareCol = { key: 'compare', label: 'Compare', render: (a) => {
|
||
const cb = h('input', { type: 'checkbox', title: 'Compare this AP' });
|
||
cb.checked = state.compare.indexOf(a.bssid) !== -1;
|
||
cb.addEventListener('click', (e) => e.stopPropagation());
|
||
cb.addEventListener('change', () => {
|
||
const ok = toggleCompare(a);
|
||
if (!ok) cb.checked = false;
|
||
});
|
||
return h('span', { class: 'recon-cmp-check' }, cb);
|
||
} };
|
||
const apCols = [compareCol].concat(RECON_AP_COLS);
|
||
|
||
function renderTables() {
|
||
const d = state.detail || { aps: [], clients: [], handshakes: [] };
|
||
const apF = filteredRows('ap');
|
||
const cliF = filteredRows('client');
|
||
cliCard.classList.remove('hidden');
|
||
renderTable(apBody, 'ap', sortRows(apF, 'ap', apCols), apCols,
|
||
'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 all = d.aps || [];
|
||
const aps = state.compare.length
|
||
? all.filter((a) => state.compare.indexOf(a.bssid) !== -1)
|
||
: all;
|
||
const n = aps.length;
|
||
const c = (d.clients || []).length;
|
||
const un = d.unassociated || 0;
|
||
|
||
const landSegs = state.compare.length
|
||
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
|
||
: [
|
||
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
|
||
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
|
||
{ label: 'Unassociated', value: un, color: RECON_LANDSCAPE_COLORS[2] }
|
||
];
|
||
const chCounts = {};
|
||
aps.forEach((a) => {
|
||
const ch = a.channel == null ? '?' : a.channel;
|
||
chCounts[ch] = (chCounts[ch] || 0) + 1;
|
||
});
|
||
const chKeys = Object.keys(chCounts);
|
||
let busiest = null, busiestN = 0;
|
||
chKeys.forEach((k) => {
|
||
if (k === '?') return;
|
||
if (chCounts[k] > busiestN) { busiest = k; busiestN = chCounts[k]; }
|
||
});
|
||
chanValue.textContent = busiest == null ? '—' : 'CH ' + busiest;
|
||
chanSub.textContent = busiest == null
|
||
? ''
|
||
: busiestN + ' of ' + n + ' APs · ' + chKeys.filter((k) => k !== '?').length + ' channels';
|
||
const encCounts = {};
|
||
aps.forEach((a) => {
|
||
const b = reconEncBucket(a.encryption);
|
||
encCounts[b] = (encCounts[b] || 0) + 1;
|
||
});
|
||
const encSegs = Object.keys(encCounts)
|
||
.sort((a, b) => {
|
||
const ia = RECON_ENC_ORDER.indexOf(a);
|
||
const ib = RECON_ENC_ORDER.indexOf(b);
|
||
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || encCounts[b] - encCounts[a];
|
||
})
|
||
.map((k, i) => ({
|
||
label: k, value: encCounts[k], color: RECON_ENC_COLORS[i % RECON_ENC_COLORS.length]
|
||
}));
|
||
|
||
const land = document.getElementById('recon-landscape');
|
||
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
||
try {
|
||
if (n > 0) {
|
||
MiniChart.doughnut(land, landSegs, { legend: false, height: 112 });
|
||
land.classList.remove('hidden');
|
||
landEmpty.classList.add('hidden');
|
||
} else {
|
||
land.classList.add('hidden');
|
||
landEmpty.classList.remove('hidden');
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
const landLegend = document.getElementById('recon-landscape-legend');
|
||
if (landLegend) {
|
||
landLegend.innerHTML = '';
|
||
landSegs.forEach((s) => {
|
||
if (!s.value) return;
|
||
landLegend.appendChild(chartLegendEntry(s));
|
||
});
|
||
}
|
||
const ch = document.getElementById('recon-channel');
|
||
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
|
||
try {
|
||
const keys = chKeys.sort((a, b) => {
|
||
if (a === '?') return 1;
|
||
if (b === '?') return -1;
|
||
return Number(a) - Number(b);
|
||
});
|
||
if (keys.length) {
|
||
MiniChart.bar(ch, keys.map((k, i) => ({
|
||
label: k, value: chCounts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
|
||
})), { height: 90 });
|
||
ch.classList.remove('hidden');
|
||
chanEmpty.classList.add('hidden');
|
||
} else {
|
||
ch.classList.add('hidden');
|
||
chanEmpty.classList.remove('hidden');
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
const enc = document.getElementById('recon-encryption');
|
||
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
||
try {
|
||
if (aps.length) {
|
||
MiniChart.doughnut(enc, encSegs, { legend: false, height: 112 });
|
||
enc.classList.remove('hidden');
|
||
encEmpty.classList.add('hidden');
|
||
} else {
|
||
enc.classList.add('hidden');
|
||
encEmpty.classList.remove('hidden');
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
const encLegend = document.getElementById('recon-enc-legend');
|
||
if (encLegend) {
|
||
encLegend.innerHTML = '';
|
||
encSegs.forEach((s) => {
|
||
encLegend.appendChild(chartLegendEntry(s));
|
||
});
|
||
}
|
||
}
|
||
|
||
function detailUrl() {
|
||
return state.archive
|
||
? '/api/recon/archives/' + encodeURIComponent(state.archive) + '/scans/' + state.selected
|
||
: '/api/recon/scans/' + state.selected;
|
||
}
|
||
|
||
function loadDetail() {
|
||
if (state.selected == null) return;
|
||
const scanId = state.selected;
|
||
const arch = state.archive;
|
||
if (state.detailLoading) {
|
||
if (state.detailLoadingId !== scanId) state.detailQueued = true;
|
||
return;
|
||
}
|
||
if (!state.scanActive && state.detailId === scanId && state.detailArchive === arch) return;
|
||
state.detailLoading = true;
|
||
state.detailLoadingId = scanId;
|
||
PagerAPI.get(detailUrl()).then((r) => {
|
||
if (state.selected !== scanId || state.archive !== arch) return;
|
||
try {
|
||
const isNewScan = state.detailId !== scanId || state.detailArchive !== arch;
|
||
state.detail = r.data;
|
||
state.detailId = scanId;
|
||
state.detailArchive = arch;
|
||
if (isNewScan) state.history = {};
|
||
const aps = r.data.aps || [];
|
||
const seen = {};
|
||
aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; });
|
||
if (state.compare.some((b) => !seen[b])) {
|
||
state.compare = state.compare.filter((b) => seen[b]);
|
||
}
|
||
if (!arch) {
|
||
// Signal-over-time history only makes sense for the live database.
|
||
const nowT = Date.now() / 1000;
|
||
aps.forEach((a) => {
|
||
if (a.bssid == null || a.signal == null) return;
|
||
const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
|
||
hist.push({ t: nowT, sig: a.signal });
|
||
while (hist.length > RECON_MAX_HISTORY) hist.shift();
|
||
});
|
||
Object.keys(state.history).forEach((b) => {
|
||
if (!seen[b]) {
|
||
const hist = state.history[b];
|
||
const recent = hist.filter((p) => nowT - p.t < 30);
|
||
if (!recent.length) delete state.history[b];
|
||
else state.history[b] = recent;
|
||
}
|
||
});
|
||
}
|
||
drawCharts(r.data);
|
||
renderTables();
|
||
renderSelection();
|
||
renderCompare();
|
||
renderChannelMap();
|
||
hsCount.textContent = (r.data.handshakes || []).length;
|
||
if (state.scanErr) { state.scanErr = null; renderScanBar(); }
|
||
} catch (err) {
|
||
// A render failure must never brick the page: reset the detail state
|
||
// so the next poll re-fetches and re-renders.
|
||
state.detail = null;
|
||
state.detailId = null;
|
||
state.detailArchive = null;
|
||
state.scanErr = 'Scan data failed to render — retrying…';
|
||
renderScanBar();
|
||
}
|
||
}).catch(() => {
|
||
// Transient failure (recon DB busy / 503 / timeout): keep detailId
|
||
// unset so the next poll retries, and tell the user.
|
||
if (state.selected === scanId && state.archive === arch) {
|
||
state.detail = null;
|
||
state.detailId = null;
|
||
state.detailArchive = null;
|
||
state.scanErr = 'Scan data unavailable — retrying…';
|
||
renderScanBar();
|
||
}
|
||
}).finally(() => {
|
||
state.detailLoading = false;
|
||
state.detailLoadingId = null;
|
||
if (state.detailQueued) {
|
||
state.detailQueued = false;
|
||
loadDetail();
|
||
}
|
||
});
|
||
}
|
||
|
||
function pickFollow(scans) {
|
||
// Prefer the newest scan with real data so a 0-AP restart row does not
|
||
// blank the table while following a live scan.
|
||
const nonEmpty = scans.find((s) => s.aps > 0 || s.devices > 0 || s.handshakes > 0);
|
||
return nonEmpty ? nonEmpty.id : (scans[0] ? scans[0].id : null);
|
||
}
|
||
|
||
function renderPicker() {
|
||
sel.innerHTML = '';
|
||
pickerOptions = [];
|
||
const cur = { archive: state.archive, scanId: state.selected };
|
||
const liveEmpty = (s) => !(s.aps > 0 || s.devices > 0 || s.handshakes > 0);
|
||
state.scans.forEach((s) => {
|
||
pickerOptions.push({ archive: null, scanId: s.id });
|
||
const opt = document.createElement('option');
|
||
opt.value = String(pickerOptions.length - 1);
|
||
opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time) + (liveEmpty(s) ? ' (empty)' : '');
|
||
if (liveEmpty(s)) opt.style.opacity = '0.55';
|
||
sel.appendChild(opt);
|
||
});
|
||
state.archives.forEach((a) => {
|
||
const og = document.createElement('optgroup');
|
||
og.label = 'Archive ·' + (a.max_id ? ' scans 1–' + a.max_id : '') +
|
||
(a.mtime ? ' · ' + fmtTime(a.mtime) : '');
|
||
(a.scans || []).forEach((s) => {
|
||
pickerOptions.push({ archive: a.id, scanId: s.id });
|
||
const opt = document.createElement('option');
|
||
opt.value = String(pickerOptions.length - 1);
|
||
opt.textContent = '#' + s.id + ' — ' + fmtTime(s.time) +
|
||
((s.aps > 0 || s.devices > 0 || s.handshakes > 0) ? '' : ' (empty)');
|
||
og.appendChild(opt);
|
||
});
|
||
sel.appendChild(og);
|
||
});
|
||
const idx = pickerOptions.findIndex((m) => m.archive === cur.archive && m.scanId === cur.scanId);
|
||
if (idx !== -1) sel.value = String(idx);
|
||
delBtn.disabled = state.archive !== null;
|
||
delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan';
|
||
const archCount = state.archives.reduce((m, a) => m + ((a.scans || []).length), 0);
|
||
const total = state.scans.length + archCount;
|
||
psValue.textContent = total ? String(total) : '—';
|
||
const latest = state.scans[0];
|
||
psSub.textContent = total
|
||
? 'Latest: ' + fmtTime(latest ? latest.time : null) + (archCount ? ' · ' + archCount + ' archived' : '')
|
||
: 'No scans recorded yet';
|
||
}
|
||
|
||
let loadPending = false;
|
||
function load() {
|
||
if (loadPending) return;
|
||
loadPending = true;
|
||
PagerAPI.get('/api/recon/scans').then((r) => {
|
||
state.scanErr = null;
|
||
state.scans = r.data.scans || [];
|
||
const newest = state.scans[0] ? state.scans[0].id : null;
|
||
let keep = null;
|
||
if (state.autoFollow) {
|
||
// Following a live run jumps back to the live database.
|
||
state.archive = null;
|
||
state.detailArchive = null;
|
||
keep = pickFollow(state.scans);
|
||
} else if (!state.archive) {
|
||
keep = state.selected && state.scans.some((s) => s.id === state.selected)
|
||
? state.selected : newest;
|
||
}
|
||
renderPicker();
|
||
if (keep == null) {
|
||
if (!state.archive) {
|
||
state.detail = null;
|
||
state.detailId = null;
|
||
state.detailArchive = null;
|
||
drawCharts({ aps: [], clients: [], handshakes: [] });
|
||
renderTables();
|
||
renderSelection();
|
||
renderCompare();
|
||
renderChannelMap();
|
||
hsCount.textContent = '0';
|
||
}
|
||
} else {
|
||
if (keep !== state.selected) {
|
||
state.selected = keep;
|
||
state.detailId = null;
|
||
state.detailArchive = null;
|
||
}
|
||
if (state.selected != null) loadDetail();
|
||
}
|
||
}).catch(() => {
|
||
state.scanErr = 'Scan list unavailable — retrying…';
|
||
renderScanBar();
|
||
}).finally(() => { loadPending = false; });
|
||
PagerAPI.get('/api/recon/status').then((r) => {
|
||
const scanning = !!r.data.scanning;
|
||
const wasScanning = state.scanActive;
|
||
const completed = wasScanning && !scanning;
|
||
state.scanActive = scanning;
|
||
state.scanRemaining = r.data.scan_remaining != null ? r.data.scan_remaining : null;
|
||
state.hopperOnline = r.data.hopper_online;
|
||
state.historyReset = !!r.data.history_reset;
|
||
state.wlan0Pinned = !!r.data.wlan0_pinned;
|
||
state.wlan0Sta = !!r.data.wlan0_sta;
|
||
state.hopperIfaces = r.data.hopper_ifaces || [];
|
||
if (r.data.hopper_warning) state.hopperWarning = r.data.hopper_warning;
|
||
else if (!scanning) state.hopperWarning = null;
|
||
state.hopperHint = r.data.hopper_hint || state.hopperHint;
|
||
if (!pendingScan) scanToggle.checked = scanning;
|
||
renderScanBar();
|
||
if (wasScanning !== scanning) restartPoll();
|
||
if (completed) {
|
||
state.autoFollow = false;
|
||
App.toast('Scan complete');
|
||
}
|
||
}).catch(() => {});
|
||
}
|
||
|
||
function loadSlow() {
|
||
// GPS and archive discovery change rarely; polling them on every 5s tick
|
||
// piles sqlite/iwinfo/gpsd work onto the same cycles as the scan data.
|
||
PagerAPI.get('/api/recon/gps').then((r) => {
|
||
state.gps = r.data;
|
||
state.wigle = !!(r.data || {}).wigle;
|
||
renderPills();
|
||
}).catch(() => {});
|
||
PagerAPI.get('/api/recon/archives').then((r) => {
|
||
state.archives = (r.data && r.data.archives) || [];
|
||
renderPicker();
|
||
}).catch(() => {});
|
||
// Keep the auto-collect toggle in sync with the device's actual
|
||
// loghandshake setting (the pager's own UI can change it).
|
||
PagerAPI.get('/api/pineap/get_config').then((r) => {
|
||
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
load();
|
||
loadSlow();
|
||
let pollIv = null;
|
||
const restartPoll = () => {
|
||
clearInterval(pollIv);
|
||
pollIv = setInterval(load, state.scanActive ? 5000 : 10000);
|
||
};
|
||
restartPoll();
|
||
let slowIv = null;
|
||
const restartSlow = () => {
|
||
clearInterval(slowIv);
|
||
slowIv = setInterval(loadSlow, 30000);
|
||
};
|
||
restartSlow();
|
||
return { destroy: () => { clearInterval(pollIv); clearInterval(slowIv); } };
|
||
};
|
||
|
||
views.recon_reports = (root) => {
|
||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||
tabBar(root, RECON_TABS, '#/recon/reports');
|
||
|
||
const reportCard = h('div', { class: 'section' });
|
||
const wigleCard = h('div', { class: 'section' });
|
||
|
||
function dl(path) { window.location = App.apiBase + path; }
|
||
|
||
function renderScans() {
|
||
reportCard.appendChild(h('h2', { text: 'Scan Reports' }));
|
||
const box = h('div');
|
||
reportCard.appendChild(box);
|
||
let gpsFix = null;
|
||
PagerAPI.get('/api/recon/gps').then((r) => {
|
||
const g = r.data || {};
|
||
gpsFix = g.lock ? { lat: g.lat, lon: g.lon, sats: g.satellites } : null;
|
||
}).catch(() => {}).then(() => {
|
||
PagerAPI.get('/api/recon/scans').then((r) => {
|
||
const scans = (r.data && r.data.scans) || [];
|
||
box.innerHTML = '';
|
||
if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; }
|
||
const gpsCell = (s) => gpsFix
|
||
? h('span', { class: 'recon-gps-cell', text: Number(gpsFix.lat).toFixed(5) + ', ' + Number(gpsFix.lon).toFixed(5) })
|
||
: h('span', { class: 'muted', text: '—' });
|
||
box.appendChild(table(
|
||
[
|
||
{ key: 'id', label: 'Scan', render: (s) => '#' + s.id },
|
||
{ key: 'time', label: 'Started', render: (s) => fmtTime(s.time) },
|
||
{ key: 'gps', label: 'GPS', render: gpsCell },
|
||
{ key: 'aps', label: 'APs' },
|
||
{ key: 'devices', label: 'Clients' },
|
||
{ key: 'handshakes', label: 'Handshakes' },
|
||
{ key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' },
|
||
iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')),
|
||
iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')),
|
||
iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) }
|
||
],
|
||
scans));
|
||
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' })));
|
||
});
|
||
}
|
||
|
||
function renderWigle() {
|
||
wigleCard.appendChild(h('h2', { text: 'WiGLE Uploads' }));
|
||
const box = h('div');
|
||
wigleCard.appendChild(box);
|
||
PagerAPI.get('/api/recon/wigle/files').then((r) => {
|
||
const files = (r.data && r.data.files) || [];
|
||
box.innerHTML = '';
|
||
if (!files.length) { box.appendChild(h('div', { class: 'empty', text: 'No WiGLE files yet. WiGLE logging writes a CSV per capture session.' })); return; }
|
||
box.appendChild(table(
|
||
[
|
||
{ key: 'name', label: 'File', render: (f) => f.name },
|
||
{ key: 'mtime', label: 'Modified', render: (f) => fmtTime(f.mtime) },
|
||
{ key: 'size', label: 'Size', render: (f) => fmtBytes(f.size) },
|
||
{ key: 'rows', label: 'AP rows', render: (f) => f.rows == null ? '--' : f.rows },
|
||
{ key: 'warn', label: '', render: (f) => f.rows === 0 ? h('span', { class: 'wigle-warn', text: 'Header only — no data yet (needs a GPS fix)' }) : h('span', {}) },
|
||
{ key: 'dl', label: 'Download', render: (f) => iconBtn('file_download', 'Download ' + f.name, () => dl('/api/recon/wigle/files/' + encodeURIComponent(f.name))) }
|
||
],
|
||
files));
|
||
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load WiGLE files.' })));
|
||
}
|
||
|
||
root.appendChild(reportCard);
|
||
root.appendChild(wigleCard);
|
||
renderScans();
|
||
renderWigle();
|
||
return { destroy: () => {} };
|
||
};
|
||
|
||
const LOGGING_TABS = [
|
||
{ label: 'PineAP', hash: '#/logging' },
|
||
{ label: 'System', hash: '#/logging/system' }
|
||
];
|
||
|
||
function hsType(name) {
|
||
const n = (name || '').toLowerCase();
|
||
if (/\.(cap|pcap|pcapng)$/.test(n)) return 'PCAP';
|
||
if (/\.(hccapx|hc22000|22000|hccap)$/.test(n)) return 'Hashcat';
|
||
return 'Unknown';
|
||
}
|
||
|
||
views.recon_handshakes = (root) => {
|
||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||
tabBar(root, RECON_TABS, '#/recon/handshakes');
|
||
const box = h('div', { class: 'section recon-handshakes-card' });
|
||
root.appendChild(box);
|
||
|
||
let flashTimer = null;
|
||
function flash(ok, msg) {
|
||
const head = box.querySelector('.recon-table-head');
|
||
if (!head) return;
|
||
const old = head.querySelector('.hs-flash');
|
||
if (old) old.remove();
|
||
const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg });
|
||
head.appendChild(el);
|
||
clearTimeout(flashTimer);
|
||
flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000);
|
||
}
|
||
|
||
function hsIconBtn(name, title, cls, onclk) {
|
||
const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title });
|
||
b.innerHTML = PineappleIcons[name] || '';
|
||
armBusy(b, onclk);
|
||
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) {
|
||
return 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', {}, (function () {
|
||
const archiveBtn = btn('Archive', () => {
|
||
return runAction(archiveBtn, () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')), 'Archiving…');
|
||
});
|
||
return archiveBtn;
|
||
})()),
|
||
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', () => {
|
||
return PagerAPI.del('/api/pineap/handshakes', { name: f.name })
|
||
.then(() => load(() => flash(true, 'Deleted ' + 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() {
|
||
return 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 })),
|
||
(function () {
|
||
const delAll = btn('Delete All Handshakes', () => {
|
||
return runAction(delAll, () => PagerAPI.del('/api/pineap/handshakes/all')
|
||
.then(() => { close(); load(() => flash(true, 'All handshakes deleted')); }), 'Deleting…');
|
||
}, 'danger');
|
||
return delAll;
|
||
})()),
|
||
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 Promise.resolve();
|
||
pending = true;
|
||
return PagerAPI.get('/api/logging/pineap?lines=200').then((r) => {
|
||
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
|
||
render();
|
||
}).catch((e) => {
|
||
lines.splice(0, lines.length, e.message || 'PineAP log unavailable');
|
||
render();
|
||
})
|
||
.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 Promise.resolve();
|
||
pending = true;
|
||
return PagerAPI.get('/api/logging/system?lines=400').then((r) => {
|
||
lines.splice(0, lines.length, ...((r.data || {}).lines || []));
|
||
render();
|
||
}).catch((e) => {
|
||
lines.splice(0, lines.length, e.message || 'System log unavailable');
|
||
render();
|
||
})
|
||
.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) {
|
||
markBusy(button, busy, label);
|
||
}
|
||
|
||
views.modules = (root) => {
|
||
const box = payloadShell(root, '#/modules');
|
||
let alive = true;
|
||
let rows = [];
|
||
const list = h('div', { class: 'payload-list' });
|
||
const status = h('p', { class: 'payload-result-count muted' });
|
||
const filters = payloadFilters(render);
|
||
const refresh = btn('Refresh', load, 'ghost');
|
||
box.appendChild(h('div', { class: 'section' },
|
||
payloadToolbar('Installed Payloads', 'Payloads installed on this Pager. Launch them here or manage their Pager Portal version.', refresh),
|
||
filters.node, status, list));
|
||
|
||
function render() {
|
||
if (!alive) return;
|
||
const q = filters.search.value.trim().toLowerCase();
|
||
const category = filters.category.value;
|
||
const visible = rows.filter((item) => (!category || item.category === category) &&
|
||
(!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1));
|
||
status.textContent = visible.length + ' of ' + rows.length + ' installed';
|
||
list.innerHTML = '';
|
||
if (!visible.length) {
|
||
list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No payloads are installed. Get payloads from the Payloads tab.' }));
|
||
return;
|
||
}
|
||
visible.forEach((item) => {
|
||
const launch = btn('Run', () => runAction(launch, () => PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => {
|
||
App.toast(item.title + ' started');
|
||
location.hash = '#/modules/running';
|
||
}), 'Starting...'));
|
||
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;
|
||
return runAction(remove, () => PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => {
|
||
App.toast(item.title + ' removed'); load();
|
||
}), 'Removing...');
|
||
}, '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) {
|
||
return runAction(button, () => PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
|
||
App.toast(item.title + ' updated'); load();
|
||
}), 'Updating...');
|
||
}
|
||
|
||
function load() {
|
||
return 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'));
|
||
}
|
||
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', () => {
|
||
return PagerAPI.post('/api/payloads/refresh').then(() => {
|
||
App.toast('Pager Portal listings refreshed'); return load();
|
||
});
|
||
});
|
||
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;
|
||
return runAction(install, () => PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => {
|
||
installed.add(item.key); App.toast(item.title + ' installed'); render();
|
||
}), 'Installing...');
|
||
}, 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', () => PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => {
|
||
App.toast(run.title + ' stop requested'); setTimeout(load, 500);
|
||
}), '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 Promise.resolve();
|
||
pending = true;
|
||
return PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || []))
|
||
.catch(apiError('Unable to load payload runs')).finally(() => { pending = 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;
|
||
}
|
||
if (routingSwitch.disabled) return;
|
||
routingSwitch.disabled = true;
|
||
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');
|
||
})
|
||
.finally(() => { routingSwitch.disabled = false; });
|
||
});
|
||
|
||
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() {
|
||
return 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 connectBtn = btn('Connect', () => {
|
||
doConnect({ ssid: net.ssid, encryption: encSel.value,
|
||
password: pw.value, routed: routedCb.checked }, connectBtn);
|
||
}, 'primary');
|
||
const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel,
|
||
connectBtn,
|
||
btn('Cancel', () => renderNetworks(), 'ghost'));
|
||
row.appendChild(form);
|
||
pw.focus();
|
||
}
|
||
|
||
let connectingButton = null;
|
||
function doConnect(opts, button) {
|
||
if (state.connecting) return;
|
||
state.connecting = true;
|
||
connectingButton = button || null;
|
||
setBusy(true);
|
||
if (connectingButton) markBusy(connectingButton, true, 'Connecting…');
|
||
PagerAPI.post('/api/settings/wifi/client/connect', opts)
|
||
.then(() => {
|
||
App.toast('Connecting to ' + opts.ssid + '\u2026');
|
||
state.status = null;
|
||
pollConnected(opts.ssid);
|
||
})
|
||
.catch((e) => {
|
||
clearConnecting();
|
||
App.toast(e.message || 'Failed to connect', 'error');
|
||
refresh();
|
||
});
|
||
}
|
||
|
||
function clearConnecting() {
|
||
state.connecting = false;
|
||
setBusy(false);
|
||
if (connectingButton) {
|
||
markBusy(connectingButton, false);
|
||
connectingButton = null;
|
||
}
|
||
}
|
||
|
||
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(() => {
|
||
clearConnecting();
|
||
App.toast('Failed to check connection status', 'error');
|
||
});
|
||
};
|
||
function finish(message, kind) {
|
||
clearConnecting();
|
||
App.toast(message, kind);
|
||
App.checkInternet(false);
|
||
}
|
||
setTimeout(tick, 2000);
|
||
}
|
||
|
||
function doDisconnect() {
|
||
if (state.connecting) return Promise.resolve();
|
||
state.connecting = true;
|
||
setBusy(true);
|
||
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'))
|
||
.finally(() => { state.connecting = false; setBusy(false); });
|
||
}
|
||
|
||
let scanningNets = false;
|
||
function scan() {
|
||
if (state.connecting || scanningNets) return Promise.resolve();
|
||
scanningNets = true;
|
||
setBusy(true);
|
||
netsEl.textContent = 'Scanning\u2026';
|
||
return 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') }));
|
||
}).finally(() => { scanningNets = false; setBusy(false); });
|
||
}
|
||
|
||
function setBusy(busy) {
|
||
const buttons = actionsEl.querySelectorAll('button');
|
||
buttons.forEach((b) => {
|
||
b.disabled = busy;
|
||
b.classList.toggle('busy', busy);
|
||
if (busy) b.setAttribute('aria-busy', 'true');
|
||
else b.removeAttribute('aria-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)));
|
||
const pwBtn = 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; }
|
||
runAction(pwBtn, () => 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');
|
||
}), 'Updating…');
|
||
});
|
||
user.appendChild(pwBtn);
|
||
|
||
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 tzBtn = btn('Update Timezone', () => {
|
||
const opt = tzSel.options[tzSel.selectedIndex];
|
||
runAction(tzBtn, () => PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' })
|
||
.then(() => App.toast('Timezone updated')), 'Updating…');
|
||
});
|
||
const syncBtn = 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');
|
||
runAction(syncBtn, () => PagerAPI.post('/api/settings/synctime', { timestamp: stamp })
|
||
.then(() => App.toast('Pager time synchronized')), 'Syncing…');
|
||
}, 'ghost');
|
||
const tzActions = h('div', { class: 'settings-actions' }, tzBtn, syncBtn);
|
||
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));
|
||
const ntpSave = btn('Save', () => {
|
||
runAction(ntpSave, () => 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')), 'Saving…');
|
||
});
|
||
ntp.appendChild(ntpSave);
|
||
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'));
|
||
|
||
const overlay = settingsCard(box, 'Mark VIII overlay',
|
||
'PineAP pool contents stay with the Pager daemon. Radio1 APs, hop pause, and enterprise overlays revert to the Pager snapshot when Mark VIII stops.');
|
||
const overlayStatus = h('p', { class: 'muted', text: 'Loading overlay status…' });
|
||
overlay.appendChild(overlayStatus);
|
||
function loadOverlay() {
|
||
PagerAPI.get('/api/mode').then((r) => {
|
||
const d = r.data || {};
|
||
const ov = d.overlay || {};
|
||
overlayStatus.textContent = (d.snapshot ? 'Pager snapshot is captured. ' : 'No snapshot. ') +
|
||
'Radio1 AP ' + (ov.radio1_ap ? 'ON' : 'off') +
|
||
' · Enterprise ' + (ov.enterprise ? 'ON' : 'off') +
|
||
' · Recon hopper ' + (ov.recon_hopper ? 'ON' : 'off') +
|
||
'. Stop the Mark VIII service to make the Pager UI the source of truth.';
|
||
}).catch(apiError('Unable to load overlay status'));
|
||
}
|
||
const restoreBtn = btn('Restore Pager overlays now', () => {
|
||
runAction(restoreBtn, () => PagerAPI.post('/api/mode/release').then(() => {
|
||
App.toast('Pager overlays restored');
|
||
loadOverlay();
|
||
}), 'Restoring…');
|
||
});
|
||
overlay.appendChild(restoreBtn);
|
||
loadOverlay();
|
||
|
||
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; }
|
||
return 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'); });
|
||
});
|
||
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) }; }
|
||
const ledSave = btn('Save', () => {
|
||
runAction(ledSave, () => PagerAPI.post('/api/settings/hardware', values())
|
||
.then(() => App.toast('Pager hardware preferences saved')), 'Saving…');
|
||
});
|
||
card.appendChild(ledSave);
|
||
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));
|
||
const chBtn = btn('Set Update Channel', () => {
|
||
runAction(chBtn, () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value })
|
||
.then(() => App.toast('Update channel saved')), 'Saving…');
|
||
});
|
||
updates.appendChild(chBtn);
|
||
|
||
const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.');
|
||
const hostname = h('input', {});
|
||
host.appendChild(h('label', {}, 'Hostname', hostname));
|
||
const hostSave = btn('Save', () => {
|
||
runAction(hostSave, () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() })
|
||
.then(() => App.toast('Hostname saved')), 'Saving…');
|
||
});
|
||
host.appendChild(hostSave);
|
||
|
||
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', () => {
|
||
output.textContent = 'Generating diagnostics…';
|
||
return PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; })
|
||
.catch((e) => { output.textContent = 'Diagnostics failed.'; throw e; });
|
||
});
|
||
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: () => {} };
|
||
};
|