|
|
|
@@ -1,6 +1,20 @@
|
|
|
|
|
'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) {
|
|
|
|
@@ -985,6 +999,31 @@ function attackLauncher(kind, opts) {
|
|
|
|
|
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', {}, btn('Deploy Attack', () => {
|
|
|
|
|
const body = {
|
|
|
|
@@ -1372,6 +1411,14 @@ function reconEncBucket(enc) {
|
|
|
|
|
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;
|
|
|
|
@@ -1403,65 +1450,86 @@ views.recon = (root) => {
|
|
|
|
|
apBand: 'all', apEnc: 'all', gps: null, wigle: null,
|
|
|
|
|
compare: [], history: {}, mapBand: null,
|
|
|
|
|
archive: null, archives: [], scanRemaining: null,
|
|
|
|
|
hopperOnline: null, historyReset: false };
|
|
|
|
|
hopperOnline: null, historyReset: false, scanErr: null };
|
|
|
|
|
const cols = reconLoadCols();
|
|
|
|
|
|
|
|
|
|
// ---- title cards ----
|
|
|
|
|
// ---- 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 wrap = h('div', { class: 'recon-title-card' });
|
|
|
|
|
const card = h('div', { class: 'recon-card' });
|
|
|
|
|
wrap.appendChild(card);
|
|
|
|
|
card.appendChild(link
|
|
|
|
|
? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText })
|
|
|
|
|
: h('div', { class: 'recon-title-card-title', text: titleText }));
|
|
|
|
|
const content = h('div', { class: 'recon-title-card-content' });
|
|
|
|
|
card.appendChild(content);
|
|
|
|
|
cardWrap.appendChild(wrap);
|
|
|
|
|
return content;
|
|
|
|
|
const 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 landContent = titleCard('Wireless Landscape', false);
|
|
|
|
|
const landBody = titleCard('Wireless Landscape', null);
|
|
|
|
|
const landValue = h('div', { class: 'recon-card-value', text: '—' });
|
|
|
|
|
const landSub = h('div', { class: 'recon-card-sub', text: '' });
|
|
|
|
|
landBody.appendChild(landValue);
|
|
|
|
|
landBody.appendChild(landSub);
|
|
|
|
|
const landBox = h('div', { class: 'recon-chart-box' });
|
|
|
|
|
landContent.appendChild(landBox);
|
|
|
|
|
landBody.appendChild(landBox);
|
|
|
|
|
const landCanvas = h('canvas', { id: 'recon-landscape' });
|
|
|
|
|
landBox.appendChild(landCanvas);
|
|
|
|
|
const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' });
|
|
|
|
|
const landEmpty = h('div', { class: 'recon-no-data', text: 'No landscape data yet — run a scan.' });
|
|
|
|
|
landBox.appendChild(landEmpty);
|
|
|
|
|
|
|
|
|
|
const chanContent = titleCard('Channel Distribution', false);
|
|
|
|
|
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' });
|
|
|
|
|
chanContent.appendChild(chanBox);
|
|
|
|
|
chanBody.appendChild(chanBox);
|
|
|
|
|
const chanCanvas = h('canvas', { id: 'recon-channel' });
|
|
|
|
|
chanBox.appendChild(chanCanvas);
|
|
|
|
|
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' });
|
|
|
|
|
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel data yet — run a scan.' });
|
|
|
|
|
chanBox.appendChild(chanEmpty);
|
|
|
|
|
|
|
|
|
|
const encContent = titleCard('Encryption Landscape', false);
|
|
|
|
|
const encBody = titleCard('Encryption Landscape', null);
|
|
|
|
|
const encValue = h('div', { class: 'recon-card-value', text: '—' });
|
|
|
|
|
const encSub = h('div', { class: 'recon-card-sub', text: '' });
|
|
|
|
|
encBody.appendChild(encValue);
|
|
|
|
|
encBody.appendChild(encSub);
|
|
|
|
|
const encBox = h('div', { class: 'recon-chart-box' });
|
|
|
|
|
encContent.appendChild(encBox);
|
|
|
|
|
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 is available yet.' });
|
|
|
|
|
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
|
|
|
|
|
encBox.appendChild(encEmpty);
|
|
|
|
|
|
|
|
|
|
const hsContent = titleCard('Handshakes', true);
|
|
|
|
|
const hsCol = h('div', { class: 'recon-hs-col' });
|
|
|
|
|
hsContent.appendChild(hsCol);
|
|
|
|
|
const hsBody = titleCard('Handshakes', '#/recon/handshakes');
|
|
|
|
|
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
|
|
|
|
|
hsCol.appendChild(hsCount);
|
|
|
|
|
hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' }));
|
|
|
|
|
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' }), ' Automatically Collect Any Handshakes');
|
|
|
|
|
h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Auto-collect handshakes');
|
|
|
|
|
hsAuto.querySelector('input').addEventListener('change', () => {
|
|
|
|
|
PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked })
|
|
|
|
|
.then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error'));
|
|
|
|
|
const requested = hsAuto.querySelector('input').checked;
|
|
|
|
|
PagerAPI.post('/api/pineap/set_config', { loghandshake: requested })
|
|
|
|
|
.then(() => App.toast('Settings saved'))
|
|
|
|
|
.catch(() => {
|
|
|
|
|
hsAuto.querySelector('input').checked = !requested;
|
|
|
|
|
App.toast('Failed to save', 'error');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
hsCol.appendChild(hsAuto);
|
|
|
|
|
hsBody.appendChild(hsAuto);
|
|
|
|
|
|
|
|
|
|
const psContent = titleCard('Previous Scans', false);
|
|
|
|
|
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', () => {
|
|
|
|
@@ -1473,6 +1541,9 @@ views.recon = (root) => {
|
|
|
|
|
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
|
|
|
|
@@ -1508,16 +1579,11 @@ views.recon = (root) => {
|
|
|
|
|
})
|
|
|
|
|
.catch(() => App.toast('Delete failed', 'error'));
|
|
|
|
|
});
|
|
|
|
|
const psActions = h('div', { class: 'row', style: 'margin:6px 0 8px' });
|
|
|
|
|
psActions.appendChild(dlJson);
|
|
|
|
|
psActions.appendChild(dlCsv);
|
|
|
|
|
psActions.appendChild(dlHtml);
|
|
|
|
|
psActions.appendChild(delBtn);
|
|
|
|
|
psActions.appendChild(delAllBtn);
|
|
|
|
|
psContent.appendChild(psActions);
|
|
|
|
|
const psRow = h('div', { class: 'recon-ps-row' });
|
|
|
|
|
psContent.appendChild(psRow);
|
|
|
|
|
psRow.appendChild(sel);
|
|
|
|
|
|
|
|
|
|
// ---- scan bar ----
|
|
|
|
|
const scanBar = h('div', { class: 'section recon-scan-bar' });
|
|
|
|
@@ -1586,8 +1652,9 @@ views.recon = (root) => {
|
|
|
|
|
}
|
|
|
|
|
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)');
|
|
|
|
|
if (state.scanErr) bits.push(state.scanErr);
|
|
|
|
|
scanStatus.textContent = bits.join(' · ');
|
|
|
|
|
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset);
|
|
|
|
|
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || !!state.scanErr);
|
|
|
|
|
}
|
|
|
|
|
scanToggle.addEventListener('change', () => {
|
|
|
|
|
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
|
|
|
|
@@ -1670,34 +1737,63 @@ views.recon = (root) => {
|
|
|
|
|
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', () => {
|
|
|
|
|
capture.disabled = true;
|
|
|
|
|
PagerAPI.post('/api/pineap/set_config', { loghandshake: true })
|
|
|
|
|
.then(() => App.toast('Handshake capture enabled (device-wide on Pager)'))
|
|
|
|
|
.catch(() => App.toast('Failed to enable handshake capture', 'error'));
|
|
|
|
|
.then(() => {
|
|
|
|
|
App.toast('Handshake capture enabled (device-wide on Pager)');
|
|
|
|
|
if (hsAuto) hsAuto.querySelector('input').checked = true;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => App.toast('Failed to enable handshake capture', 'error'))
|
|
|
|
|
.finally(() => { capture.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
actions.appendChild(capture);
|
|
|
|
|
const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' });
|
|
|
|
|
stopHs.addEventListener('click', () => {
|
|
|
|
|
stopHs.disabled = true;
|
|
|
|
|
PagerAPI.post('/api/pineap/set_config', { loghandshake: false })
|
|
|
|
|
.then(() => App.toast('Handshake capture disabled'))
|
|
|
|
|
.catch(() => App.toast('Failed to disable handshake capture', 'error'));
|
|
|
|
|
.then(() => {
|
|
|
|
|
App.toast('Handshake capture disabled');
|
|
|
|
|
if (hsAuto) hsAuto.querySelector('input').checked = false;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => App.toast('Failed to disable handshake capture', 'error'))
|
|
|
|
|
.finally(() => { stopHs.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
actions.appendChild(stopHs);
|
|
|
|
|
const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' });
|
|
|
|
|
exB.addEventListener('click', () => {
|
|
|
|
|
if (!ap.bssid) return;
|
|
|
|
|
exB.disabled = true;
|
|
|
|
|
PagerAPI.post('/api/recon/examine', { bssid: ap.bssid })
|
|
|
|
|
.then(() => App.toast('Examining ' + ap.bssid))
|
|
|
|
|
.catch(() => App.toast('Examine failed', 'error'));
|
|
|
|
|
.then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen'))
|
|
|
|
|
.catch(() => App.toast('Examine failed', 'error'))
|
|
|
|
|
.finally(() => { exB.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
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; }
|
|
|
|
|
exC.disabled = true;
|
|
|
|
|
PagerAPI.post('/api/recon/examine', { channel: ap.channel })
|
|
|
|
|
.then(() => App.toast('Examining channel ' + ap.channel))
|
|
|
|
|
.catch(() => App.toast('Examine failed', 'error'));
|
|
|
|
|
.then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen'))
|
|
|
|
|
.catch(() => App.toast('Examine failed', 'error'))
|
|
|
|
|
.finally(() => { exC.disabled = false; });
|
|
|
|
|
});
|
|
|
|
|
actions.appendChild(exC);
|
|
|
|
|
|
|
|
|
@@ -1798,7 +1894,7 @@ views.recon = (root) => {
|
|
|
|
|
const mapCard = h('div', { class: 'section recon-map-card' });
|
|
|
|
|
root.appendChild(mapCard);
|
|
|
|
|
mapCard.appendChild(h('h2', { text: 'Channel Map' }));
|
|
|
|
|
mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz.' }));
|
|
|
|
|
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' });
|
|
|
|
@@ -1807,6 +1903,61 @@ views.recon = (root) => {
|
|
|
|
|
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 || {};
|
|
|
|
@@ -1853,6 +2004,8 @@ views.recon = (root) => {
|
|
|
|
|
if (!vis.length || !hasChan) {
|
|
|
|
|
mapCanvas.classList.add('hidden');
|
|
|
|
|
mapEmpty.classList.remove('hidden');
|
|
|
|
|
mapTip.classList.add('hidden');
|
|
|
|
|
mapTipPinned = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
mapEmpty.classList.add('hidden');
|
|
|
|
@@ -2078,64 +2231,94 @@ views.recon = (root) => {
|
|
|
|
|
: all;
|
|
|
|
|
const n = aps.length;
|
|
|
|
|
const c = (d.clients || []).length;
|
|
|
|
|
const land = document.getElementById('recon-landscape');
|
|
|
|
|
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
|
|
|
|
if (n > 0) {
|
|
|
|
|
const segs = state.compare.length
|
|
|
|
|
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
|
|
|
|
|
: [
|
|
|
|
|
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
|
|
|
|
|
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
|
|
|
|
|
{ label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
|
|
|
|
|
];
|
|
|
|
|
MiniChart.doughnut(land, segs, { legend: true, height: 130 });
|
|
|
|
|
land.classList.remove('hidden');
|
|
|
|
|
landEmpty.classList.add('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
land.classList.add('hidden');
|
|
|
|
|
landEmpty.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const counts = {};
|
|
|
|
|
const un = d.unassociated || 0;
|
|
|
|
|
|
|
|
|
|
// Card headline values (compare-aware, matching the charts below).
|
|
|
|
|
landValue.textContent = n;
|
|
|
|
|
landSub.textContent = n
|
|
|
|
|
? c + ' client' + (c === 1 ? '' : 's') + ' · ' + un + ' unassociated'
|
|
|
|
|
: '';
|
|
|
|
|
const chCounts = {};
|
|
|
|
|
aps.forEach((a) => {
|
|
|
|
|
const ch = a.channel == null ? '?' : a.channel;
|
|
|
|
|
counts[ch] = (counts[ch] || 0) + 1;
|
|
|
|
|
chCounts[ch] = (chCounts[ch] || 0) + 1;
|
|
|
|
|
});
|
|
|
|
|
const keys = Object.keys(counts).sort((a, b) => {
|
|
|
|
|
if (a === '?') return 1;
|
|
|
|
|
if (b === '?') return -1;
|
|
|
|
|
return Number(a) - Number(b);
|
|
|
|
|
const chKeys = Object.keys(chCounts);
|
|
|
|
|
let busiest = null, busiestN = 0;
|
|
|
|
|
chKeys.forEach((k) => {
|
|
|
|
|
if (k === '?') return;
|
|
|
|
|
if (chCounts[k] > busiestN) { busiest = k; busiestN = chCounts[k]; }
|
|
|
|
|
});
|
|
|
|
|
const ch = document.getElementById('recon-channel');
|
|
|
|
|
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
|
|
|
|
|
if (keys.length) {
|
|
|
|
|
MiniChart.bar(ch, keys.map((k, i) => ({
|
|
|
|
|
label: k, value: counts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
|
|
|
|
|
})), { height: 130 });
|
|
|
|
|
ch.classList.remove('hidden');
|
|
|
|
|
chanEmpty.classList.add('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
ch.classList.add('hidden');
|
|
|
|
|
chanEmpty.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
let topEnc = null, topEncN = 0;
|
|
|
|
|
Object.keys(encCounts).forEach((k) => {
|
|
|
|
|
if (encCounts[k] > topEncN) { topEnc = k; topEncN = encCounts[k]; }
|
|
|
|
|
});
|
|
|
|
|
encValue.textContent = topEnc || '—';
|
|
|
|
|
encSub.textContent = topEnc ? topEncN + ' of ' + n + ' APs' : '';
|
|
|
|
|
|
|
|
|
|
const land = document.getElementById('recon-landscape');
|
|
|
|
|
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
|
|
|
|
try {
|
|
|
|
|
if (n > 0) {
|
|
|
|
|
const segs = state.compare.length
|
|
|
|
|
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
|
|
|
|
|
: [
|
|
|
|
|
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
|
|
|
|
|
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
|
|
|
|
|
{ label: 'Unassociated', value: un, color: RECON_LANDSCAPE_COLORS[2] }
|
|
|
|
|
];
|
|
|
|
|
MiniChart.doughnut(land, segs, { legend: false, height: 90 });
|
|
|
|
|
land.classList.remove('hidden');
|
|
|
|
|
landEmpty.classList.add('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
land.classList.add('hidden');
|
|
|
|
|
landEmpty.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
|
if (aps.length) {
|
|
|
|
|
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
|
|
|
|
|
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
|
|
|
|
|
})), { legend: true, height: 130 });
|
|
|
|
|
enc.classList.remove('hidden');
|
|
|
|
|
encEmpty.classList.add('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
enc.classList.add('hidden');
|
|
|
|
|
encEmpty.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (aps.length) {
|
|
|
|
|
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
|
|
|
|
|
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
|
|
|
|
|
})), { legend: true, height: 66 });
|
|
|
|
|
enc.classList.remove('hidden');
|
|
|
|
|
encEmpty.classList.add('hidden');
|
|
|
|
|
} else {
|
|
|
|
|
enc.classList.add('hidden');
|
|
|
|
|
encEmpty.classList.remove('hidden');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2158,42 +2341,63 @@ views.recon = (root) => {
|
|
|
|
|
state.detailLoadingId = scanId;
|
|
|
|
|
PagerAPI.get(detailUrl()).then((r) => {
|
|
|
|
|
if (state.selected !== scanId || state.archive !== arch) return;
|
|
|
|
|
const isNewScan = state.detailId !== scanId || state.detailArchive !== arch;
|
|
|
|
|
state.detail = r.data;
|
|
|
|
|
state.detailId = scanId;
|
|
|
|
|
state.detailArchive = arch;
|
|
|
|
|
if (isNewScan) state.history = {};
|
|
|
|
|
const aps = r.data.aps || [];
|
|
|
|
|
const seen = {};
|
|
|
|
|
aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; });
|
|
|
|
|
if (state.compare.some((b) => !seen[b])) {
|
|
|
|
|
state.compare = state.compare.filter((b) => seen[b]);
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}).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();
|
|
|
|
|
}
|
|
|
|
|
drawCharts(r.data);
|
|
|
|
|
renderTables();
|
|
|
|
|
renderSelection();
|
|
|
|
|
renderCompare();
|
|
|
|
|
renderChannelMap();
|
|
|
|
|
hsCount.textContent = (r.data.handshakes || []).length;
|
|
|
|
|
}).catch(() => {}).finally(() => {
|
|
|
|
|
}).finally(() => {
|
|
|
|
|
state.detailLoading = false;
|
|
|
|
|
state.detailLoadingId = null;
|
|
|
|
|
if (state.detailQueued) {
|
|
|
|
@@ -2241,10 +2445,21 @@ views.recon = (root) => {
|
|
|
|
|
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;
|
|
|
|
@@ -2278,7 +2493,10 @@ views.recon = (root) => {
|
|
|
|
|
}
|
|
|
|
|
if (state.selected != null) loadDetail();
|
|
|
|
|
}
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
}).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;
|
|
|
|
@@ -2309,11 +2527,13 @@ views.recon = (root) => {
|
|
|
|
|
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(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
PagerAPI.get('/api/pineap/get_config').then((r) => {
|
|
|
|
|
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
load();
|
|
|
|
|
loadSlow();
|
|
|
|
|
let pollIv = null;
|
|
|
|
|