mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 08:52:00 +00:00
- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads. - Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications. - Both classes include error handling and JSON response formatting.
529 lines
17 KiB
JavaScript
529 lines
17 KiB
JavaScript
/**
|
|
* Bifrost — Pwnagotchi Mode SPA page
|
|
* Real-time WiFi recon dashboard with face, mood, activity feed, networks, plugins.
|
|
*/
|
|
import { ResourceTracker } from '../core/resource-tracker.js';
|
|
import { api, Poller } from '../core/api.js';
|
|
import { el, $, $$, empty, toast, escapeHtml, confirmT } from '../core/dom.js';
|
|
import { t } from '../core/i18n.js';
|
|
|
|
const PAGE = 'bifrost';
|
|
|
|
/* ── State ─────────────────────────────────────────────── */
|
|
|
|
let tracker = null;
|
|
let poller = null;
|
|
let root = null;
|
|
|
|
let bifrostEnabled = false;
|
|
let status = {};
|
|
let stats = {};
|
|
let networks = [];
|
|
let activity = [];
|
|
let plugins = [];
|
|
let epochs = [];
|
|
let sideTab = 'networks'; // 'networks' | 'plugins' | 'history'
|
|
|
|
/* ── Lifecycle ─────────────────────────────────────────── */
|
|
|
|
export async function mount(container) {
|
|
tracker = new ResourceTracker(PAGE);
|
|
root = buildShell();
|
|
container.appendChild(root);
|
|
bindEvents();
|
|
await refresh();
|
|
poller = new Poller(refresh, 4000);
|
|
poller.start();
|
|
}
|
|
|
|
export function unmount() {
|
|
if (poller) { poller.stop(); poller = null; }
|
|
if (tracker) { tracker.cleanupAll(); tracker = null; }
|
|
root = null;
|
|
networks = [];
|
|
activity = [];
|
|
plugins = [];
|
|
epochs = [];
|
|
}
|
|
|
|
/* ── Shell ─────────────────────────────────────────────── */
|
|
|
|
function buildShell() {
|
|
return el('div', { class: 'bifrost-page' }, [
|
|
|
|
/* ── Header ───────────────────────────────────────── */
|
|
el('div', { class: 'bifrost-header' }, [
|
|
el('h1', { class: 'bifrost-title' }, [
|
|
el('span', { class: 'bifrost-title-icon' }, ['🌈']),
|
|
el('span', { 'data-i18n': 'bifrost.title' }, [t('bifrost.title')]),
|
|
]),
|
|
el('div', { class: 'bifrost-controls' }, [
|
|
el('button', { class: 'bifrost-btn', id: 'bifrost-toggle' }, [
|
|
el('span', { class: 'dot' }),
|
|
el('span', { class: 'bifrost-toggle-label', 'data-i18n': 'bifrost.disabled' }, [t('bifrost.disabled')]),
|
|
]),
|
|
el('button', { class: 'bifrost-btn', id: 'bifrost-mode' }, [
|
|
el('span', { class: 'bifrost-mode-label' }, ['Auto']),
|
|
]),
|
|
]),
|
|
]),
|
|
|
|
/* ── Stats bar ────────────────────────────────────── */
|
|
el('div', { class: 'bifrost-stats', id: 'bifrost-stats' }),
|
|
|
|
/* ── Main grid ────────────────────────────────────── */
|
|
el('div', { class: 'bifrost-grid' }, [
|
|
|
|
/* Left: Live view */
|
|
el('div', { class: 'bifrost-panel bifrost-live' }, [
|
|
el('div', { class: 'bifrost-face-wrap' }, [
|
|
el('div', { class: 'bifrost-face', id: 'bifrost-face' }, ['(. .)']),
|
|
el('div', { class: 'bifrost-mood', id: 'bifrost-mood' }, ['sleeping']),
|
|
el('div', { class: 'bifrost-voice', id: 'bifrost-voice' }),
|
|
]),
|
|
el('div', { class: 'bifrost-info-row', id: 'bifrost-info' }),
|
|
el('div', { class: 'bifrost-panel-head' }, [
|
|
el('span', { 'data-i18n': 'bifrost.activityFeed' }, [t('bifrost.activityFeed')]),
|
|
el('button', {
|
|
class: 'bifrost-btn', id: 'bifrost-clear-activity',
|
|
style: 'padding:3px 8px;font-size:0.65rem',
|
|
}, [t('bifrost.clearActivity')]),
|
|
]),
|
|
el('div', { class: 'bifrost-activity', id: 'bifrost-activity' }),
|
|
]),
|
|
|
|
/* Right: sidebar */
|
|
el('div', { class: 'bifrost-panel' }, [
|
|
el('div', { class: 'bifrost-side-tabs' }, [
|
|
sideTabBtn('networks', t('bifrost.networks')),
|
|
sideTabBtn('plugins', t('bifrost.plugins')),
|
|
sideTabBtn('history', t('bifrost.history')),
|
|
]),
|
|
el('div', { class: 'bifrost-sidebar', id: 'bifrost-sidebar' }),
|
|
]),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
function sideTabBtn(id, label) {
|
|
return el('button', {
|
|
class: `bifrost-side-tab${sideTab === id ? ' active' : ''}`,
|
|
'data-btab': id,
|
|
}, [label]);
|
|
}
|
|
|
|
/* ── Events ────────────────────────────────────────────── */
|
|
|
|
function bindEvents() {
|
|
root.addEventListener('click', async (e) => {
|
|
// Toggle enable/disable — BIFROST is a 4th exclusive mode
|
|
const toggle = e.target.closest('#bifrost-toggle');
|
|
if (toggle) {
|
|
const willEnable = !bifrostEnabled;
|
|
// Warn user: enabling puts WiFi in monitor mode, kills network
|
|
if (willEnable && !confirmT(t('bifrost.confirmEnable'))) return;
|
|
try {
|
|
const res = await api.post('/api/bifrost/toggle', { enabled: willEnable });
|
|
bifrostEnabled = res.enabled;
|
|
paintToggle();
|
|
} catch (err) { toast(err.message, 3000, 'error'); }
|
|
return;
|
|
}
|
|
|
|
// Toggle mode
|
|
const modeBtn = e.target.closest('#bifrost-mode');
|
|
if (modeBtn) {
|
|
const newMode = status.mode === 'auto' ? 'manual' : 'auto';
|
|
try {
|
|
await api.post('/api/bifrost/mode', { mode: newMode });
|
|
status.mode = newMode;
|
|
paintMode();
|
|
} catch (err) { toast(err.message, 3000, 'error'); }
|
|
return;
|
|
}
|
|
|
|
// Clear activity
|
|
if (e.target.closest('#bifrost-clear-activity')) {
|
|
try {
|
|
await api.post('/api/bifrost/activity/clear', {});
|
|
toast(t('bifrost.activityCleared'), 2000, 'success');
|
|
activity = [];
|
|
paintActivity();
|
|
} catch (err) { toast(err.message, 3000, 'error'); }
|
|
return;
|
|
}
|
|
|
|
// Side tab switch
|
|
const stab = e.target.closest('[data-btab]');
|
|
if (stab) {
|
|
sideTab = stab.dataset.btab;
|
|
$$('.bifrost-side-tab', root).forEach(b =>
|
|
b.classList.toggle('active', b.dataset.btab === sideTab));
|
|
paintSidebar();
|
|
return;
|
|
}
|
|
|
|
// Plugin toggle
|
|
const pluginToggle = e.target.closest('[data-plugin-toggle]');
|
|
if (pluginToggle) {
|
|
const name = pluginToggle.dataset.pluginToggle;
|
|
const plugin = plugins.find(p => p.name === name);
|
|
if (plugin) {
|
|
try {
|
|
await api.post('/api/bifrost/plugin/toggle', { name, enabled: !plugin.enabled });
|
|
await refreshPlugins();
|
|
} catch (err) { toast(err.message, 3000, 'error'); }
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ── Data refresh ──────────────────────────────────────── */
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [statusData, statsData, actData] = await Promise.all([
|
|
api.get('/api/bifrost/status'),
|
|
api.get('/api/bifrost/stats'),
|
|
api.get('/api/bifrost/activity?limit=50'),
|
|
]);
|
|
status = statusData || {};
|
|
stats = statsData || {};
|
|
bifrostEnabled = status.enabled || false;
|
|
activity = actData.activity || [];
|
|
paint();
|
|
} catch (err) {
|
|
console.warn('[bifrost] refresh error:', err.message);
|
|
}
|
|
|
|
// Lazy-load sidebar data
|
|
if (sideTab === 'networks') refreshNetworks();
|
|
else if (sideTab === 'plugins') refreshPlugins();
|
|
else if (sideTab === 'history') refreshEpochs();
|
|
}
|
|
|
|
async function refreshNetworks() {
|
|
try {
|
|
const data = await api.get('/api/bifrost/networks');
|
|
networks = data.networks || [];
|
|
paintSidebar();
|
|
} catch (err) { console.warn('[bifrost] networks error:', err.message); }
|
|
}
|
|
|
|
async function refreshPlugins() {
|
|
try {
|
|
const data = await api.get('/api/bifrost/plugins');
|
|
plugins = data.plugins || [];
|
|
paintSidebar();
|
|
} catch (err) { console.warn('[bifrost] plugins error:', err.message); }
|
|
}
|
|
|
|
async function refreshEpochs() {
|
|
try {
|
|
const data = await api.get('/api/bifrost/epochs');
|
|
epochs = data.epochs || [];
|
|
paintSidebar();
|
|
} catch (err) { console.warn('[bifrost] epochs error:', err.message); }
|
|
}
|
|
|
|
/* ── Paint ─────────────────────────────────────────────── */
|
|
|
|
function paint() {
|
|
paintToggle();
|
|
paintMode();
|
|
paintStats();
|
|
paintFace();
|
|
paintInfo();
|
|
paintActivity();
|
|
paintSidebar();
|
|
}
|
|
|
|
function paintToggle() {
|
|
const btn = $('#bifrost-toggle', root);
|
|
if (!btn) return;
|
|
btn.classList.toggle('active', bifrostEnabled);
|
|
const lbl = $('.bifrost-toggle-label', btn);
|
|
if (lbl) {
|
|
const key = bifrostEnabled ? 'bifrost.enabled' : 'bifrost.disabled';
|
|
lbl.textContent = t(key);
|
|
lbl.setAttribute('data-i18n', key);
|
|
}
|
|
}
|
|
|
|
function paintMode() {
|
|
const lbl = $('.bifrost-mode-label', root);
|
|
if (lbl) {
|
|
const mode = status.mode || 'auto';
|
|
lbl.textContent = mode === 'auto' ? 'Auto' : 'Manual';
|
|
}
|
|
}
|
|
|
|
function paintStats() {
|
|
const container = $('#bifrost-stats', root);
|
|
if (!container) return;
|
|
|
|
const items = [
|
|
{ val: stats.total_networks || 0, lbl: t('bifrost.statNetworks') },
|
|
{ val: stats.total_handshakes || 0, lbl: t('bifrost.statHandshakes') },
|
|
{ val: stats.total_deauths || 0, lbl: t('bifrost.statDeauths') },
|
|
{ val: stats.total_assocs || 0, lbl: t('bifrost.statAssocs') },
|
|
{ val: stats.total_epochs || 0, lbl: t('bifrost.statEpochs') },
|
|
{ val: stats.total_peers || 0, lbl: t('bifrost.statPeers') },
|
|
];
|
|
|
|
empty(container);
|
|
for (const s of items) {
|
|
container.appendChild(
|
|
el('div', { class: 'bifrost-stat' }, [
|
|
el('div', { class: 'bifrost-stat-val' }, [String(s.val)]),
|
|
el('div', { class: 'bifrost-stat-lbl' }, [s.lbl]),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
function paintFace() {
|
|
const faceEl = $('#bifrost-face', root);
|
|
const moodEl = $('#bifrost-mood', root);
|
|
const voiceEl = $('#bifrost-voice', root);
|
|
if (faceEl) {
|
|
if (status.monitor_failed) {
|
|
faceEl.textContent = '(X_X)';
|
|
faceEl.className = 'bifrost-face mood-angry';
|
|
} else {
|
|
faceEl.textContent = status.face || '(. .)';
|
|
faceEl.className = 'bifrost-face';
|
|
if (status.mood) faceEl.classList.add('mood-' + status.mood);
|
|
}
|
|
}
|
|
if (moodEl) {
|
|
if (status.monitor_failed) {
|
|
moodEl.textContent = t('bifrost.monitorFailed');
|
|
moodEl.className = 'bifrost-mood mood-badge-angry';
|
|
} else {
|
|
const mood = status.mood || 'sleeping';
|
|
moodEl.textContent = mood;
|
|
moodEl.className = 'bifrost-mood mood-badge-' + mood;
|
|
}
|
|
}
|
|
if (voiceEl) {
|
|
if (status.monitor_failed) {
|
|
voiceEl.textContent = t('bifrost.monitorFailedHint');
|
|
} else {
|
|
voiceEl.textContent = status.voice || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
function paintInfo() {
|
|
const container = $('#bifrost-info', root);
|
|
if (!container) return;
|
|
empty(container);
|
|
|
|
const items = [
|
|
{ label: 'Ch', value: status.channel || 0 },
|
|
{ label: 'APs', value: status.num_aps || 0 },
|
|
{ label: '🤝', value: status.num_handshakes || 0 },
|
|
{ label: '⏱', value: formatUptime(status.uptime || 0) },
|
|
{ label: 'Ep', value: status.epoch || 0 },
|
|
];
|
|
|
|
for (const item of items) {
|
|
container.appendChild(
|
|
el('span', { class: 'bifrost-info-chip' }, [
|
|
el('span', { class: 'bifrost-info-label' }, [item.label]),
|
|
el('span', { class: 'bifrost-info-value' }, [String(item.value)]),
|
|
])
|
|
);
|
|
}
|
|
|
|
if (status.last_pwnd) {
|
|
container.appendChild(
|
|
el('span', { class: 'bifrost-info-chip pwnd' }, [
|
|
el('span', { class: 'bifrost-info-label' }, ['🏆']),
|
|
el('span', { class: 'bifrost-info-value' }, [escapeHtml(status.last_pwnd)]),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
function paintActivity() {
|
|
const container = $('#bifrost-activity', root);
|
|
if (!container) return;
|
|
empty(container);
|
|
|
|
if (activity.length === 0) {
|
|
container.appendChild(
|
|
el('div', { class: 'bifrost-empty' }, [t('bifrost.noActivity')])
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const ev of activity) {
|
|
const icon = eventIcon(ev.event_type);
|
|
container.appendChild(
|
|
el('div', { class: 'bifrost-activity-item' }, [
|
|
el('span', { class: 'bifrost-act-time' }, [formatTime(ev.timestamp)]),
|
|
el('span', { class: 'bifrost-act-icon' }, [icon]),
|
|
el('span', { class: 'bifrost-act-title' }, [escapeHtml(ev.title || '')]),
|
|
ev.details ? el('span', { class: 'bifrost-act-detail' }, [escapeHtml(ev.details)]) : '',
|
|
].filter(Boolean))
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ── Sidebar panels ────────────────────────────────────── */
|
|
|
|
function paintSidebar() {
|
|
const container = $('#bifrost-sidebar', root);
|
|
if (!container) return;
|
|
empty(container);
|
|
|
|
switch (sideTab) {
|
|
case 'networks': paintNetworks(container); break;
|
|
case 'plugins': paintPlugins(container); break;
|
|
case 'history': paintEpochs(container); break;
|
|
}
|
|
}
|
|
|
|
/* ── Networks ─────────────────────────────────────────── */
|
|
|
|
function paintNetworks(container) {
|
|
if (networks.length === 0) {
|
|
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noNetworks')]));
|
|
return;
|
|
}
|
|
|
|
for (const net of networks) {
|
|
const signal = signalBars(net.rssi);
|
|
const encBadge = encryptionBadge(net.encryption || 'OPEN');
|
|
container.appendChild(
|
|
el('div', { class: 'bifrost-net-row' }, [
|
|
el('div', { class: 'bifrost-net-main' }, [
|
|
el('span', { class: 'bifrost-net-signal' }, [signal]),
|
|
el('span', { class: 'bifrost-net-essid' }, [escapeHtml(net.essid || '<hidden>')]),
|
|
el('span', { class: 'bifrost-net-enc' }, [encBadge]),
|
|
]),
|
|
el('div', { class: 'bifrost-net-meta' }, [
|
|
`ch${net.channel || '?'}`,
|
|
net.rssi ? ` ${net.rssi}dB` : '',
|
|
net.clients ? ` · ${net.clients} sta` : '',
|
|
net.handshake ? ' · ✅' : '',
|
|
].join('')),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ── Plugins ──────────────────────────────────────────── */
|
|
|
|
function paintPlugins(container) {
|
|
if (plugins.length === 0) {
|
|
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noPlugins')]));
|
|
return;
|
|
}
|
|
|
|
for (const plug of plugins) {
|
|
container.appendChild(
|
|
el('div', { class: 'bifrost-plugin-row' }, [
|
|
el('div', { class: 'bifrost-plugin-info' }, [
|
|
el('span', { class: 'bifrost-plugin-name' }, [escapeHtml(plug.name)]),
|
|
el('span', { class: 'bifrost-plugin-desc' }, [escapeHtml(plug.description || '')]),
|
|
]),
|
|
el('button', {
|
|
class: `bifrost-btn${plug.enabled ? ' active' : ''}`,
|
|
'data-plugin-toggle': plug.name,
|
|
style: 'padding:2px 8px;font-size:0.6rem',
|
|
}, [plug.enabled ? '⏸' : '▶']),
|
|
])
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ── Epoch History ────────────────────────────────────── */
|
|
|
|
function paintEpochs(container) {
|
|
if (epochs.length === 0) {
|
|
container.appendChild(el('div', { class: 'bifrost-empty' }, [t('bifrost.noEpochs')]));
|
|
return;
|
|
}
|
|
|
|
const table = el('div', { class: 'bifrost-epoch-table' }, [
|
|
el('div', { class: 'bifrost-epoch-header' }, [
|
|
el('span', {}, ['#']),
|
|
el('span', {}, ['🤝']),
|
|
el('span', {}, ['💀']),
|
|
el('span', {}, ['📡']),
|
|
el('span', {}, [t('bifrost.mood')]),
|
|
el('span', {}, ['⭐']),
|
|
]),
|
|
]);
|
|
|
|
for (const ep of epochs.slice(0, 50)) {
|
|
const rewardStr = typeof ep.reward === 'number' ? ep.reward.toFixed(2) : '—';
|
|
table.appendChild(
|
|
el('div', { class: 'bifrost-epoch-row' }, [
|
|
el('span', {}, [String(ep.epoch_num ?? ep.id ?? '?')]),
|
|
el('span', {}, [String(ep.num_handshakes ?? 0)]),
|
|
el('span', {}, [String(ep.num_deauths ?? 0)]),
|
|
el('span', {}, [String(ep.num_hops ?? 0)]),
|
|
el('span', { class: `mood-badge-${ep.mood || 'sleeping'}` }, [ep.mood || '—']),
|
|
el('span', {}, [rewardStr]),
|
|
])
|
|
);
|
|
}
|
|
|
|
container.appendChild(table);
|
|
}
|
|
|
|
/* ── Helpers ───────────────────────────────────────────── */
|
|
|
|
function eventIcon(type) {
|
|
const icons = {
|
|
handshake: '🤝', deauth: '💀', association: '📡',
|
|
new_ap: '📶', channel_hop: '📻', epoch: '🔄',
|
|
plugin: '🧩', error: '❌', start: '▶️', stop: '⏹️',
|
|
};
|
|
return icons[type] || '📝';
|
|
}
|
|
|
|
function signalBars(rssi) {
|
|
if (!rssi) return '▂';
|
|
const val = Math.abs(rssi);
|
|
if (val < 50) return '▂▄▆█';
|
|
if (val < 60) return '▂▄▆';
|
|
if (val < 70) return '▂▄';
|
|
return '▂';
|
|
}
|
|
|
|
function encryptionBadge(enc) {
|
|
if (!enc || enc === 'OPEN' || enc === '') return 'OPEN';
|
|
if (enc.includes('WPA3')) return 'WPA3';
|
|
if (enc.includes('WPA2')) return 'WPA2';
|
|
if (enc.includes('WPA')) return 'WPA';
|
|
if (enc.includes('WEP')) return 'WEP';
|
|
return enc;
|
|
}
|
|
|
|
function formatUptime(secs) {
|
|
if (!secs || secs < 0) return '0s';
|
|
const h = Math.floor(secs / 3600);
|
|
const m = Math.floor((secs % 3600) / 60);
|
|
if (h > 0) return `${h}h${m.toString().padStart(2, '0')}m`;
|
|
return `${m}m`;
|
|
}
|
|
|
|
function formatTime(ts) {
|
|
if (!ts) return '—';
|
|
try {
|
|
const d = new Date(ts.includes('Z') || ts.includes('+') ? ts : ts + 'Z');
|
|
const now = Date.now();
|
|
const diff = now - d.getTime();
|
|
if (diff < 60000) return t('bifrost.justNow');
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m`;
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`;
|
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
} catch { return ts; }
|
|
}
|