'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'); }); 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'; }).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 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'; }) ]; 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) => a && a.ssid).filter(Boolean) .filter((ssid, i, all) => all.indexOf(ssid) === i).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 = ''; 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 = ''; 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: () => {} }; };