'use strict'; const views = {}; const h = (tag, attrs, ...children) => { const n = document.createElement(tag); if (attrs) { for (const k in attrs) { if (k === 'class') n.className = attrs[k]; else if (k === 'text') n.textContent = attrs[k]; else if (k === 'html') n.innerHTML = attrs[k]; else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]); else n.setAttribute(k, attrs[k]); } } for (const c of children) { if (c == null) continue; n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); } return n; }; const table = (columns, rows, rowAttrs) => { const t = h('table', { class: 'tbl' }); const thead = h('thead'), tr = h('tr'); columns.forEach((c) => tr.appendChild(h('th', { text: c.label }))); thead.appendChild(tr); t.appendChild(thead); const tb = h('tbody'); (rows || []).forEach((r) => { const trr = h('tr', rowAttrs ? rowAttrs(r) : {}); columns.forEach((c) => trr.appendChild(h('td', { text: c.render ? c.render(r) : r[c.key] }))); tb.appendChild(trr); }); t.appendChild(tb); return t; }; const fmtTime = (ts) => { if (!ts) return '--'; const d = new Date(ts * 1000); return d.toLocaleString(); }; const fmtDur = (secs) => { if (secs == null) return '--'; const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600), mm = Math.floor((secs % 3600) / 60); return (d ? d + 'd ' : '') + hh + 'h ' + mm + 'm'; }; const fmtBytes = (bytes) => { if (bytes == null || !isFinite(Number(bytes))) return '--'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = Number(bytes), unit = 0; while (Math.abs(value) >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return value.toFixed(unit < 2 ? 0 : 1) + ' ' + units[unit]; }; const downloadText = (filename, text) => { const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); const a = h('a', { href: url, download: filename }); document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 0); }; const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' }); const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label }); const iconBtn = (name, title, onclk) => { const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk }); b.innerHTML = PineappleIcons[name] || ''; return b; }; const tabBar = (box, items, activeHash) => { const bar = h('div', { class: 'tabbar' }); items.forEach((it) => { const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label }); t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); bar.appendChild(t); }); box.appendChild(bar); return bar; }; views.dashboard = (root) => { const history = { clients: [] }; const max = 60; const title = h('h1', { class: 'page-title', text: 'Dashboard' }); root.appendChild(title); const grid = h('div', { class: 'cards' }); root.appendChild(grid); const defs = [ ['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'], ['disk', 'Disk Usage'], ['uptime', 'Uptime'] ]; const cards = {}; defs.forEach(([k, label]) => { const card = h('div', { class: 'card' }, h('div', { class: 'card-label', text: label }), h('div', { class: 'card-value', text: 'Loading…' })); grid.appendChild(card); cards[k] = card.querySelector('.card-value'); }); const chartBox = h('div', { class: 'section' }, h('h2', {}, 'Clients'), h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' })); root.appendChild(chartBox); const canvas = chartBox.querySelector('#dash-chart'); const update = (msg) => { const s = msg.status || {}; const n = (msg.clients || []).length; history.clients.push(n); if (history.clients.length > max) { history.clients.shift(); } cards.clients.textContent = n; cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime); cards.disk.textContent = s.disk && s.disk.size != null ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'; if (typeof MiniChart !== 'undefined') { MiniChart.draw(canvas, [ { label: 'Clients', color: '#1976d2', points: history.clients } ]); } }; const unsubscribe = Live.onTick(update); PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients })) .catch(() => { cards.clients.textContent = 'Unavailable'; cards.disk.textContent = 'Unavailable'; cards.uptime.textContent = 'Unavailable'; }); PagerAPI.get('/api/pineap/handshakes').then((r) => { cards.handshakes.textContent = (r.data.files || []).length; }).catch(() => { cards.handshakes.textContent = 'Unavailable'; }); const clBody = h('div', {}); const clBox = h('div', { class: 'section' }, h('h2', {}, 'Connected Clients'), clBody); root.appendChild(clBox); const hsBody = h('div', {}); const hsBox = h('div', { class: 'section' }, h('h2', {}, 'Captured WPA Handshakes'), hsBody); root.appendChild(hsBox); function loadClients() { PagerAPI.get('/api/pineap/clients').then((r) => { clBody.innerHTML = ''; clBody.appendChild(table( [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }], r.data.clients, (c) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } }))); const cols = ['MAC', 'Interface', 'RSSI']; clBody.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; }); }).catch(() => { clBody.textContent = 'Unable to load connected clients.'; }); } function loadHandshakes() { PagerAPI.get('/api/pineap/handshakes').then((r) => { hsBody.innerHTML = ''; hsBody.appendChild(table( [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }], (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })))); }).catch(() => { hsBody.textContent = 'Unable to load captured handshakes.'; }); } loadClients(); loadHandshakes(); const iv = setInterval(loadClients, 10000); return { destroy: () => { clearInterval(iv); unsubscribe(); } }; }; const PINEAP_TABS = [ { label: 'PineAP', hash: '#/pineap' }, { label: 'Open AP', hash: '#/pineap/open' }, { label: 'Evil WPA', hash: '#/pineap/evilwpa' }, { label: 'Impersonation', hash: '#/pineap/impersonation' }, { label: 'Clients', hash: '#/pineap/clients' }, { label: 'Filtering', hash: '#/pineap/filtering' } ]; // The Pager daemon can change these states but cannot read them back. Keep // them explicitly unknown until this WebUI successfully changes them. const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null }; function setKnownCheckbox(cb, value) { cb.indeterminate = value == null; if (value != null) cb.checked = !!value; } function pineapShell(root, activeHash) { root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' })); tabBar(root, PINEAP_TABS, activeHash); const box = h('div', {}); root.appendChild(box); return box; } function pineapTitleCard(titleText, linkHash, valueNode) { const title = linkHash ? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText) : titleText; return h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, title), h('div', { class: 'pineap-card-title-content' }, valueNode)); } views.pineap = (root) => { const box = pineapShell(root, '#/pineap'); const stats = {}; const statWrap = h('div', { class: 'pineap-title-card-container' }); const statDefs = [ ['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'], ['clients', 'Clients Connected', '#/pineap/clients'], ['handshakes', 'Handshakes Captured', '#/pineap/evilwpa'] ]; statDefs.forEach(([k, label, hash]) => { const val = h('span', { text: '—' }); stats[k] = val; statWrap.appendChild(pineapTitleCard(label, hash, val)); }); box.appendChild(statWrap); const mode = h('span', { class: 'badge', text: '—' }); let selectedMode = 'unknown'; let modeDirty = false; let modePending = false; const segBtns = {}; const modeBar = h('div', { class: 'seg' }); ['passive', 'active', 'advanced'].forEach((m) => { const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) }); b.addEventListener('click', () => selectMode(m, true)); modeBar.appendChild(b); segBtns[m] = b; }); const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }); const saveModeBtn = btn('Save Mode', saveMode, 'ghost'); saveModeBtn.disabled = true; const modeCard = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title-flex' }, mode), h('div', { class: 'pineap-card-button-group' }, modeBar), modeInfo, h('div', { class: 'pineap-mode-save' }, saveModeBtn)); const quick = { collect: h('input', { type: 'checkbox', id: 'po-collect' }), advertise: h('input', { type: 'checkbox', id: 'po-advertise' }) }; const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' }, h('div', { class: 'pineap-card-title' }, 'Quick Settings')); quickCard.appendChild(h('label', { class: 'switch' }, quick.collect, h('span', { class: 'track' }), 'Capture SSIDs to Pool')); quickCard.appendChild(h('label', { class: 'switch' }, quick.advertise, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool')); quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }, 'Client connect/disconnect notifications are handled by the Pager alert payload system.')); const modeRow = h('div', { class: 'pineap-title-card-container' }); modeRow.appendChild(modeCard); modeRow.appendChild(quickCard); box.appendChild(modeRow); const cards = { karma: {}, open: {}, wpa: {} }; const cardWrap = h('div', { class: 'pineap-title-card-container' }); const statusDefs = [ ['karma', 'Karma', '#/pineap/open'], ['open', 'Open Network', '#/pineap/open'], ['wpa', 'Evil WPA', '#/pineap/evilwpa'] ]; statusDefs.forEach(([k, label, hash]) => { const val = h('span', { text: '—' }); cards[k].value = val; cardWrap.appendChild(pineapTitleCard(label, hash, val)); }); box.appendChild(cardWrap); function bind(cb, on, remember) { cb.addEventListener('change', () => { const requested = cb.checked; cb.indeterminate = false; on(requested).then(() => { if (remember) remember(requested); load(); }).catch(() => { cb.checked = !requested; load(); App.toast('Failed', 'error'); }); }); } function rememberAdvanced(key, value) { PINEAP_SESSION[key] = value; PINEAP_SESSION.mode = 'advanced'; modeDirty = false; selectMode('advanced', false); } bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }), (v) => rememberAdvanced('collect', v)); bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }), (v) => rememberAdvanced('advertise', v)); setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); function renderModeInfo(m) { modeInfo.innerHTML = ''; if (m === 'unknown') { modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.'; return; } const descriptions = { passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'], active: ['Capture SSIDs to the impersonation pool', 'Broadcast the impersonation pool', 'Enable the PineAP response engine'] }; if (m === 'advanced') { modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.'; return; } modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' })); const list = h('ul', { class: 'pineap-mode-features' }); descriptions[m].forEach((text) => list.appendChild(h('li', { text }))); modeInfo.appendChild(list); } function selectMode(m, dirty) { selectedMode = m; if (dirty) modeDirty = true; Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m)); mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1); mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on'); renderModeInfo(m); saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown'; } function saveMode() { if (!modeDirty || modePending || selectedMode === 'unknown') return; modePending = true; saveModeBtn.disabled = true; saveModeBtn.classList.add('busy'); PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => { const state = r.data || {}; PINEAP_SESSION.mode = state.mode || selectedMode; ['karma', 'advertise', 'collect'].forEach((key) => { if (typeof state[key] === 'boolean') PINEAP_SESSION[key] = state[key]; }); modeDirty = false; selectMode(PINEAP_SESSION.mode, false); App.toast('Mode: ' + PINEAP_SESSION.mode[0].toUpperCase() + PINEAP_SESSION.mode.slice(1)); load(); }).catch(() => { App.toast('Failed to save PineAP mode', 'error'); }) .finally(() => { modePending = false; saveModeBtn.classList.remove('busy'); saveModeBtn.disabled = !modeDirty; }); } let loadPending = false; function load() { if (loadPending) return; loadPending = true; const stateRequest = Promise.all([ PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })), PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} })) ]).then(([cfg, host, ap, preset]) => { const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {}; const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null; const wpa = a.wpa || {}; PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown'; ['karma', 'advertise', 'collect'].forEach((key) => { if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key]; }); if (!modeDirty) selectMode(PINEAP_SESSION.mode, false); mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown'); const collect = typeof PINEAP_SESSION.collect === 'boolean' ? PINEAP_SESSION.collect : Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null; setKnownCheckbox(quick.collect, collect); const pool = a.pool || {}; if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false; setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); cards.karma.value.textContent = PINEAP_SESSION.karma == null ? 'Unknown' : (PINEAP_SESSION.karma ? 'On' : 'Off'); const open = a.open || {}; cards.open.value.textContent = open.enabled == null ? 'Unavailable' : (open.enabled ? 'On' : 'Off'); cards.wpa.value.textContent = wpa.enabled == null ? 'Unavailable' : (wpa.enabled ? 'On' : 'Off'); }); const statRequests = [ PagerAPI.get('/api/pineap/ssids').then((ss) => { stats.ssids.textContent = Array.isArray((ss.data || {}).ssids) ? ss.data.ssids.length : 'Unavailable'; }).catch(() => { stats.ssids.textContent = 'Unavailable'; }), PagerAPI.get('/api/pineap/clients').then((cl) => { stats.clients.textContent = typeof (cl.data || {}).count === 'number' ? cl.data.count : 'Unavailable'; }).catch(() => { stats.clients.textContent = 'Unavailable'; }), PagerAPI.get('/api/pineap/handshakes').then((hs) => { stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable'; }).catch(() => { stats.handshakes.textContent = 'Unavailable'; }) ]; Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; const OPEN_CHANNELS = Array.from({ length: 11 }, (_, i) => { const c = i + 1; return [c, 'Channel ' + c + ' (' + (2412 + (c - 1) * 5) + ' MHz)']; }); const OPEN_COUNTRIES = [ ['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'], ['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'], ['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'], ['CR', 'Costa Rica'], ['CY', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'], ['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'], ['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'], ['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'], ['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'], ['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'], ['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'], ['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'], ['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'], ['PL', 'Poland'], ['PT', 'Portugal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'], ['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'], ['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'], ['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'], ['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'], ['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uruguay'], ['VE', 'Venezuela'], ['VN', 'Vietnam'] ]; views.pineap_open = (root) => { const box = pineapShell(root, '#/pineap/open'); const card = h('div', { class: 'pineap-title-card' }); box.appendChild(card); card.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAP Open Access Point')); const subtitle = h('div', { class: 'pineap-card-subtitle' }); card.appendChild(subtitle); const ssidIn = h('input', { id: 'oa-ssid' }); const bssidIn = h('input', { id: 'oa-bssid' }); const chSel = h('select', { id: 'oa-channel' }); OPEN_CHANNELS.forEach(([v, l]) => chSel.appendChild(h('option', { value: v, text: l }))); const coSel = h('select', { id: 'oa-country' }); OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l }))); const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' }); const karmaCb = h('input', { type: 'checkbox', id: 'oa-karma' }); let karmaDirty = false; karmaCb.addEventListener('change', () => { karmaDirty = true; karmaCb.indeterminate = false; render(); }); card.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'Open SSID', ssidIn)), h('div', {}, h('label', {}, 'BSSID', bssidIn)))); card.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'Channel', chSel)), h('div', {}, h('label', {}, 'Current Country', coSel)))); card.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')), h('div', {}, h('label', { class: 'switch' }, karmaCb, h('span', { class: 'track' }), ' Respond to all probe requests (impersonate all networks)')))); const info = h('div', { class: 'muted', style: 'margin-top:10px;font-size:13px' }); card.appendChild(info); const boxes = h('div', {}); card.appendChild(boxes); card.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, h('div', {}, btn('Save', save)), h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.'))); const state = {}; function cfgLink() { return h('a', { href: '#/pineap/filtering', style: 'color:var(--primary);cursor:pointer', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'filter configuration'); } function filterBtn() { return h('a', { class: 'btn', href: '#/pineap/filtering', style: 'text-decoration:none;display:inline-block', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'Change Filters'); } function infobox(severity, text, ...actions) { return h('div', { class: 'pineap-infobox ' + severity }, h('span', { text }), h('div', { class: 'pineap-infobox-actions' }, ...actions)); } function filterSentence(sm, cm) { if (sm === 'allow' && cm === 'allow') return 'any client in the filter configuration may connect to any SSID in the filter configuration.'; if (sm === 'deny' && cm === 'allow') return 'any client not in the filter configuration may connect to any SSID in the filter configuration.'; if (sm === 'allow' && cm === 'deny') return 'any client in the filter configuration may connect to any SSID not in the filter configuration.'; return 'any client not in the filter configuration may connect to any SSID not in the filter configuration.'; } function save() { const requests = [PagerAPI.post('/api/pineap/wifi/set_ap', { open: { ssid: ssidIn.value, bssid: bssidIn.value.trim(), hidden: hiddenCb.checked, enabled: state.enabledLoaded ? !!state.enabled : true, channel: chSel.value ? parseInt(chSel.value, 10) : null, country: coSel.value } })]; if (karmaDirty) requests.push(PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked })); Promise.allSettled(requests).then((results) => { const ok = results.every((r) => r.status === 'fulfilled'); if (ok && karmaDirty) { PINEAP_SESSION.karma = karmaCb.checked; karmaDirty = false; } App.toast(ok ? 'Open AP saved' : 'Some settings failed', ok ? '' : 'error'); load(); }); } function render() { const sm = state.ssidMode || 'deny'; const cm = state.clientMode || 'deny'; subtitle.textContent = ''; subtitle.appendChild(document.createTextNode('The Open SSID is advertised without encryption. When client association is enabled, ')); subtitle.appendChild(cfgLink()); subtitle.appendChild(document.createTextNode(' ' + filterSentence(sm, cm))); const hidden = hiddenCb.checked; const karma = karmaCb.checked; let t = 'The Open access point will be ' + (hidden ? 'hidden' : 'advertised'); if (!karma) { t += '.'; } else { if (sm === 'allow' && cm === 'allow') t += ', and clients in the allowed client filter list will be able to connect to any SSID in the allowed SSID filter.'; else if (sm === 'allow' && cm === 'deny') t += ', and clients in the allowed client filter list will be able to connect to any SSID not blocked by the SSID filter.'; else if (sm === 'deny' && cm === 'allow') t += ', and clients not in the denied client filter list will be able to connect to any SSID in the allowed SSID filter.'; else t += ', and clients not in the denied client filter list will be able to connect to any SSID not blocked by the SSID filter.'; } info.textContent = t; boxes.innerHTML = ''; const openSsid = ssidIn.value; const ssidList = state.ssidList || []; const clientList = state.clientList || []; if (state.ssidFetched && sm === 'allow' && openSsid && ssidList.indexOf(openSsid) === -1) { boxes.appendChild(infobox('error', 'The open SSID "' + openSsid + '" is not included in the filter allow list, clients will not be able to connect.', btn('Add Allowed', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'add', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error'))))); } if (state.ssidFetched && sm === 'deny' && openSsid && ssidList.indexOf(openSsid) !== -1) { boxes.appendChild(infobox('error', 'The open SSID "' + openSsid + '" is included in the filter deny list, clients will not be able to connect.', btn('Remove Filter', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'delete', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error'))))); } if (sm === 'allow' && ssidList.length > 0 && karmaCb.checked) { boxes.appendChild(infobox('info', 'Remember to add SSIDs you wish to impersonate to the PineAP SSID filter, or change to "Deny" mode to allow responding to all requested networks!', filterBtn())); } if (state.clientFetched && cm === 'allow' && clientList.length === 0) { boxes.appendChild(infobox('error', 'The PineAP Client filter is set to "allow", but no clients are listed; no clients will be able to connect!', btn('Change Mode', () => PagerAPI.post('/api/pineap/filters/client', { action: 'set_mode', mode: 'deny' }).then(load).catch(() => App.toast('Failed', 'error'))), filterBtn())); } } function load() { Promise.all([ PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/filters/ssid').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} })) ]).then(([ap, sf, cf]) => { const a = ap.data || {}; const open = a.open || {}; ssidIn.value = open.ssid || ''; bssidIn.value = open.bssid || ''; if (open.channel != null) chSel.value = String(open.channel); if (open.country) coSel.value = open.country; hiddenCb.checked = !!open.hidden; state.enabledLoaded = !!(a.open); state.enabled = !!open.enabled; if (!karmaDirty) setKnownCheckbox(karmaCb, PINEAP_SESSION.karma); const sd = sf.data || {}, cd = cf.data || {}; state.ssidFetched = !!sd.mode; state.clientFetched = !!cd.mode; state.ssidMode = sd.mode; state.clientMode = cd.mode; state.ssidList = sd.entries || []; state.clientList = cd.entries || []; render(); }); } load(); return { destroy: () => {} }; }; const EVIL_ENC = [ ['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE'] ]; views.pineap_evilwpa = (root) => { const box = pineapShell(root, '#/pineap/evilwpa'); const cfg = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, 'Evil WPA')); box.appendChild(cfg); const ssidIn = h('input', { id: 'ew-ssid' }); const pskIn = h('input', { id: 'ew-psk', type: 'password', autocomplete: 'new-password' }); const encSel = h('select', { id: 'ew-enc' }); EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' }); const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' }); cfg.appendChild(h('label', {}, 'SSID', ssidIn)); cfg.appendChild(h('label', {}, 'Passphrase', pskIn)); cfg.appendChild(h('label', {}, 'Encryption', encSel)); cfg.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled')); cfg.appendChild(h('div', { class: 'row' }, h('div', {}, btn('Save', () => { PagerAPI.post('/api/pineap/wifi/set_ap', { wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value, hidden: hiddenCb.checked, enabled: enabledCb.checked } }).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error')); })), h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.'))); const capBox = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, 'Handshake Capture')); box.appendChild(capBox); const captureCb = h('input', { type: 'checkbox', id: 'ew-capture' }); const partialCb = h('input', { type: 'checkbox', id: 'ew-partial' }); capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Automatic Capture' })); capBox.appendChild(h('label', { class: 'switch' }, captureCb, h('span', { class: 'track' }), 'Capture WPA handshakes')); capBox.appendChild(h('label', { class: 'switch' }, partialCb, h('span', { class: 'track' }), 'Keep partial handshakes')); capBox.appendChild(h('div', { class: 'muted', style: 'margin:6px 0 10px;font-size:12px' }, 'Automatically save handshakes observed by PineAP. Partial captures may not contain enough material for password recovery.')); capBox.appendChild(btn('Save capture settings', () => { PagerAPI.post('/api/pineap/set_config', { loghandshake: captureCb.checked, logpartialhandshake: partialCb.checked }).then(() => { App.toast('Handshake capture settings saved'); load(); }) .catch(() => App.toast('Failed to save capture settings', 'error')); }, 'ghost')); capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Targeted Capture' })); const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' }); const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' }); capBox.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'BSSID', bssidIn)), h('div', {}, h('label', {}, 'Seconds', secsIn)), h('div', {}, btn('Examine', () => { const b = bssidIn.value.trim(); if (!b) { App.toast('BSSID required', 'error'); return; } PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 }) .then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error')); })), h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger')))); const hsBody = h('div', {}); const hsBox = h('div', { class: 'pineap-title-card pineap-card-handshakes' }, h('div', { class: 'pineap-card-title' }, 'Captured Handshakes'), hsBody); box.appendChild(hsBox); function load() { PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const w = (r.data || {}).wpa || {}; ssidIn.value = w.ssid || ''; pskIn.value = w.passphrase || ''; if (w.enctype && Array.prototype.some.call(encSel.options, (o) => o.value === w.enctype)) { encSel.value = w.enctype; } hiddenCb.checked = !!w.hidden; enabledCb.checked = !!w.enabled; }).catch(() => {}); PagerAPI.get('/api/pineap/get_config').then((r) => { const p = r.data || {}; captureCb.checked = !!p.loghandshake; partialCb.checked = !!p.logpartialhandshake; }).catch(() => {}); PagerAPI.get('/api/pineap/handshakes').then((r) => { hsBody.innerHTML = ''; const rows = (r.data.handshakes || []).map((x) => ({ name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--' })); hsBody.appendChild(table( [{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' }, { label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }], rows)); if (!rows.length) hsBody.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No handshakes captured yet.' })); }).catch(() => {}); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; views.pineap_enterprise = (root) => { const box = pineapShell(root, '#/pineap/enterprise'); const cfg = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title' }, 'Evil Enterprise')); box.appendChild(cfg); const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' }); const authCb = h('input', { type: 'checkbox', id: 'ee-auth' }); cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled')); cfg.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), 'Auth Pass Capture')); enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; App.toast('Failed', 'error'); })); authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); })); function tableBox(name, endpoint, clearTable) { const body = h('div', {}); const tb = h('div', { class: 'pineap-title-card pineap-card-inject' }, h('div', { class: 'pineap-card-title-flex' }, h('span', { text: name }), h('span', { class: 'toolbar-spacer' }), btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')), body); box.appendChild(tb); return { body, endpoint }; } const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic'); const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge'); function load() { PagerAPI.get('/api/pineap/hostapd').then((r) => { const hh = r.data || {}; enabledCb.checked = !hh.pineape_disabled; authCb.checked = !!hh.pineape_auth_pass; }).catch(() => {}); [basic, chall].forEach((t) => { PagerAPI.get(t.endpoint).then((r) => { const rows = (r.data.rows || []).slice(); t.body.innerHTML = ''; const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k })) : [{ label: '—', key: '_none' }]; t.body.appendChild(table(cols, rows)); if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' })); }).catch(() => {}); }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; views.pineap_impersonation = (root) => { const box = pineapShell(root, '#/pineap/impersonation'); const poolCount = h('span', { text: '—' }); const countRow = h('div', { class: 'pineap-title-card-container' }, pineapTitleCard('Total SSIDs in Pool', '#/pineap/impersonation', poolCount)); box.appendChild(countRow); const input = h('input', { id: 'imp-ssid' }); const list = h('div', {}); const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' }); const colCb = h('input', { type: 'checkbox', id: 'imp-collect' }); const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' }, h('div', { class: 'pineap-card-title' }, 'SSID Pool')); poolBox.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'SSID', input)), h('div', {}, btn('Add', () => { const v = input.value.trim(); if (!v) return; PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); App.toast('Added'); }); })), h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger')))); poolBox.appendChild(h('label', { class: 'switch' }, advCb, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool')); poolBox.appendChild(h('label', { class: 'switch' }, colCb, h('span', { class: 'track' }), 'Capture SSIDs to Pool')); poolBox.appendChild(list); box.appendChild(poolBox); setKnownCheckbox(advCb, PINEAP_SESSION.advertise); advCb.addEventListener('change', () => { const requested = advCb.checked; advCb.indeterminate = false; PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => { PINEAP_SESSION.advertise = requested; load(); }).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); App.toast('Failed', 'error'); }); }); colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); })); function render(ssids) { poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0; list.innerHTML = ''; list.appendChild(table( [{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }], (ssids || []).map((s) => ({ ssid: s })), (r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } }))); list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; }); if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No SSIDs in pool.' })); } function load() { PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {}); PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const p = (r.data || {}).pool || {}; if (p.disabled != null) PINEAP_SESSION.advertise = p.disabled === false; setKnownCheckbox(advCb, PINEAP_SESSION.advertise); setKnownCheckbox(colCb, Object.prototype.hasOwnProperty.call(p, 'collecting') ? !!p.collecting : null); }).catch(() => {}); } load(); return { destroy: () => {} }; }; views.pineap_clients = (root) => { const box = pineapShell(root, '#/pineap/clients'); const state = { clients: [] }; const count = h('span', { text: '—' }); const countRow = h('div', { class: 'pineap-title-card-container' }, pineapTitleCard('Clients Connected', '#/pineap/clients', count)); box.appendChild(countRow); const body = h('div', {}); const tableCard = h('div', { class: 'pineap-title-card' }, h('div', { class: 'pineap-card-title-flex' }, h('span', { text: 'Connected Clients' }), h('span', { class: 'toolbar-spacer' }), btn('Refresh', load, 'ghost')), body); box.appendChild(tableCard); function render() { body.innerHTML = ''; body.appendChild(table( [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }], state.clients, (r) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } }))); const cols = ['MAC', 'Interface', 'RSSI']; body.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; }); } let pending = false; function load() { if (pending) return; pending = true; PagerAPI.get('/api/pineap/clients').then((r) => { state.clients = r.data.clients || []; count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : '—'; render(); }).catch(() => { count.textContent = 'Unavailable'; body.textContent = 'Unable to load connected clients.'; }) .finally(() => { pending = false; }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; views.pineap_filtering = (root) => { const box = pineapShell(root, '#/pineap/filtering'); function filterCard(title, kind) { const path = '/api/pineap/filters/' + kind; const noun = kind === 'client' ? 'client MACs' : 'SSIDs'; const singular = kind === 'client' ? 'client MAC' : 'SSID'; const state = { mode: 'deny', entries: [], pending: false }; const allowDefault = h('button', { class: 'seg-btn', text: 'Allow by default' }); const denyDefault = h('button', { class: 'seg-btn', text: 'Deny by default' }); const modeBar = h('div', { class: 'seg filter-mode-seg' }, allowDefault, denyDefault); const explanation = h('div', { class: 'pineap-filter-description' }); const valueIn = h('input', { id: 'fv-' + kind, autocomplete: 'off' }); const valueLabel = h('label', {}, h('span', { class: 'pineap-filter-input-label' }), valueIn); const list = h('div', {}); const addButton = btn('Add', addEntry); const clearButton = btn('Clear current list', clearCurrent, 'danger'); const allowAllButton = btn('Allow all', allowAll, 'ghost'); const card = h('div', { class: 'pineap-title-card pineap-filter-card' }, h('div', { class: 'pineap-card-title' }, title), h('div', { class: 'pineap-settings-section', text: 'Default behavior' }), modeBar, explanation, h('div', { class: 'row pineap-filter-entry-row' }, h('div', { class: 'pineap-filter-value' }, valueLabel), h('div', {}, addButton)), h('div', { class: 'pineap-filter-actions' }, allowAllButton, clearButton), list); box.appendChild(card); function setPending(value) { state.pending = value; [allowDefault, denyDefault, addButton, clearButton, allowAllButton, valueIn] .forEach((el) => { el.disabled = value; }); } function applyResponse(r) { const data = (r && r.data) || {}; state.mode = data.mode === 'allow' ? 'allow' : 'deny'; state.entries = Array.isArray(data.entries) ? data.entries : []; render(); } function fail(message) { App.toast(message, 'error'); } function mutate(payload, success) { if (state.pending) return; setPending(true); PagerAPI.post(path, Object.assign({ mode: state.mode }, payload)) .then((r) => { applyResponse(r); if (success) App.toast(success); }) .catch(() => fail('Failed to update ' + title.toLowerCase())) .finally(() => setPending(false)); } function setMode(mode) { if (state.mode === mode || state.pending) return; mutate({ action: 'set_mode', mode }, mode === 'deny' ? title + ': allowing by default' : title + ': denying by default'); } function addEntry() { const value = valueIn.value.trim(); if (!value) { fail(singular + ' required'); return; } mutate({ action: 'add', value }, singular + ' added'); valueIn.value = ''; } function removeEntry(value) { if (confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) { mutate({ action: 'delete', value }, singular + ' removed'); } } function clearCurrent() { if (confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) { mutate({ action: 'clear' }, 'Current list cleared'); } } function allowAll() { if (confirm('Allow all ' + noun + '? This clears the deny list and changes the default behavior to allow.')) { mutate({ action: 'allow_all' }, 'All ' + noun + ' allowed'); } } function render() { const isDenyList = state.mode === 'deny'; allowDefault.classList.toggle('active', isDenyList); denyDefault.classList.toggle('active', !isDenyList); explanation.textContent = isDenyList ? 'New ' + noun + ' are allowed. Only entries in the deny list are blocked.' : 'New ' + noun + ' are blocked. Only entries in the allow list are permitted.'; valueLabel.querySelector('.pineap-filter-input-label').textContent = 'Add to ' + (isDenyList ? 'deny' : 'allow') + ' list'; valueIn.placeholder = kind === 'client' ? 'AA:BB:CC:DD:EE:FF' : 'Network name'; list.innerHTML = ''; list.appendChild(table( [{ label: (isDenyList ? 'Denied ' : 'Allowed ') + singular, key: 'value' }, { label: '', render: () => 'Remove' }], state.entries.map((e) => ({ value: e })), (row) => ({ onclick: () => removeEntry(row.value) }))); if (!state.entries.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'The ' + (isDenyList ? 'deny' : 'allow') + ' list is empty.' })); } allowDefault.addEventListener('click', () => setMode('deny')); denyDefault.addEventListener('click', () => setMode('allow')); valueIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') addEntry(); }); PagerAPI.get(path).then(applyResponse).catch(() => fail('Unable to load ' + title.toLowerCase())); } filterCard('Client Filter', 'client'); filterCard('SSID Filter', 'ssid'); return { destroy: () => {} }; }; const RECON_TABS = [ { label: 'Scanning', hash: '#/recon' }, { label: 'Handshakes', hash: '#/recon/handshakes' } ]; const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad']; const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e']; const RECON_ENC_BUCKETS = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA3', 'Enterprise']; const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91']; const RECON_AP_COLS = [ { key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' }, { key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' }, { key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel }, { key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : a.signal + ' dBm' }, { key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' }, { key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' } ]; const RECON_CLIENT_COLS = [ { key: 'mac', label: 'Client MAC', render: (c) => c.mac }, { key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' }, { key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' }, { key: 'packets', label: 'Packets', render: (c) => c.packets || 0 } ]; function reconDefaultCols() { return { ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: true, hidden: true }, client: { mac: true, signal: true, freq: true, packets: true } }; } function reconLoadCols() { try { const v = JSON.parse(localStorage.getItem('pw_recon_cols')); if (v && v.ap && v.client) return v; } catch (e) {} return reconDefaultCols(); } function reconFiltered(rows, q, colsArr) { const ql = (q || '').toLowerCase(); if (!ql) return rows; return rows.filter((r) => colsArr.some((c) => String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(ql) !== -1)); } function reconEncBucket(enc) { const s = (enc || '').trim(); if (s === 'Open') return 'Open'; if (s.indexOf('Enterprise') !== -1) return 'Enterprise'; if (s.indexOf('WEP') !== -1) return 'WEP'; if (s.indexOf('WPA2') !== -1) return 'WPA2'; if (s.indexOf('WPA3') !== -1) return 'WPA3'; if (s.indexOf('WPA') !== -1) return 'WPA'; return s || 'Unknown'; } function reconPer(key, def) { const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10); return [10, 25, 50].indexOf(v) !== -1 ? v : def; } function reconCmp(a, b, col, dir) { const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets'; if (numeric) { const x = a[col.key] == null ? -Infinity : Number(a[col.key]); const y = b[col.key] == null ? -Infinity : Number(b[col.key]); return (x - y) * dir; } const xs = String(a[col.key] == null ? '' : a[col.key]).toLowerCase(); const ys = String(b[col.key] == null ? '' : b[col.key]).toLowerCase(); return xs.localeCompare(ys) * dir; } views.recon = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); tabBar(root, RECON_TABS, '#/recon'); const state = { scans: [], selected: null, detail: null, apPage: 0, apSearch: '', clientPage: 0, clientSearch: '', apPer: reconPer('ap', 10), clientPer: reconPer('client', 10), apSort: null, clientSort: null, focusAp: null, autoFollow: false, scanActive: false }; const cols = reconLoadCols(); // ---- title cards ---- const cardWrap = h('div', { class: 'recon-title-card-container' }); root.appendChild(cardWrap); function titleCard(titleText, link) { const wrap = h('div', { class: 'recon-title-card' }); const card = h('div', { class: 'recon-card' }); wrap.appendChild(card); card.appendChild(link ? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText }) : h('div', { class: 'recon-title-card-title', text: titleText })); const content = h('div', { class: 'recon-title-card-content' }); card.appendChild(content); cardWrap.appendChild(wrap); return content; } const landContent = titleCard('Wireless Landscape', false); const landBox = h('div', { class: 'recon-chart-box' }); landContent.appendChild(landBox); const landCanvas = h('canvas', { id: 'recon-landscape' }); landBox.appendChild(landCanvas); const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' }); landBox.appendChild(landEmpty); const chanContent = titleCard('Channel Distribution', false); const chanBox = h('div', { class: 'recon-chart-box' }); chanContent.appendChild(chanBox); const chanCanvas = h('canvas', { id: 'recon-channel' }); chanBox.appendChild(chanCanvas); const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' }); chanBox.appendChild(chanEmpty); const encContent = titleCard('Encryption Landscape', false); const encBox = h('div', { class: 'recon-chart-box' }); encContent.appendChild(encBox); const encCanvas = h('canvas', { id: 'recon-encryption' }); encBox.appendChild(encCanvas); const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data is available yet.' }); encBox.appendChild(encEmpty); const hsContent = titleCard('Handshakes', true); const hsCol = h('div', { class: 'recon-hs-col' }); hsContent.appendChild(hsCol); const hsCount = h('span', { class: 'recon-hs-count', text: '0' }); hsCol.appendChild(hsCount); hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' })); const hsAuto = h('label', { class: 'recon-toggle' }, h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Automatically Collect Any Handshakes'); hsAuto.querySelector('input').addEventListener('change', () => { PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked }) .then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error')); }); hsCol.appendChild(hsAuto); const psContent = titleCard('Previous Scans', false); const psRow = h('div', { class: 'recon-ps-row' }); psContent.appendChild(psRow); const sel = h('select', { class: 'sel', id: 'recon-scan-select' }); sel.addEventListener('change', () => { state.selected = parseInt(sel.value, 10) || null; state.apPage = 0; state.clientPage = 0; loadDetail(); }); psRow.appendChild(sel); psRow.appendChild(iconBtn('file_download', 'Download scan JSON', () => { if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json'; })); psRow.appendChild(iconBtn('delete', 'Delete scan', () => { if (state.selected == null) return; if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return; PagerAPI.del('/api/recon/scans/' + state.selected) .then(() => { App.toast('Scan deleted'); load(); }) .catch(() => App.toast('Delete failed', 'error')); })); // ---- scan bar ---- const scanBar = h('div', { class: 'section recon-scan-bar' }); root.appendChild(scanBar); const scanToggle = h('input', { type: 'checkbox', id: 'recon-scan-toggle' }); const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan'); scanBar.appendChild(scanLabel); const durSel = h('select', { class: 'sel', id: 'recon-duration' }); [[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes'], [0, 'Continuous']] .forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t }))); durSel.value = localStorage.getItem('pw_scan_duration') || '30'; durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value)); scanBar.appendChild(durSel); scanBar.appendChild(h('span', { class: 'toolbar-spacer' })); scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden'))); let pendingScan = false; scanToggle.addEventListener('change', () => { if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } const on = scanToggle.checked; pendingScan = true; scanToggle.disabled = true; PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {}) .then(() => { if (on) { state.scanActive = true; state.selected = null; state.apPage = 0; state.clientPage = 0; App.toast('Scan started'); } else { state.scanActive = false; App.toast('Scan stopped'); } restartPoll(); load(); }) .catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); }) .finally(() => { pendingScan = false; scanToggle.disabled = false; }); }); // ---- settings sidebar ---- const sidebar = h('div', { class: 'recon-settings-sidebar hidden' }); sidebar.appendChild(h('div', { class: 'recon-settings-head' }, h('span', { class: 'recon-settings-title', text: 'Recon Settings' }), btn('×', () => sidebar.classList.add('hidden'), 'ghost'))); const colDefs = { ap: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['channel', 'Show Channel'], ['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['hidden', 'Show Hidden']], client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']] }; Object.keys(colDefs).forEach((grp) => { sidebar.appendChild(h('div', { class: 'recon-settings-section', text: grp === 'ap' ? 'Access Points' : 'Clients' })); colDefs[grp].forEach(([key, label]) => { const cb = h('input', { type: 'checkbox', id: 'col-' + grp + '-' + key }); cb.checked = cols[grp][key]; cb.addEventListener('change', () => { cols[grp][key] = cb.checked; localStorage.setItem('pw_recon_cols', JSON.stringify(cols)); renderTables(); }); sidebar.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label)); }); }); root.appendChild(sidebar); // ---- AP focus sidebar ---- const focusSidebar = h('div', { class: 'recon-focus-sidebar hidden' }); root.appendChild(focusSidebar); function renderFocus() { const ap = state.focusAp; if (!ap) return; focusSidebar.innerHTML = ''; const head = h('div', { class: 'recon-focus-header' }, h('div', { class: 'recon-focus-header-text', text: ap.ssid || 'Hidden SSID' }), btn('×', () => { state.focusAp = null; focusSidebar.classList.add('hidden'); renderTables(); }, 'ghost')); focusSidebar.appendChild(head); focusSidebar.appendChild(h('div', { class: 'recon-focus-bssid', text: ap.bssid || '--' })); focusSidebar.appendChild(h('div', { class: 'recon-focus-subtext', text: 'Channel ' + (ap.channel == null ? '--' : ap.channel) + ' · ' + (ap.freq || '--') + ' MHz' })); const actions = h('div', { class: 'recon-focus-body' }); focusSidebar.appendChild(actions); actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' })); const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' }); capture.addEventListener('click', () => { PagerAPI.post('/api/pineap/set_config', { loghandshake: true }) .then(() => App.toast('Handshake capture enabled (device-wide on Pager)')) .catch(() => App.toast('Failed to enable handshake capture', 'error')); }); actions.appendChild(capture); const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' }); stopHs.addEventListener('click', () => { PagerAPI.post('/api/pineap/set_config', { loghandshake: false }) .then(() => App.toast('Handshake capture disabled')) .catch(() => App.toast('Failed to disable handshake capture', 'error')); }); actions.appendChild(stopHs); const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' }); exB.addEventListener('click', () => { if (!ap.bssid) return; PagerAPI.post('/api/recon/examine', { bssid: ap.bssid }) .then(() => App.toast('Examining ' + ap.bssid)) .catch(() => App.toast('Examine failed', 'error')); }); actions.appendChild(exB); const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' }); exC.addEventListener('click', () => { if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; } PagerAPI.post('/api/recon/examine', { channel: ap.channel }) .then(() => App.toast('Examining channel ' + ap.channel)) .catch(() => App.toast('Examine failed', 'error')); }); actions.appendChild(exC); const details = h('div', { class: 'recon-focus-body' }); focusSidebar.appendChild(details); details.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Details' })); [['Channel', ap.channel == null ? '--' : ap.channel], ['Signal', ap.signal == null ? '--' : ap.signal + ' dBm'], ['Encryption', ap.encryption || '--'], ['Frequency', ap.freq || '--'], ['Hidden', ap.hidden ? 'Yes' : 'No']].forEach(([k, v]) => { details.appendChild(h('div', { class: 'recon-focus-detail' }, h('span', { class: 'recon-focus-detail-label', text: k }), h('span', { text: v }))); }); } function toggleFocus(ap) { if (state.focusAp && state.focusAp.bssid === ap.bssid) { state.focusAp = null; focusSidebar.classList.add('hidden'); } else { state.focusAp = ap; renderFocus(); focusSidebar.classList.remove('hidden'); } renderTables(); } // ---- results tables ---- const apCard = h('div', { class: 'section recon-scan-results-card' }); root.appendChild(apCard); const cliCard = h('div', { class: 'section recon-scan-results-card' }); root.appendChild(cliCard); function buildPaginator(key) { const mk = (id, icon, title, fn) => { const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title }); b.innerHTML = PineappleIcons[icon] || ''; b.addEventListener('click', fn); return b; }; return h('div', { class: 'recon-paginator' }, mk('first', 'first_page', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }), mk('prev', 'chevron_left', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }), h('span', { class: 'muted', id: key + '-range', text: '' }), mk('next', 'chevron_right', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }), mk('last', 'last_page', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); })); } function reconPageCount(key) { const d = state.detail || {}; const rows = key === 'ap' ? (d.aps || []) : (d.clients || []); const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS; const q = key === 'ap' ? state.apSearch : state.clientSearch; return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / state[key + 'Per'])); } function perSelect(key) { const sel = h('select', { class: 'sel recon-per', id: key + '-per' }); [10, 25, 50].forEach((n) => sel.appendChild(h('option', { value: String(n), text: n + ' / page' }))); sel.value = String(state[key + 'Per']); sel.addEventListener('change', () => { state[key + 'Per'] = parseInt(sel.value, 10) || 10; localStorage.setItem('pw_recon_per_' + key, String(state[key + 'Per'])); state[key + 'Page'] = 0; renderTables(); }); return sel; } function tableHead(box, title, key, searchId, onInput) { const head = h('div', { class: 'recon-table-head' }, h('h2', { text: title }), h('span', { class: 'toolbar-spacer' }), h('input', { class: 'recon-search', id: searchId, placeholder: 'Search' }), perSelect(key), buildPaginator(key)); box.appendChild(head); head.querySelector('#' + searchId).addEventListener('input', onInput); } tableHead(apCard, 'Access Points', 'ap', 'ap-search', () => { state.apSearch = document.getElementById('ap-search').value; state.apPage = 0; renderTables(); }); const apBody = h('div', { class: 'recon-table-body' }); apCard.appendChild(apBody); tableHead(cliCard, 'Clients', 'client', 'cli-search', () => { state.clientSearch = document.getElementById('cli-search').value; state.clientPage = 0; renderTables(); }); const cliBody = h('div', { class: 'recon-table-body' }); cliCard.appendChild(cliBody); function renderTable(box, key, rows, colsArr, emptyMsg) { box.innerHTML = ''; const vis = colsArr.filter((c) => cols[key][c.key]); const per = state[key + 'Per']; const page = state[key + 'Page']; const start = page * per; const slice = rows.slice(start, start + per); const rowAttrs = key === 'ap' ? (r) => ({ class: state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '', style: 'cursor:pointer', onclick: () => toggleFocus(r) }) : undefined; const tbl = table(vis, slice, rowAttrs); box.appendChild(tbl); tbl.querySelectorAll('th').forEach((th, i) => { const col = vis[i]; if (!col) return; th.style.cursor = 'pointer'; th.title = 'Sort by ' + col.label; const arrow = h('span', { class: 'recon-sort-arrow' }); th.appendChild(arrow); th.addEventListener('click', () => { const cur = state[key + 'Sort']; state[key + 'Sort'] = (cur && cur.key === col.key) ? { key: col.key, dir: -cur.dir } : { key: col.key, dir: 1 }; state[key + 'Page'] = 0; renderTables(); }); const cur = state[key + 'Sort']; if (cur && cur.key === col.key) { th.classList.add('recon-sorted'); arrow.textContent = cur.dir === 1 ? ' ▲' : ' ▼'; } }); if (!rows.length) box.appendChild(h('div', { class: 'empty', text: emptyMsg })); const range = document.getElementById(key + '-range'); if (range) range.textContent = rows.length ? (start + 1) + '–' + Math.min(start + per, rows.length) + ' of ' + rows.length : '0 of 0'; const p = reconPageCount(key); const first = document.getElementById(key + '-first'); const prev = document.getElementById(key + '-prev'); const next = document.getElementById(key + '-next'); const last = document.getElementById(key + '-last'); if (first) first.disabled = page === 0; if (prev) prev.disabled = page === 0; if (next) next.disabled = page >= p - 1; if (last) last.disabled = page >= p - 1; } function sortRows(rows, key, colsArr) { const s = state[key + 'Sort']; if (!s) return rows; const col = colsArr.find((c) => c.key === s.key); if (!col) return rows; const copy = rows.slice(); copy.sort((a, b) => reconCmp(a, b, col, s.dir)); return copy; } function renderTables() { const d = state.detail || { aps: [], clients: [], handshakes: [] }; const apF = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS); const cliF = reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS); renderTable(apBody, 'ap', sortRows(apF, 'ap', RECON_AP_COLS), RECON_AP_COLS, 'No access points in this scan.'); renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.'); } function drawCharts(d) { const n = (d.aps || []).length; const c = (d.clients || []).length; const land = document.getElementById('recon-landscape'); if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { if (n + c > 0) { MiniChart.doughnut(land, [ { label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] }, { label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] }, { label: 'Unassociated', value: 0, color: RECON_LANDSCAPE_COLORS[2] } ], { legend: true, height: 130 }); land.classList.remove('hidden'); landEmpty.classList.add('hidden'); } else { land.classList.add('hidden'); landEmpty.classList.remove('hidden'); } } const counts = {}; (d.aps || []).forEach((a) => { const ch = a.channel == null ? '?' : a.channel; counts[ch] = (counts[ch] || 0) + 1; }); const keys = Object.keys(counts).sort((a, b) => { if (a === '?') return 1; if (b === '?') return -1; return Number(a) - Number(b); }); const ch = document.getElementById('recon-channel'); if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) { if (keys.length) { MiniChart.bar(ch, keys.map((k, i) => ({ label: k, value: counts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length] })), { height: 130 }); ch.classList.remove('hidden'); chanEmpty.classList.add('hidden'); } else { ch.classList.add('hidden'); chanEmpty.classList.remove('hidden'); } } const encCounts = {}; (d.aps || []).forEach((a) => { const b = reconEncBucket(a.encryption); encCounts[b] = (encCounts[b] || 0) + 1; }); const enc = document.getElementById('recon-encryption'); if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { if ((d.aps || []).length) { MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({ label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i] })), { legend: true, height: 130 }); enc.classList.remove('hidden'); encEmpty.classList.add('hidden'); } else { enc.classList.add('hidden'); encEmpty.classList.remove('hidden'); } } } function loadDetail() { if (state.selected == null) return; PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => { state.detail = r.data; drawCharts(r.data); renderTables(); hsCount.textContent = (r.data.handshakes || []).length; }).catch(() => {}); } function load() { PagerAPI.get('/api/recon/scans').then((r) => { state.scans = r.data.scans || []; const keep = state.selected && state.scans.some((s) => s.id === state.selected) ? state.selected : (state.scans[0] ? state.scans[0].id : null); sel.innerHTML = ''; state.scans.forEach((s) => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time); sel.appendChild(opt); }); if (keep == null) { state.detail = null; drawCharts({ aps: [], clients: [], handshakes: [] }); renderTables(); hsCount.textContent = '0'; } if (keep != null) sel.value = keep; state.selected = keep; if (keep != null) loadDetail(); }).catch(() => {}); PagerAPI.get('/api/pineap/get_config').then((r) => { hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake); }).catch(() => {}); PagerAPI.get('/api/recon/status').then((r) => { const scanning = !!r.data.scanning; state.scanActive = scanning; if (!pendingScan) scanToggle.checked = scanning; restartPoll(); }).catch(() => {}); } load(); let pollIv = null; const restartPoll = () => { clearInterval(pollIv); pollIv = setInterval(load, state.scanActive ? 5000 : 10000); }; restartPoll(); return { destroy: () => clearInterval(pollIv) }; }; const LOGGING_TABS = [ { label: 'PineAP', hash: '#/logging' }, { label: 'System', hash: '#/logging/system' } ]; function hsType(name) { const n = (name || '').toLowerCase(); if (/\.(cap|pcap|pcapng)$/.test(n)) return 'PCAP'; if (/\.(hccapx|hc22000|22000|hccap)$/.test(n)) return 'Hashcat'; return 'Unknown'; } views.recon_handshakes = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); tabBar(root, RECON_TABS, '#/recon/handshakes'); const box = h('div', { class: 'section recon-handshakes-card' }); root.appendChild(box); let flashTimer = null; function flash(ok, msg) { const head = box.querySelector('.recon-table-head'); if (!head) return; const old = head.querySelector('.hs-flash'); if (old) old.remove(); const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg }); head.appendChild(el); clearTimeout(flashTimer); flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000); } function hsIconBtn(name, title, cls, onclk) { const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk }); b.innerHTML = PineappleIcons[name] || ''; return b; } function textCell(v) { return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) }); } function naGlyph() { return h('td', { class: 'mat-cell hs-cell-center' }, h('span', { class: 'hs-na', title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted.", html: PineappleIcons.question_mark })); } function boolGlyph(v) { return h('td', { class: 'mat-cell hs-cell-center' }, h('span', { class: v ? 'hs-ok' : 'hs-bad', html: v ? PineappleIcons.check : PineappleIcons.close })); } function msgCell(inDb, present) { return inDb ? boolGlyph(present) : naGlyph(); } function load(done) { PagerAPI.get('/api/pineap/handshakes').then((r) => { box.innerHTML = ''; const head = h('div', { class: 'recon-table-head' }, h('h2', { text: 'Captured WPA Handshakes' }), h('span', { class: 'toolbar-spacer' }), iconBtn('settings', 'Handshakes settings', openSettings)); box.appendChild(head); box.appendChild(h('div', { class: 'row' }, h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })), h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))), h('div', {}, btn('Refresh', () => load(), 'ghost')))); const hs = r.data.handshakes || []; if (!hs.length) { box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' })); if (done) done(); return; } const t = h('table', { class: 'tbl' }); const thead = h('thead'); const th = h('tr'); ['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2', 'Message 3', 'Message 4', 'Beacon Frame', ''] .forEach((c) => th.appendChild(h('th', { text: c }))); thead.appendChild(th); t.appendChild(thead); const tb = h('tbody'); hs.forEach((f) => { const trr = h('tr'); trr.appendChild(textCell(f.mac)); trr.appendChild(textCell(f.client)); trr.appendChild(textCell(f.source)); trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name))); trr.appendChild(textCell(fmtTime(f.timestamp))); [1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0))); trr.appendChild(msgCell(f.in_db, !!f.beacon)); const act = h('td', { class: 'mat-cell' }, h('span', { class: 'hs-actions' }, hsIconBtn('file_download', 'Download', '', () => { window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name); }), hsIconBtn('delete', 'Delete', 'hs-warn', () => { PagerAPI.del('/api/pineap/handshakes', { name: f.name }) .then(() => load(() => flash(true, 'Deleted ' + f.name))) .catch(() => flash(false, 'Failed to delete ' + f.name)); }))); trr.appendChild(act); tb.appendChild(trr); }); t.appendChild(tb); box.appendChild(t); if (done) done(); }).catch(() => { if (box.querySelector('.recon-table-head')) flash(false, 'Failed to load handshakes'); else App.toast('Failed to load handshakes', 'error'); }); } function openSettings() { PagerAPI.get('/api/pineap/handshakes/location').then((r) => { const loc = (r.data || {}).location || '--'; const overlay = h('div', { class: 'modal-overlay' }); function close() { overlay.remove(); } overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); const modal = h('div', { class: 'modal' }, h('div', { class: 'modal-title', text: 'Handshake Settings' }), h('div', { class: 'modal-body' }, h('div', { class: 'hs-settings-row' }, h('span', { class: 'hs-settings-label', text: 'Handshake Location' }), h('span', { class: 'hs-settings-value', text: loc })), btn('Delete All Handshakes', () => { PagerAPI.del('/api/pineap/handshakes/all') .then(() => { close(); load(() => flash(true, 'All handshakes deleted')); }) .catch(() => { close(); flash(false, 'Failed to delete all handshakes'); }); }, 'danger')), h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost'))); overlay.appendChild(modal); document.body.appendChild(overlay); }).catch(() => App.toast('Failed to load handshake settings', 'error')); } load(); return { destroy: () => {} }; }; views.logging = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); tabBar(root, LOGGING_TABS, '#/logging'); const lines = []; const search = h('input', { class: 'log-search', placeholder: 'Search activity log' }); const output = h('pre', { class: 'logs' }); const pineBox = h('div', { class: 'section' }, h('div', { class: 'log-toolbar' }, h('h2', {}, 'Activity Log'), h('span', { class: 'toolbar-spacer' }), search, btn('Download', () => downloadText('pineap.log', lines.join('\n')), 'ghost'), btn('Refresh', load, 'ghost')), output); root.appendChild(pineBox); function render() { const q = search.value.trim().toLowerCase(); output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n'); } search.addEventListener('input', render); let pending = false; function load() { if (pending) return; pending = true; PagerAPI.get('/api/logging/pineap?lines=200').then((r) => { lines.splice(0, lines.length, ...((r.data || {}).lines || [])); render(); }).catch(() => { App.toast('Failed to load PineAP log', 'error'); }) .finally(() => { pending = false; }); } load(); const iv = setInterval(load, 10000); return { destroy: () => clearInterval(iv) }; }; views.logging_system = (root) => { root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); tabBar(root, LOGGING_TABS, '#/logging/system'); const lines = []; const search = h('input', { class: 'log-search', placeholder: 'Search system log' }); const output = h('pre', { class: 'logs' }); const box = h('div', { class: 'section' }, h('div', { class: 'log-toolbar' }, h('h2', {}, 'System Log'), h('span', { class: 'toolbar-spacer' }), search, btn('Download', () => downloadText('system.log', lines.join('\n')), 'ghost'), btn('Refresh', load, 'ghost')), output); root.appendChild(box); function render() { const q = search.value.trim().toLowerCase(); output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n'); } search.addEventListener('input', render); let pending = false; function load() { if (pending) return; pending = true; PagerAPI.get('/api/logging/system?lines=400').then((r) => { lines.splice(0, lines.length, ...((r.data || {}).lines || [])); render(); }).catch(() => { App.toast('Failed to load system log', 'error'); }) .finally(() => { pending = false; }); } load(); const iv = setInterval(load, 10000); return { destroy: () => clearInterval(iv) }; }; const PAYLOAD_TABS = [ { label: 'Installed', hash: '#/modules' }, { label: 'Payloads', hash: '#/modules/online' }, { label: 'Running', hash: '#/modules/running' }, { label: 'Develop', hash: '#/modules/develop' } ]; function payloadShell(root, activeHash) { root.appendChild(h('h1', { class: 'page-title', text: 'Payloads' })); tabBar(root, PAYLOAD_TABS, activeHash); const box = h('div', { class: 'payload-content' }); root.appendChild(box); return box; } function payloadToolbar(title, description, action) { const head = h('div', { class: 'payload-heading' }, h('div', {}, h('h2', { text: title }), h('p', { class: 'muted', text: description }))); if (action) head.appendChild(action); return head; } function payloadFilters(onchange) { const search = h('input', { class: 'payload-search', placeholder: 'Search payloads' }); const category = h('select', { class: 'payload-category' }, h('option', { value: '', text: 'All categories' })); search.addEventListener('input', onchange); category.addEventListener('change', onchange); return { search, category, node: h('div', { class: 'payload-filters' }, search, category) }; } function fillPayloadCategories(select, rows, key) { const values = Array.from(new Set((rows || []).map((row) => row[key] || '').filter(Boolean))).sort(); values.forEach((value) => select.appendChild(h('option', { value, text: value.replace(/_/g, ' ') }))); } function payloadVersionTag(version) { const value = String(version || '').trim(); return value ? (/^v/i.test(value) ? value : 'v' + value) : ''; } function payloadCard(item, actions, tags) { const meta = h('div', { class: 'payload-meta' }); (tags || []).filter(Boolean).forEach((tag) => meta.appendChild(h('span', { class: 'payload-tag', text: tag }))); const controls = h('div', { class: 'payload-actions' }); (actions || []).forEach((action) => controls.appendChild(action)); return h('article', { class: 'payload-card' }, h('div', { class: 'payload-card-main' }, h('h3', { text: item.title || item.key || 'Untitled payload' }), h('p', { class: 'payload-author muted', text: item.author ? 'by ' + item.author : 'Local payload' }), h('p', { class: 'payload-description', text: item.description || 'No description provided.' }), meta), controls); } function setPayloadBusy(button, busy, label) { button.disabled = busy; if (label) button.textContent = busy ? label : button.dataset.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', () => { setPayloadBusy(launch, true, 'Starting...'); PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => { App.toast(item.title + ' started'); location.hash = '#/modules/running'; }).catch(apiError('Unable to start payload')).finally(() => setPayloadBusy(launch, false)); }); launch.dataset.label = 'Run'; if (item.disabled || item.key === 'user~remote_access~pager-webui') { launch.disabled = true; launch.title = item.disabled ? 'This payload is disabled' : 'The WebUI cannot launch itself'; } const update = item.update ? btn('Update', () => installPayload(item, update), 'ghost') : null; if (update) update.dataset.label = 'Update'; const remove = btn('Remove', () => { if (!confirm('Remove "' + item.title + '" from this Pager?')) return; setPayloadBusy(remove, true, 'Removing...'); PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => { App.toast(item.title + ' removed'); load(); }).catch(apiError('Unable to remove payload')).finally(() => setPayloadBusy(remove, false)); }, 'danger'); remove.dataset.label = 'Remove'; if (item.key === 'user~remote_access~pager-webui') { remove.disabled = true; remove.title = 'The active WebUI cannot remove itself'; } list.appendChild(payloadCard(item, [launch, update, remove].filter(Boolean), [item.category && item.category.replace(/_/g, ' '), payloadVersionTag(item.version), item.update && 'Update available', item.missingmanifest && 'Local manifest'])); }); } function installPayload(item, button) { setPayloadBusy(button, true, 'Updating...'); PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { App.toast(item.title + ' updated'); load(); }).catch(apiError('Unable to update payload')).finally(() => setPayloadBusy(button, false)); } function load() { refresh.disabled = true; PagerAPI.get('/api/payloads/installed').then((r) => { if (!alive) return; rows = (r.data && r.data.payloads) || []; filters.category.innerHTML = ''; fillPayloadCategories(filters.category, rows, 'category'); render(); }).catch(apiError('Unable to load installed payloads')).finally(() => { refresh.disabled = false; }); } load(); return { destroy: () => { alive = false; } }; }; views.modules_online = (root) => { const box = payloadShell(root, '#/modules/online'); let alive = true; let rows = [], installed = new Set(); const list = h('div', { class: 'payload-list' }); const status = h('p', { class: 'payload-result-count muted' }); const filters = payloadFilters(render); const refresh = btn('Get Available Payloads', () => { refresh.disabled = true; PagerAPI.post('/api/payloads/refresh').then(() => { App.toast('Pager Portal listings refreshed'); return load(); }).catch(apiError('Unable to refresh Pager Portal')).finally(() => { refresh.disabled = false; }); }); box.appendChild(h('div', { class: 'section' }, payloadToolbar('Available Payloads', 'Browse community payloads from the Pager Portal and install them directly on this Pager.', refresh), filters.node, status, list)); function render() { if (!alive) return; const q = filters.search.value.trim().toLowerCase(); const category = filters.category.value; const matches = rows.filter((item) => (!category || item.parent === category) && (!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1)); const visible = matches.slice(0, 120); status.textContent = matches.length + ' result' + (matches.length === 1 ? '' : 's') + (matches.length > visible.length ? ' - showing first ' + visible.length : ''); list.innerHTML = ''; if (!visible.length) { list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No Portal listing is cached. Select Get Available Payloads.' })); return; } visible.forEach((item) => { const isInstalled = installed.has(item.key); const install = btn(isInstalled ? 'Installed' : 'Install', () => { if (installed.has(item.key)) return; install.dataset.label = 'Install'; setPayloadBusy(install, true, 'Installing...'); PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { installed.add(item.key); App.toast(item.title + ' installed'); render(); }).catch(apiError('Unable to install payload')).finally(() => setPayloadBusy(install, false)); }, isInstalled ? 'ghost' : ''); install.dataset.label = isInstalled ? 'Installed' : 'Install'; install.disabled = isInstalled; list.appendChild(payloadCard(item, [install], [item.parent && item.parent.replace('/', ' / ').replace(/_/g, ' '), payloadVersionTag(item.version)])); }); } function load() { return Promise.all([PagerAPI.get('/api/payloads/index'), PagerAPI.get('/api/payloads/installed')]).then((results) => { if (!alive) return; rows = (results[0].data && results[0].data.payloads) || []; installed = new Set(((results[1].data && results[1].data.payloads) || []).map((item) => item.key)); filters.category.innerHTML = ''; fillPayloadCategories(filters.category, rows, 'parent'); render(); }).catch(apiError('Unable to load Pager Portal payloads')); } load(); return { destroy: () => { alive = false; } }; }; views.modules_running = (root) => { const box = payloadShell(root, '#/modules/running'); let alive = true, pending = false; const list = h('div', { class: 'payload-runs' }); const refresh = btn('Refresh', load, 'ghost'); box.appendChild(h('div', { class: 'section' }, payloadToolbar('Running Payloads', 'Tracks payloads launched from this WebUI session. Output is read from each payload process.', refresh), list)); function render(rows) { if (!alive) return; list.innerHTML = ''; if (!rows.length) { list.appendChild(h('div', { class: 'payload-empty', text: 'No payloads have been launched from this WebUI session.' })); return; } rows.forEach((run) => { const state = h('span', { class: 'payload-run-state ' + (run.running ? 'running' : 'finished'), text: run.running ? 'Running' : (run.returncode === 0 ? 'Completed' : 'Exited ' + run.returncode) }); const actions = h('div', { class: 'payload-actions' }); if (run.running) { const stop = btn('Stop', () => { stop.disabled = true; PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => { App.toast(run.title + ' stop requested'); setTimeout(load, 500); }).catch(apiError('Unable to stop payload')).finally(() => { stop.disabled = false; }); }, 'danger'); actions.appendChild(stop); } const output = h('pre', { class: 'payload-output', text: run.output || 'No output yet.' }); list.appendChild(h('article', { class: 'payload-run' }, h('div', { class: 'payload-run-head' }, h('div', {}, h('h3', { text: run.title }), h('p', { class: 'muted', text: 'PID ' + run.pid + ' - started ' + fmtTime(run.started) })), state, actions), output)); }); } function load() { if (pending) return; pending = true; refresh.disabled = true; PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || [])) .catch(apiError('Unable to load payload runs')).finally(() => { pending = false; refresh.disabled = false; }); } load(); const iv = setInterval(load, 3000); return { destroy: () => { alive = false; clearInterval(iv); } }; }; views.modules_develop = (root) => { const box = payloadShell(root, '#/modules/develop'); box.appendChild(h('div', { class: 'section payload-develop' }, h('h2', { text: 'Developing Pager Payloads' }), h('p', { text: 'Pager payloads are native shell or script-based workflows, not Pineapple modules. A payload lives in a context and category folder and declares its launchpoint and metadata in _hak5_manifest.json.' }), h('div', { class: 'payload-dev-grid' }, h('div', { class: 'payload-dev-block' }, h('h3', { text: 'Payload structure' }), h('pre', { class: 'payload-code', text: '/root/payloads/user/general/my_payload/\n payload.sh\n _hak5_manifest.json\n assets/' })), h('div', { class: 'payload-dev-block' }, h('h3', { text: 'Native capabilities' }), h('p', { text: 'Scripts launched here keep access to Pager commands such as ALERT, LOG, CONFIRMATION_DIALOG, TEXT_PICKER and other hak5cmd-backed helpers.' }))), h('p', { class: 'muted', text: 'Use the terminal to create or inspect payload files. Refresh Installed when the manifest changes so the firmware inventory is re-read.' }), btn('Open Terminal', () => { const terminal = document.getElementById('terminal-btn'); if (terminal) terminal.click(); }))); return { destroy: () => {} }; }; const SETTINGS_TABS = [ { label: 'General', hash: '#/settings' }, { label: 'Networking', hash: '#/settings/networking' }, { label: 'WiFi', hash: '#/settings/wifi' }, { label: 'LED', hash: '#/settings/led' }, { label: 'Advanced', hash: '#/settings/advanced' }, { label: 'Help', hash: '#/settings/help' } ]; function settingsShell(root, activeHash) { root.appendChild(h('h1', { class: 'page-title', text: 'Settings' })); tabBar(root, SETTINGS_TABS, activeHash); const box = h('div', { class: 'settings-content' }); root.appendChild(box); return box; } function settingsCard(box, title, subtitle) { const card = h('div', { class: 'section settings-card' }, h('h2', {}, title)); if (subtitle) card.appendChild(h('p', { class: 'muted settings-subtitle', text: subtitle })); box.appendChild(card); return card; } function apiError(message) { return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error'); } views.settings = (root) => { const box = settingsShell(root, '#/settings'); const user = settingsCard(box, 'User Management & Timezone'); const currentPw = h('input', { type: 'password', autocomplete: 'current-password' }); const newPw = h('input', { type: 'password', autocomplete: 'new-password' }); const repeatPw = h('input', { type: 'password', autocomplete: 'new-password' }); user.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Current Password', currentPw), h('label', {}, 'New Password', newPw), h('label', {}, 'Repeat New Password', repeatPw))); user.appendChild(btn('Update Password', () => { if (!currentPw.value) { App.toast('Current password is required', 'error'); return; } if (newPw.value.length < 4) { App.toast('New password is too short', 'error'); return; } if (newPw.value !== repeatPw.value) { App.toast('New passwords do not match', 'error'); return; } PagerAPI.post('/api/settings/password', { current_password: currentPw.value, new_password: newPw.value, repeat_password: repeatPw.value }).then(() => { currentPw.value = ''; newPw.value = ''; repeatPw.value = ''; App.toast('Root password updated'); }).catch(apiError('Failed to update password')); })); const timezones = [ ['UTC', '(GMT+0) Coordinated Universal Time', 'Etc/UTC'], ['EST5EDT,M3.2.0,M11.1.0', '(GMT-5) Eastern Time (US & Canada)', 'America/New_York'], ['CST6CDT,M3.2.0,M11.1.0', '(GMT-6) Central Time (US & Canada)', 'America/Chicago'], ['MST7MDT,M3.2.0,M11.1.0', '(GMT-7) Mountain Time (US & Canada)', 'America/Denver'], ['MST7', '(GMT-7) Arizona', 'America/Phoenix'], ['PST8PDT,M3.2.0,M11.1.0', '(GMT-8) Pacific Time (US & Canada)', 'America/Los_Angeles'], ['AKST9AKDT,M3.2.0,M11.1.0', '(GMT-9) Alaska', 'America/Anchorage'], ['HST10', '(GMT-10) Hawaii', 'Pacific/Honolulu'], ['GMT0BST,M3.5.0/1,M10.5.0', '(GMT+0) London', 'Europe/London'], ['CET-1CEST,M3.5.0,M10.5.0/3', '(GMT+1) Central Europe', 'Europe/Berlin'], ['JST-9', '(GMT+9) Japan', 'Asia/Tokyo'], ['AEST-10AEDT,M10.1.0,M4.1.0/3', '(GMT+10) Sydney', 'Australia/Sydney'] ]; const tzSel = h('select', { 'aria-label': 'Timezone' }); timezones.forEach(([value, label, zone]) => tzSel.appendChild(h('option', { value, text: label, 'data-zone': zone }))); const tzActions = h('div', { class: 'settings-actions' }, btn('Update Timezone', () => { const opt = tzSel.options[tzSel.selectedIndex]; PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' }) .then(() => App.toast('Timezone updated')).catch(apiError('Failed to update timezone')); }), btn('Sync Browser Time', () => { const d = new Date(); const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0'); PagerAPI.post('/api/settings/synctime', { timestamp: stamp }) .then(() => App.toast('Pager time synchronized')).catch(apiError('Failed to synchronize time')); }, 'ghost')); user.appendChild(h('label', {}, 'Timezone', tzSel)); user.appendChild(tzActions); PagerAPI.get('/api/settings/timezone').then((r) => { const value = (r.data || {}).timezone || 'UTC'; if (!Array.from(tzSel.options).some((o) => o.value === value)) { tzSel.insertBefore(h('option', { value, text: value + ' (current)' }), tzSel.firstChild); } tzSel.value = value; }).catch(apiError('Unable to load timezone')); const ntp = settingsCard(box, 'Network Time', 'Configure the Pager\'s NTP client.'); const ntpEnabled = h('input', { type: 'checkbox' }); const ntpServers = h('input', {}); ntp.appendChild(h('label', { class: 'switch' }, ntpEnabled, h('span', { class: 'track' }), 'Enabled')); ntp.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers)); ntp.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', { enabled: ntpEnabled.checked, servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) }).then(() => App.toast('Network time settings saved')).catch(apiError('Failed to save network time')))); PagerAPI.get('/api/settings/ntp').then((r) => { ntpEnabled.checked = !!r.data.enabled; ntpServers.value = (r.data.servers || []).join(', '); }).catch(apiError('Unable to load network time settings')); const details = settingsCard(box, 'Device & Web Interface'); const detailBody = h('div', { class: 'settings-kv-grid' }); details.appendChild(detailBody); Promise.all([PagerAPI.get('/api/status'), PagerAPI.get('/api/settings/service')]).then(([status, service]) => { const s = status.data || {}, svc = service.data || {}; [['Model', 'WiFi Pineapple Pager'], ['Firmware', s.firmware || 'Unavailable'], ['Uptime', fmtDur(s.uptime)], ['Disk', s.disk && s.disk.size != null ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'], ['WebUI Service', (svc.running ? 'Running' : 'Stopped') + (svc.background ? ' (background)' : '')]] .forEach(([k, v]) => detailBody.appendChild(h('div', { class: 'settings-kv' }, h('span', { text: k }), h('code', { text: v })))); }).catch(apiError('Unable to load device details')); settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.'); const resources = settingsCard(box, 'Resources'); const resourceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); resources.appendChild(resourceBody); PagerAPI.get('/api/settings/resources').then((r) => { resourceBody.innerHTML = ''; resourceBody.appendChild(table([ { label: 'Filesystem', key: 'filesystem' }, { label: 'Format', key: 'format' }, { label: 'Size', render: (x) => fmtBytes(x.size) }, { label: 'Used', render: (x) => fmtBytes(x.used) }, { label: 'Available', render: (x) => fmtBytes(x.available) }, { label: 'Used %', key: 'used_percent' }, { label: 'Mount Point', key: 'mount' } ], (r.data || {}).filesystems || [])); }).catch(() => { resourceBody.textContent = 'Unable to load filesystems.'; }); const usb = settingsCard(box, 'USB Devices'); const usbBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); usb.appendChild(usbBody); PagerAPI.get('/api/settings/usb').then((r) => { usbBody.innerHTML = ''; const rows = (r.data || {}).devices || []; usbBody.appendChild(table([{ label: 'Bus', key: 'bus' }, { label: 'Device', key: 'device' }, { label: 'VID:PID', key: 'id' }, { label: 'Name', key: 'name' }], rows)); if (!rows.length) usbBody.appendChild(h('div', { class: 'empty', text: 'No USB devices reported.' })); }).catch(() => { usbBody.textContent = 'Unable to load USB devices.'; }); return { destroy: () => {} }; }; views.settings_networking = (root) => { const box = settingsShell(root, '#/settings/networking'); settingsCard(box, 'Wireless Client Mode', 'The Pager firmware owns client-mode association. Status is shown here; connection changes remain in the native Pager interface to protect PineAP radio state.'); const recon = settingsCard(box, 'Recon Wireless Interfaces'); const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' }); recon.appendChild(reconBody); const interfaces = settingsCard(box, 'Interfaces'); const interfaceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); interfaces.appendChild(interfaceBody); const routes = settingsCard(box, 'Routing Table'); const routeBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); routes.appendChild(routeBody); PagerAPI.get('/api/settings/network').then((r) => { const d = r.data || {}; reconBody.innerHTML = ''; (d.recon_interfaces || []).forEach((name) => reconBody.appendChild(h('code', { class: 'settings-chip', text: name }))); if (!(d.recon_interfaces || []).length) reconBody.textContent = 'No monitor interfaces detected.'; interfaceBody.innerHTML = ''; interfaceBody.appendChild(table([{ label: 'Name', key: 'name' }, { label: 'IP Address', render: (x) => (x.addresses || []).join(', ') || '—' }, { label: 'MAC Address', render: (x) => x.mac || '—' }, { label: 'Flags', render: (x) => (x.flags || []).join(', ') }], d.interfaces || [])); routeBody.innerHTML = ''; routeBody.appendChild(table([{ label: 'Destination', key: 'destination' }, { label: 'Gateway', key: 'gateway' }, { label: 'Genmask', key: 'genmask' }, { label: 'Interface', key: 'interface' }, { label: 'Flags', key: 'flags' }, { label: 'Metric', key: 'metric' }, { label: 'Ref', key: 'ref' }, { label: 'Use', key: 'use' }], d.routes || [])); }).catch(apiError('Unable to load networking information')); return { destroy: () => {} }; }; views.settings_wifi = (root) => { const box = settingsShell(root, '#/settings/wifi'); const card = settingsCard(box, 'Management Access Point', 'Configure the Pager management WiFi interface. Saving reloads wireless services; USB management access remains available.'); const ssid = h('input', {}), bssid = h('input', { placeholder: 'Optional' }); const password = h('input', { type: 'password', placeholder: 'Leave blank to keep the existing password', autocomplete: 'new-password' }); const confirm = h('input', { type: 'password', placeholder: 'Repeat only when changing it', autocomplete: 'new-password' }); const hidden = h('input', { type: 'checkbox' }), enabled = h('input', { type: 'checkbox' }); card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Management SSID', ssid), h('label', {}, 'BSSID', bssid))); card.appendChild(h('label', { class: 'switch' }, hidden, h('span', { class: 'track' }), 'Hidden')); card.appendChild(h('label', { class: 'switch' }, enabled, h('span', { class: 'track' }), 'Enabled')); card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Password', password), h('label', {}, 'Confirm Password', confirm))); const save = btn('Save', () => { if (password.value !== confirm.value) { App.toast('Management passwords do not match', 'error'); return; } save.disabled = true; PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(), password: password.value, hidden: hidden.checked, enabled: enabled.checked }) .then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); }) .catch(apiError('Failed to save management WiFi')).finally(() => { save.disabled = false; }); }); card.appendChild(save); PagerAPI.get('/api/settings/wifi/management').then((r) => { const d = r.data || {}; ssid.value = d.ssid || ''; bssid.value = d.bssid || ''; hidden.checked = !!d.hidden; enabled.checked = !!d.enabled; }).catch(apiError('Unable to load management WiFi')); return { destroy: () => {} }; }; views.settings_led = (root) => { const box = settingsShell(root, '#/settings/led'); const card = settingsCard(box, 'LED Configuration', 'Pager-specific equivalents of the Mark VII LED controls. Settings are stored in the native Pager configuration.'); const color = h('select', {}); ['red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white'].forEach((v) => color.appendChild(h('option', { value: v, text: v[0].toUpperCase() + v.slice(1) }))); const vibrate = h('input', { type: 'checkbox' }), clock24 = h('input', { type: 'checkbox' }); const lcd = h('input', { type: 'number', min: '1', max: '11' }); const dim = h('input', { type: 'number', min: '0', max: '11' }); const dimTimeout = h('input', { type: 'number', min: '0', max: '3600' }); const lcdTimeout = h('input', { type: 'number', min: '0', max: '86400' }); card.appendChild(h('label', {}, 'Status LED color', color)); card.appendChild(h('label', { class: 'switch' }, vibrate, h('span', { class: 'track' }), 'Vibrate with alerts')); card.appendChild(h('label', { class: 'switch' }, clock24, h('span', { class: 'track' }), '24-hour clock')); card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'LCD brightness (1–11)', lcd), h('label', {}, 'Dim brightness (0–11)', dim), h('label', {}, 'Dim timeout (seconds)', dimTimeout), h('label', {}, 'LCD timeout (seconds)', lcdTimeout))); function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked, lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value), dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; } card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values()) .then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences')))); PagerAPI.get('/api/settings/hardware').then((r) => { const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate; clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness; dimTimeout.value = d.dim_timeout; lcdTimeout.value = d.lcd_timeout; }).catch(apiError('Unable to load hardware preferences')); return { destroy: () => {} }; }; views.settings_advanced = (root) => { const box = settingsShell(root, '#/settings/advanced'); const updates = settingsCard(box, 'Alternative Updates', 'Select the Pager software update channel. Update checking and firmware installation remain in the native Pager workflow.'); const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }), h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' })); updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel)); updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value }) .then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel')))); const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.'); const hostname = h('input', {}); host.appendChild(h('label', {}, 'Hostname', hostname)); host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() }) .then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname')))); const webui = settingsCard(box, 'Web Interface'); const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' })); const poll = h('input', { type: 'number', min: '2', max: '120' }); const hotkeys = h('input', { type: 'checkbox' }); theme.value = Theme.current(); poll.value = localStorage.getItem('pw-poll') || '5'; hotkeys.checked = localStorage.getItem('pw_hotkeys') !== 'false'; webui.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'UI Theme', theme), h('label', {}, 'Poll interval (seconds)', poll))); webui.appendChild(h('label', { class: 'switch' }, hotkeys, h('span', { class: 'track' }), 'Enable Hot Keys')); webui.appendChild(btn('Save Web Interface', () => { localStorage.setItem(Theme.KEY, theme.value); localStorage.setItem('pw-poll', poll.value); localStorage.setItem('pw_hotkeys', hotkeys.checked ? 'true' : 'false'); Theme.apply(); App.toast('Web interface preferences saved'); })); PagerAPI.get('/api/settings/advanced').then((r) => { channel.value = r.data.update_channel || 'stable'; hostname.value = r.data.hostname || ''; }) .catch(apiError('Unable to load advanced settings')); return { destroy: () => {} }; }; views.settings_help = (root) => { const box = settingsShell(root, '#/settings/help'); const help = settingsCard(box, 'Help & Information'); help.appendChild(h('p', {}, 'Documentation: ', h('a', { href: 'https://docs.hak5.org/wifi-pineapple/', target: '_blank', rel: 'noopener', text: 'docs.hak5.org' }))); help.appendChild(h('p', {}, 'Community support: ', h('a', { href: 'https://community.hak5.org/', target: '_blank', rel: 'noopener', text: 'community.hak5.org' }))); help.appendChild(h('p', {}, 'Downloads: ', h('a', { href: 'https://downloads.hak5.org/', target: '_blank', rel: 'noopener', text: 'downloads.hak5.org' }))); const diagnostics = settingsCard(box, 'Diagnostics', 'Generate a local support report containing device, network, storage, radio, USB, and recent log information.'); const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' }); let report = ''; const generate = btn('Generate Diagnostics', () => { generate.disabled = true; output.textContent = 'Generating diagnostics…'; PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; }) .catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); }) .finally(() => { generate.disabled = false; }); }); diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate, btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost'))); diagnostics.appendChild(output); const license = settingsCard(box, 'Licenses'); license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' })); return { destroy: () => {} }; };