feat(webui,recon): drop PineAP mode card, sort recon report APs by signal
- Remove the Passive/Active/Advanced mode card from the PineAP dashboard and move the RF Role (radio1) card into its slot, along with the now dead mode state machinery and mode-card-only CSS. - Sort recon HTML report access points by signal strength (strongest first).
This commit is contained in:
@@ -2281,9 +2281,10 @@ def _recon_html_download(scan_id, data, client_count, archive=None):
|
|||||||
body_parts.append(_html_table(['Band', 'Channel', 'Access Points'], chan_rows))
|
body_parts.append(_html_table(['Band', 'Channel', 'Access Points'], chan_rows))
|
||||||
else:
|
else:
|
||||||
body_parts.append('<p class="empty">No access points with a known channel.</p>')
|
body_parts.append('<p class="empty">No access points with a known channel.</p>')
|
||||||
# AP table with color-coded signal.
|
# AP table with color-coded signal, strongest signal first.
|
||||||
ap_rows = []
|
ap_rows = []
|
||||||
for a in aps:
|
for a in sorted(aps, key=lambda row: row.get('signal')
|
||||||
|
if row.get('signal') is not None else -200, reverse=True):
|
||||||
ap_rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
ap_rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
||||||
a.get('band') or '--',
|
a.get('band') or '--',
|
||||||
a.get('channel') if a.get('channel') is not None else '--',
|
a.get('channel') if a.get('channel') is not None else '--',
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
|
|||||||
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
||||||
html.dark .modal { background: #303030; }
|
html.dark .modal { background: #303030; }
|
||||||
|
|
||||||
/* ---- PineAP overview segmented control ---- */
|
/* ---- PineAP segmented control ---- */
|
||||||
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; }
|
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; }
|
||||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||||
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
|
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
|
||||||
@@ -510,11 +510,6 @@ html.dark .modal { background: #303030; }
|
|||||||
.pineap-card-title-link:visited { color: inherit; }
|
.pineap-card-title-link:visited { color: inherit; }
|
||||||
.pineap-card-title-link:hover { text-decoration: underline; }
|
.pineap-card-title-link:hover { text-decoration: underline; }
|
||||||
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
||||||
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
|
|
||||||
.pineap-card-button-group .seg { flex: 1; height: 100%; margin-top: 0; }
|
|
||||||
.pineap-card-button-group .seg-btn { flex: 1; }
|
|
||||||
.pineap-mode-save { display: flex; justify-content: flex-end; margin-top: 10px; }
|
|
||||||
.pineap-mode-features { margin: 6px 0 0; padding-left: 20px; }
|
|
||||||
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
|
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
|
||||||
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
|
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ const PINEAP_TABS = [
|
|||||||
|
|
||||||
// The Pager daemon can change these states but cannot read them back. Keep
|
// The Pager daemon can change these states but cannot read them back. Keep
|
||||||
// them explicitly unknown until this WebUI successfully changes them.
|
// them explicitly unknown until this WebUI successfully changes them.
|
||||||
const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null };
|
const PINEAP_SESSION = { karma: null, advertise: null, collect: null };
|
||||||
|
|
||||||
function setKnownCheckbox(cb, value) {
|
function setKnownCheckbox(cb, value) {
|
||||||
cb.indeterminate = value == null;
|
cb.indeterminate = value == null;
|
||||||
@@ -430,27 +430,6 @@ views.pineap = (root) => {
|
|||||||
});
|
});
|
||||||
box.appendChild(statWrap);
|
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 = {
|
const quick = {
|
||||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||||
@@ -465,11 +444,6 @@ views.pineap = (root) => {
|
|||||||
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
|
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.'));
|
'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', {},
|
const rfSel = h('select', {},
|
||||||
h('option', { value: 'uplink', text: 'Uplink (station)' }),
|
h('option', { value: 'uplink', text: 'Uplink (station)' }),
|
||||||
h('option', { value: 'attack', text: 'Attack' }),
|
h('option', { value: 'attack', text: 'Attack' }),
|
||||||
@@ -523,9 +497,11 @@ views.pineap = (root) => {
|
|||||||
h('label', {}, 'Uplink Password', rfPsk)));
|
h('label', {}, 'Uplink Password', rfPsk)));
|
||||||
rfCard.appendChild(rfApply);
|
rfCard.appendChild(rfApply);
|
||||||
rfCard.appendChild(rfResult);
|
rfCard.appendChild(rfResult);
|
||||||
const rfRow = h('div', { class: 'pineap-title-card-container' });
|
|
||||||
rfRow.appendChild(rfCard);
|
const modeRow = h('div', { class: 'pineap-title-card-container' });
|
||||||
box.appendChild(rfRow);
|
modeRow.appendChild(rfCard);
|
||||||
|
modeRow.appendChild(quickCard);
|
||||||
|
box.appendChild(modeRow);
|
||||||
|
|
||||||
const cards = { karma: {}, open: {}, wpa: {} };
|
const cards = { karma: {}, open: {}, wpa: {} };
|
||||||
const cardWrap = h('div', { class: 'pineap-title-card-container' });
|
const cardWrap = h('div', { class: 'pineap-title-card-container' });
|
||||||
@@ -559,9 +535,6 @@ views.pineap = (root) => {
|
|||||||
}
|
}
|
||||||
function rememberAdvanced(key, value) {
|
function rememberAdvanced(key, value) {
|
||||||
PINEAP_SESSION[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 }),
|
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }),
|
||||||
(v) => rememberAdvanced('collect', v));
|
(v) => rememberAdvanced('collect', v));
|
||||||
@@ -569,80 +542,16 @@ views.pineap = (root) => {
|
|||||||
(v) => rememberAdvanced('advertise', v));
|
(v) => rememberAdvanced('advertise', v));
|
||||||
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
|
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;
|
let loadPending = false;
|
||||||
function load() {
|
function load() {
|
||||||
if (loadPending) return;
|
if (loadPending) return;
|
||||||
loadPending = true;
|
loadPending = true;
|
||||||
const stateRequest = Promise.all([
|
const stateRequest = Promise.all([
|
||||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
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.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
|
]).then(([cfg, ap]) => {
|
||||||
PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} }))
|
const c = cfg.data || {}, a = ap.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 || {};
|
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'
|
const collect = typeof PINEAP_SESSION.collect === 'boolean'
|
||||||
? PINEAP_SESSION.collect
|
? PINEAP_SESSION.collect
|
||||||
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
|
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
|
||||||
|
|||||||
@@ -1164,6 +1164,16 @@ class ReconReportTest(unittest.TestCase):
|
|||||||
self.assertIn('AE:77:C0:EB:31:41', text)
|
self.assertIn('AE:77:C0:EB:31:41', text)
|
||||||
self.assertIn('handshake', text)
|
self.assertIn('handshake', text)
|
||||||
self.assertNotIn('ProbeOnlySSID', text)
|
self.assertNotIn('ProbeOnlySSID', text)
|
||||||
|
def test_html_download_orders_strongest_signal_first(self):
|
||||||
|
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
|
||||||
|
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
text = payload.data.decode('utf-8')
|
||||||
|
# Fixture: hidden AP at -64 dBm is stronger than Anderson-5 at -76 dBm.
|
||||||
|
strong = '50:6F:9A:01:00:00'
|
||||||
|
weak = 'C8:9E:43:64:80:80'
|
||||||
|
self.assertLess(text.index(strong), text.index(weak))
|
||||||
|
|
||||||
def test_html_report_includes_gps_when_locked(self):
|
def test_html_report_includes_gps_when_locked(self):
|
||||||
with mock.patch.object(server, '_gps_status_data',
|
with mock.patch.object(server, '_gps_status_data',
|
||||||
return_value={'lock': True, 'lat': 37.7,
|
return_value={'lock': True, 'lat': 37.7,
|
||||||
|
|||||||
Reference in New Issue
Block a user