Files
Mark-VIII/payload/user/remote_access/pager-webui/www/js/app.js
T
c4ch3c4d3 88d7141d45 feat(deauth,evilportal,capture): bulk deauth UX, Hak5-compatible Evil Portal, monitor capture fixes
- Recon AP focus sidebar: 'Deauth All Clients' with engagement-scope confirm
- Deauth Targeting card: 'Deauth All' behind the same scope confirmation
- New POST /api/attacks/deauth/bulk (max 32 targets, per-target results)
- Evil Portal tab: import EvilPortalNano-format portal zips into
  /mmc/mk8/portals, serve active portal on port 80 to unauthenticated
  clients via a minimal PHP shim, capture all form POSTs (.logs in stock
  MyPortal.php format + captures.jsonl), dnsmasq address=/#/ DNS hijack
- OpenAP: Evil Portal template dropdown (greyed when none), activated with
  the attack and stopped with it
- Monitor Capture fix: iface-less status now reports whichever monitor is
  actually capturing; pcap dir mkdir'd; tcpdump stderr surfaced instead of
  discarded
2026-08-23 19:50:41 -06:00

642 lines
24 KiB
JavaScript

'use strict';
const Theme = (() => {
const KEY = 'pw_theme';
function apply() {
document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark');
}
function toggle() {
localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark');
apply();
}
function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; }
return { apply, toggle, current, KEY };
})();
const App = (() => {
const cfg = window.PAGER_CONFIG || {};
const API_BASE = cfg.apiBase || '';
const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws');
const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs');
const PAGER_SCREEN_WS = cfg.pagerScreenWs || (WS_BASE + '/api/pager/display/screen.ws');
const PAGER_KEYS_WS = cfg.pagerKeysWs || (WS_BASE + '/api/pager/input/keys.ws');
const els = {};
let currentView = null;
let notifications = [];
let unreadNotifications = 0;
const NOTIFICATIONS_KEY = 'markviii_notifications';
const railItems = [
{ key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' },
{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' },
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
{ key: 'evilportal', label: 'Evil Portal', hash: '#/evilportal', icon: 'portal' },
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
{ key: 'harness', label: 'Harness', hash: '#/harness', icon: 'robot' },
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
];
const railDividers = new Set(['logging']);
function keyOf(hash) {
const seg = (hash || '#/dashboard').replace(/^#\//, '').split('/')[0];
return seg || 'dashboard';
}
function buildRail() {
const rail = els.rail;
rail.innerHTML = '';
function railEntry(it) {
const a = document.createElement('a');
a.className = 'entry';
a.href = it.hash;
a.title = it.label;
a.innerHTML = '<span class="entry-icon">' + PineappleIcons[it.icon] + '</span><span class="entry-text">' + it.label + '</span>';
a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; });
return a;
}
railItems.filter((it) => it.key !== 'settings').forEach((it) => {
if (railDividers.has(it.key)) {
const d = document.createElement('div');
d.className = 'entry divider';
rail.appendChild(d);
}
rail.appendChild(railEntry(it));
});
const foot = document.createElement('div');
foot.className = 'rail-footer';
const settings = railItems.find((it) => it.key === 'settings');
if (settings) foot.appendChild(railEntry(settings));
const open = document.createElement('a');
open.className = 'entry';
open.title = 'Open Menu';
open.innerHTML = '<span class="entry-icon">' + PineappleIcons.chevron_right + '</span><span class="entry-text">Open Menu</span>';
open.addEventListener('click', (e) => {
e.preventDefault();
const openState = localStorage.getItem('pw_rail') === 'open';
localStorage.setItem('pw_rail', openState ? 'closed' : 'open');
els.rail.classList.toggle('open', !openState);
});
foot.appendChild(open);
rail.appendChild(foot);
rail.classList.toggle('open', localStorage.getItem('pw_rail') === 'open');
}
function route() {
closeToolbarMenus();
let hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
if (hash === '#/recon/survey') {
location.hash = '#/recon';
return;
}
if (hash.indexOf('#/attacks') === 0) {
const map = {
'#/attacks': '#/pineap',
'#/attacks/wpa': '#/pineap/evilwpa',
'#/attacks/open': '#/pineap/open',
'#/attacks/enterprise': '#/pineap/enterprise'
};
hash = map[hash] || '#/pineap';
location.replace(hash);
return;
}
const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = '';
if (!name || !views[name]) {
const ph = document.createElement('div');
ph.className = 'section empty';
ph.textContent = 'View not available.';
els.content.appendChild(ph);
currentView = null;
} else {
currentView = views[name](els.content);
}
const key = keyOf(hash);
Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => {
const href = a.getAttribute('href');
a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key));
});
}
function loadNotifications() {
try {
const stored = JSON.parse(sessionStorage.getItem(NOTIFICATIONS_KEY) || '{}');
notifications = Array.isArray(stored.items) ? stored.items.slice(0, 30) : [];
unreadNotifications = Math.max(0, Number(stored.unread) || 0);
} catch (e) {
notifications = [];
unreadNotifications = 0;
}
}
function saveNotifications() {
try {
sessionStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify({
items: notifications.slice(0, 30), unread: unreadNotifications
}));
} catch (e) {}
}
function notificationTime(timestamp) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
const today = new Date();
if (date.toDateString() === today.toDateString()) {
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function renderNotifications() {
if (!els.notificationsList) return;
els.notificationsList.innerHTML = '';
els.notificationBadge.classList.toggle('hidden', unreadNotifications === 0);
els.notificationBadge.textContent = unreadNotifications > 99 ? '99+' : String(unreadNotifications);
els.notificationsClear.disabled = notifications.length === 0;
if (!notifications.length) {
const empty = document.createElement('div');
empty.className = 'notification-empty';
empty.textContent = 'No Notifications';
els.notificationsList.appendChild(empty);
return;
}
notifications.forEach((notice) => {
const item = document.createElement('div');
item.className = 'notification-item';
const marker = document.createElement('span');
marker.className = 'notification-kind ' + (notice.kind || 'info');
const message = document.createElement('span');
message.className = 'notification-message';
message.textContent = notice.message;
const time = document.createElement('time');
time.className = 'notification-time';
time.dateTime = new Date(notice.timestamp).toISOString();
time.textContent = notificationTime(notice.timestamp);
item.append(marker, message, time);
els.notificationsList.appendChild(item);
});
}
function addNotification(message, kind) {
const normalizedKind = ['error', 'success'].indexOf(kind) === -1 ? 'info' : kind;
notifications.unshift({
message: String(message).slice(0, 300), kind: normalizedKind, timestamp: Date.now()
});
notifications = notifications.slice(0, 30);
unreadNotifications += 1;
saveNotifications();
renderNotifications();
}
function toast(msg, kind, options) {
if (!options || options.notify !== false) addNotification(msg, kind);
const d = document.createElement('div');
d.className = 'toast ' + (kind || 'info');
d.textContent = msg;
els.toasts.appendChild(d);
setTimeout(() => d.remove(), 4000);
}
function closeToolbarMenus() {
if (!els.notificationsMenu) return;
els.notificationsMenu.classList.add('hidden');
els.overflowMenu.classList.add('hidden');
els.notificationsButton.setAttribute('aria-expanded', 'false');
els.overflowButton.setAttribute('aria-expanded', 'false');
}
function toggleToolbarMenu(name) {
const menu = name === 'notifications' ? els.notificationsMenu : els.overflowMenu;
const button = name === 'notifications' ? els.notificationsButton : els.overflowButton;
const opening = menu.classList.contains('hidden');
closeToolbarMenus();
if (!opening) return;
menu.classList.remove('hidden');
button.setAttribute('aria-expanded', 'true');
if (name === 'notifications') {
unreadNotifications = 0;
saveNotifications();
renderNotifications();
}
}
function setInternetStatus(state) {
const labels = { checking: 'Checking\u2026', online: 'Online', offline: 'Offline' };
els.internetStatus.textContent = labels[state];
els.internetStatusDot.className = 'connection-dot ' + state;
}
function checkInternet(announce) {
setInternetStatus('checking');
return PagerAPI.get('/api/settings/internet')
.then((response) => {
const online = !!response.data.online;
setInternetStatus(online ? 'online' : 'offline');
if (announce) toast(online ? 'Internet connection is online' : 'Internet connection is offline', online ? 'success' : 'error');
})
.catch(() => {
setInternetStatus('offline');
if (announce) toast('Unable to check the internet connection', 'error');
});
}
function handleMenuAction(action, button) {
closeToolbarMenus();
if (action === 'help') {
location.hash = '#/settings/help';
} else if (action === 'updates') {
location.hash = '#/settings/advanced';
toast('Update controls are available in Advanced settings');
} else if (action === 'internet') {
if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
else checkInternet(true);
} else if (action === 'logout') {
if (button) { button.disabled = true; button.classList.add('busy'); }
PagerAPI.post('/api/logout')
.then(() => showLogin())
.catch((error) => toast(error.message || 'Logout failed', 'error'))
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
} else if (action === 'reboot') {
if (!window.confirm('Reboot Mark VIII now?')) return;
if (button) { button.disabled = true; button.classList.add('busy'); }
PagerAPI.post('/api/settings/reboot')
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
.catch((error) => toast(error.message || 'Reboot failed', 'error'))
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
}
}
function showDock(name) {
if (name === 'pager') {
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
} else {
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
}
}
function showApp() {
els.login.classList.add('hidden');
els.app.classList.remove('hidden');
Theme.apply();
Live.start();
route();
checkInternet(false);
}
function showLogin() {
closeToolbarMenus();
if (typeof Live !== 'undefined') Live.stop();
if (typeof Term !== 'undefined' && Term.isOpen()) Term.close();
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
els.app.classList.add('hidden');
els.login.classList.remove('hidden');
}
function init() {
els.login = document.getElementById('login-screen');
els.app = document.getElementById('app');
els.rail = document.getElementById('rail');
els.content = document.getElementById('content');
els.toasts = document.getElementById('toast-container');
els.notificationsButton = document.getElementById('notifications-btn');
els.notificationsMenu = document.getElementById('notifications-menu');
els.notificationsList = document.getElementById('notifications-list');
els.notificationsClear = document.getElementById('notifications-clear');
els.notificationBadge = document.getElementById('notification-badge');
els.overflowButton = document.getElementById('overflow-btn');
els.overflowMenu = document.getElementById('overflow-menu');
els.internetStatus = document.getElementById('internet-status');
els.internetStatusDot = document.getElementById('internet-status-dot');
els.notificationsButton.innerHTML = PineappleIcons.notifications;
document.getElementById('terminal-btn').innerHTML = PineappleIcons.terminal;
document.getElementById('pager-btn').innerHTML = PineappleIcons.pager;
els.overflowButton.innerHTML = PineappleIcons.more_vert;
Array.prototype.forEach.call(document.querySelectorAll('[data-icon]'), (icon) => {
icon.innerHTML = PineappleIcons[icon.getAttribute('data-icon')] || '';
});
loadNotifications();
renderNotifications();
els.notificationsButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleToolbarMenu('notifications');
});
els.overflowButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleToolbarMenu('overflow');
});
els.notificationsMenu.addEventListener('click', (event) => event.stopPropagation());
els.overflowMenu.addEventListener('click', (event) => event.stopPropagation());
els.notificationsClear.addEventListener('click', () => {
notifications = [];
unreadNotifications = 0;
saveNotifications();
renderNotifications();
});
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'), item));
});
document.addEventListener('click', closeToolbarMenus);
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
const btn = document.getElementById('login-button');
const pw = document.getElementById('login-password').value;
document.getElementById('login-error').textContent = '';
btn.disabled = true;
btn.classList.add('busy');
PagerAPI.login('root', pw)
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
.catch((err) => {
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
? 'Login failed.' : 'Invalid credentials.';
})
.finally(() => { btn.disabled = false; btn.classList.remove('busy'); });
});
document.getElementById('terminal-btn').addEventListener('click', () => {
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
showDock('terminal');
Term.toggle();
});
document.getElementById('terminal-close').addEventListener('click', () => {
if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; }
showDock('terminal');
Term.toggle();
});
document.getElementById('pager-btn').addEventListener('click', () => {
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
showDock('pager');
Pager.toggle();
});
document.getElementById('pager-close').addEventListener('click', () => {
if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; }
showDock('pager');
Pager.toggle();
});
window.addEventListener('hashchange', route);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeToolbarMenus();
return;
}
if (localStorage.getItem('pw_hotkeys') === 'false') return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const map = { d: '#/dashboard', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' };
if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; }
else if (e.key === '`') {
if (typeof Term === 'undefined') return;
if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close();
Term.toggle();
}
});
PagerAPI.setBase(API_BASE);
PagerAPI.on401(() => showLogin());
buildRail();
Theme.apply();
checkSession();
}
function checkSession() {
PagerAPI.get('/api/api_ping')
.then(() => showApp())
.catch(() => showLogin());
}
const routes = {
'#/dashboard': 'dashboard',
'#/pineap': 'pineap',
'#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/enterprise': 'pineap_enterprise',
'#/pineap/open': 'pineap_open',
'#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
'#/recon': 'recon',
'#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes',
'#/evilportal': 'evilportal',
'#/logging': 'logging',
'#/logging/system': 'logging_system',
'#/modules': 'modules',
'#/modules/online': 'modules_online',
'#/modules/running': 'modules_running',
'#/modules/develop': 'modules_develop',
'#/settings': 'settings',
'#/settings/networking': 'settings_networking',
'#/settings/wifi': 'settings_wifi',
'#/settings/led': 'settings_led',
'#/settings/advanced': 'settings_advanced',
'#/settings/help': 'settings_help',
'#/harness': 'harness'
};
return { init, route, toast, showLogin, checkInternet, wsUrl: (p) => WS_BASE + p,
terminalWs: TERMINAL_WS, pagerScreenWs: PAGER_SCREEN_WS,
pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
keyOf, railItems, go: (h) => { location.hash = h; },
get key() { return keyOf(location.hash); } };
})();
const Live = (() => {
let ws = null;
let ever = false;
let poll = null;
let pollHealthTimer = null;
let pollEventsTimer = null;
let pollRfTimer = null;
let rfState = null;
let rfChannel = null;
const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false,
pineapUp: null, mon0: null, mon1: null };
const subs = [];
let timer = null;
function stopPoll() {
if (poll) { clearInterval(poll); poll = null; }
}
function start() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
stopPoll();
if (!pollHealthTimer) {
pollHealthTimer = setInterval(pollHealth, 15000);
pollHealth();
}
if (!pollEventsTimer) {
pollEventsTimer = setInterval(pollEvents, 15000);
pollEvents();
}
if (!pollRfTimer) {
pollRfTimer = setInterval(pollRfplan, 15000);
pollRfplan();
}
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
catch (e) { fallback(); return; }
ws.onopen = () => { ever = true; };
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
subs.forEach((fn) => fn(msg));
updateBar(msg);
};
ws.onclose = () => {
ws = null;
clearTimeout(timer);
if (ever) timer = setTimeout(start, 5000);
else fallback();
};
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
function stop() {
ever = false;
clearTimeout(timer);
timer = null;
stopPoll();
if (ws) {
const active = ws;
ws = null;
active.onclose = null;
try { active.close(); } catch (e) {}
}
}
function fallback() {
stopPoll();
const seconds = Math.max(2, Math.min(120, parseInt(localStorage.getItem('pw-poll') || '5', 10) || 5));
poll = setInterval(async () => {
try {
const r = await PagerAPI.get('/api/status');
const msg = { type: 'tick', status: r.data, clients: r.data.clients };
subs.forEach((fn) => fn(msg));
updateBar(msg);
} catch (e) {}
}, seconds * 1000);
}
function updateBar(msg) {
const b = (msg.status || {}).battery || {};
const n = (msg.clients || []).length;
const el = document.getElementById('live-status');
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
const wifi = (msg.status || {}).wifi;
if (Array.isArray(wifi)) {
const up = wifi.find((w) => w && w.iface === 'wlan1up');
rfChannel = up && up.channel != null ? Number(up.channel) : null;
renderRfChip();
}
}
function pollRfplan() {
fetch(App.apiBase + '/api/rfplan', { credentials: 'include' }).then((r) => {
if (!r.ok) throw new Error('http ' + r.status);
return r.json();
}).then((d) => {
rfState = d && typeof d.role === 'string' ? d : null;
renderRfChip();
}).catch(() => {
rfState = null;
const el = document.getElementById('rf-chip');
if (el) {
el.textContent = 'PHY1: ?';
el.title = 'RF plan unavailable';
el.className = 'health-chip warn';
}
});
}
function renderRfChip() {
const el = document.getElementById('rf-chip');
if (!el) return;
if (!rfState) {
el.textContent = '';
el.title = '';
el.className = 'health-chip';
return;
}
let text;
let cls = '';
if (rfState.role === 'uplink') {
text = 'PHY1: UPLINK' + (rfState.assoc && rfChannel ? ' ch' + rfChannel : '');
cls = rfState.assoc ? 'good' : 'warn';
} else if (rfState.role === 'attack') {
text = 'PHY1: ATTACK';
cls = 'warn';
} else if (rfState.role === 'idle') {
text = 'PHY1: IDLE';
} else {
text = 'PHY1: ' + String(rfState.role).toUpperCase();
}
el.textContent = text;
el.className = 'health-chip' + (cls ? ' ' + cls : '');
el.title = 'radio1 role: ' + rfState.role +
' \u00b7 assoc ' + (rfState.assoc || 'none') +
' \u00b7 hop ' + (rfState.hop_paused == null ? 'unknown' : rfState.hop_paused ? 'paused' : 'running');
}
function pollHealth() {
fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json())
.then((h) => {
const el = document.getElementById('health-status');
if (!el) return;
if (h.pineap_up === false) {
el.textContent = 'PINEAPD DOWN';
el.className = 'health-chip bad';
} else if (h.env && h.env.overall === 'fail') {
el.textContent = 'ENV CHECK FAIL';
el.className = 'health-chip bad';
} else if (h.pool_disabled) {
el.textContent = 'POOL OFF';
el.className = 'health-chip warn';
} else if (h.pineap_up) {
el.textContent = 'PINEAP OK';
el.className = 'health-chip good';
}
const prev = lastEvents;
if (prev.pineapUp === false && h.pineap_up) {
toast('PineAPd recovered', 'success');
} else if (prev.pineapUp === true && h.pineap_up === false) {
toast('PineAPd is down — health monitor is repairing it', 'error');
}
lastEvents.pineapUp = !!h.pineap_up;
const monState = [h.wlan0mon_up, h.wlan1mon_up];
if (prev.mon0 === true && monState[0] === false) toast('wlan0mon went down', 'error');
if (prev.mon1 === true && monState[1] === false) toast('wlan1mon went down', 'error');
lastEvents.mon0 = !!monState[0];
lastEvents.mon1 = !!monState[1];
}).catch(() => {});
}
function pollEvents() {
Promise.all([
fetch(App.apiBase + '/api/pineap/handshakes', { credentials: 'include' }).then((r) => r.json()).catch(() => ({})),
fetch(App.apiBase + '/api/attacks/status', { credentials: 'include' }).then((r) => r.json()).catch(() => ({}))
]).then(([hs, atk]) => {
const files = (hs && hs.files) || [];
const fresh = files.filter((f) => !lastEvents.hsSeen[f.name]);
if (lastEvents.hsPrimed && fresh.length) {
fresh.forEach((f) => {
const bssid = (f.name.match(/^[0-9]+_([0-9A-F]+)_/) || [])[1] || '';
toast('Handshake captured: ' + (bssid || f.name), 'success');
});
}
files.forEach((f) => { lastEvents.hsSeen[f.name] = true; });
lastEvents.hsPrimed = true;
const creds = ((atk.enterprise || {}).creds) == null ? null : atk.enterprise.creds;
if (lastEvents.credsPrimed && creds !== null && creds > lastEvents.creds) {
toast('Enterprise credential captured (' + (creds - lastEvents.creds) + ' new)', 'success');
}
if (creds !== null) lastEvents.creds = creds;
lastEvents.credsPrimed = true;
}).catch(() => {});
}
function onTick(fn) {
subs.push(fn);
return () => {
const i = subs.indexOf(fn);
if (i !== -1) subs.splice(i, 1);
};
}
return { start, stop, onTick };
})();
document.addEventListener('DOMContentLoaded', () => App.init());