Files
Bjorn/web/js/pages/zombieland.js
Fabien POLLY eb20b168a6 Add RLUtils class for managing RL/AI dashboard endpoints
- Implemented methods for fetching AI stats, training history, and recent experiences.
- Added functionality to set operation mode (MANUAL, AUTO, AI) with appropriate handling.
- Included helper methods for querying the database and sending JSON responses.
- Integrated model metadata extraction for visualization purposes.
2026-02-18 22:36:10 +01:00

763 lines
31 KiB
JavaScript

/**
* Zombieland page module — C2 (Command & Control) agent management.
* Uses Server-Sent Events (SSE) via /c2/events for real-time updates.
* The EventSource connection is closed in unmount() to prevent leaks.
*/
import { ResourceTracker } from '../core/resource-tracker.js';
import { api, Poller } from '../core/api.js';
import { el, $, empty, toast } from '../core/dom.js';
import { t } from '../core/i18n.js';
import { initSharedSidebarLayout } from '../core/sidebar-layout.js';
const PAGE = 'zombieland';
const L = (key, fallback, vars = {}) => {
const v = t(key, vars);
return v === key ? fallback : v;
};
/* ——— Presence thresholds (ms) ——— */
const PRESENCE = { GRACE: 30000, WARN: 60000, ORANGE: 100000, RED: 160000 };
/* ——— ECG waveform paths ——— */
const ECG_PQRST = 'M0,21 L15,21 L18,19 L20,21 L30,21 L32,23 L34,21 L40,21 L42,12 L44,30 L46,8 L48,35 L50,21 L60,21 L65,21 L70,19 L72,21 L85,21 L90,21 L100,21 L110,21 L115,19 L118,21 L130,21 L132,23 L134,21 L140,21 L142,12 L144,30 L146,8 L148,35 L150,21 L160,21 L170,21 L180,21 L190,21 L200,21';
const ECG_FLAT = 'M0,21 L200,21';
/* ——— State ——— */
let tracker = null;
let poller = null;
let disposeSidebarLayout = null;
let eventSource = null;
let agents = new Map(); // id -> agent object
let selectedAgents = new Set();
let searchTerm = '';
let c2Running = false;
let c2Port = null;
let sseHealthy = false;
let commandHistory = [];
let historyIndex = -1;
function loadStylesheet(path, id) {
const link = el('link', {
rel: 'stylesheet',
href: path,
id: `style-${id}`
});
document.head.appendChild(link);
return () => {
const styleElement = document.getElementById(`style-${id}`);
if (styleElement) {
styleElement.remove();
}
};
}
/* ================================================================
* Lifecycle
* ================================================================ */
export async function mount(container) {
tracker = new ResourceTracker(PAGE);
// Load page-specific styles and track them for cleanup
const unloadStyles = loadStylesheet('/web/css/zombieland.css', PAGE);
tracker.trackResource(unloadStyles);
agents.clear();
selectedAgents.clear();
searchTerm = '';
c2Running = false;
c2Port = null;
sseHealthy = false;
commandHistory = [];
historyIndex = -1;
const shell = buildShell();
container.appendChild(shell);
container.appendChild(buildGenerateClientModal());
container.appendChild(buildFileBrowserModal());
disposeSidebarLayout = initSharedSidebarLayout(shell, {
sidebarSelector: '.zl-sidebar',
mainSelector: '.zl-main',
storageKey: 'sidebar:zombieland',
mobileBreakpoint: 900,
toggleLabel: t('common.menu') || 'Menu',
});
await refreshState();
syncSearchClearButton();
connectSSE();
poller = new Poller(refreshState, 10000, { immediate: false });
poller.start();
tracker.trackInterval(tickPresence, 1000);
}
export function unmount() {
if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch { } disposeSidebarLayout = null; }
if (eventSource) { eventSource.close(); eventSource = null; }
sseHealthy = false;
if (poller) { poller.stop(); poller = null; }
if (tracker) { tracker.cleanupAll(); tracker = null; }
agents.clear();
selectedAgents.clear();
searchTerm = '';
commandHistory = [];
historyIndex = -1;
}
/* ================================================================
* Shell & Modals
* ================================================================ */
function buildShell() {
return el('div', { class: 'zombieland-container page-with-sidebar' }, [
el('aside', { class: 'zl-sidebar page-sidebar' }, [
el('div', { class: 'sidehead' }, [
el('div', { class: 'sidetitle' }, [t('nav.zombieland') || 'Zombieland']),
el('div', { class: 'spacer' }),
el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide') || 'Hide']),
]),
el('div', { class: 'sidecontent' }, [
el('div', { class: 'zl-stats-grid' }, [
statItem('zl-stat-total', L('zombieland.totalAgents', 'Total')),
statItem('zl-stat-alive', L('zombieland.alive', 'Online')),
statItem('zl-stat-avg-cpu', 'Avg CPU'),
statItem('zl-stat-avg-ram', 'Avg RAM'),
statItem('zl-stat-c2', L('zombieland.c2Status', 'C2 Port')),
]),
el('div', { class: 'zl-toolbar' }, [
el('button', { class: 'btn btn-icon', onclick: onRefresh, title: t('common.refresh') }, [el('i', { 'data-lucide': 'refresh-cw' })]),
el('button', { class: 'btn', onclick: onGenerateClient }, [el('i', { 'data-lucide': 'plus-circle' }), ' ' + t('zombie.generateClient')]),
el('button', { class: 'btn btn-primary', onclick: onStartC2 }, [el('i', { 'data-lucide': 'play' }), ' ' + t('zombie.startC2')]),
el('button', { class: 'btn btn-danger', onclick: onStopC2 }, [el('i', { 'data-lucide': 'square' }), ' ' + t('zombie.stopC2')]),
el('button', { class: 'btn', onclick: onCheckStale }, [el('i', { 'data-lucide': 'search' }), ' ' + t('zombie.checkStale')]),
el('button', { class: 'btn btn-danger', onclick: onPurgeStale, title: t('zombie.purgeStaleHint') }, [el('i', { 'data-lucide': 'trash-2' }), ' ' + t('zombie.purgeStale')]),
]),
]),
]),
el('div', { class: 'zl-main page-main' }, [
el('div', { class: 'zl-main-grid' }, [
el('div', { class: 'zl-console-panel' }, [
el('div', { class: 'zl-panel-header' }, [
el('span', { class: 'zl-panel-title' }, [t('console.title')]),
el('div', { class: 'zl-quickbar' }, [
quickCmd('sysinfo'), quickCmd('pwd'), quickCmd('ls -la'), quickCmd('ps aux'), quickCmd('ip a'),
]),
el('button', { class: 'btn btn-sm btn-icon', onclick: clearConsole, title: t('zombie.clearConsole') }, [el('i', { 'data-lucide': 'trash-2' })]),
]),
el('div', { class: 'zl-console-output', id: 'zl-console-output' }),
el('div', { class: 'zl-console-input-row' }, [
el('select', { class: 'zl-target-select', id: 'zl-target-select' }, [
el('option', { value: 'broadcast' }, [t('zombie.allAgents')]),
el('option', { value: 'selected' }, [t('zombie.selectedAgents')]),
]),
el('input', { type: 'text', class: 'zl-cmd-input', id: 'zl-cmd-input', placeholder: t('zombie.enterCommand'), onkeydown: onCmdKeyDown }),
el('button', { class: 'btn btn-primary', onclick: onSendCommand }, [el('i', { 'data-lucide': 'send' }), ' ' + t('common.send')]),
]),
]),
el('div', { class: 'zl-agents-panel' }, [
el('div', { class: 'zl-panel-header' }, [
el('span', { class: 'zl-panel-title' }, [t('zombie.agents'), ' (', el('span', { id: 'zl-agent-count' }, ['0']), ')']),
el('div', { class: 'zl-toolbar-left' }, [
el('input', { type: 'text', class: 'zl-search-input', id: 'zl-search', placeholder: t('zombie.fileBrowser'), oninput: onSearch }),
el('button', { class: 'zl-search-clear', onclick: clearSearch }, [el('i', { 'data-lucide': 'x' })]),
]),
el('button', { class: 'btn btn-sm btn-icon', onclick: onSelectAll, title: t('zombie.selectAll') }, [el('i', { 'data-lucide': 'check-square' })]),
el('button', { class: 'btn btn-sm btn-icon', onclick: onDeselectAll, title: t('zombie.deselectAll') }, [el('i', { 'data-lucide': 'square' })]),
]),
el('div', { class: 'zl-agents-list', id: 'zl-agents-list', onclick: onAgentListClick }),
]),
]),
el('div', { class: 'zl-logs-panel' }, [
el('div', { class: 'zl-panel-header' }, [
el('span', { class: 'zl-panel-title' }, [el('i', { 'data-lucide': 'file-text' }), ' ' + t('zombie.systemLogs')]),
el('button', { class: 'btn btn-sm btn-icon', onclick: clearLogs, title: t('zombie.clearLogs') }, [el('i', { 'data-lucide': 'trash-2' })]),
]),
el('div', { class: 'zl-logs-output', id: 'zl-logs-output' }),
]),
]),
]);
}
function statItem(id, label) {
return el('div', { class: 'stat-item' }, [
el('span', { class: 'stat-value', id }, ['0']),
el('span', { class: 'stat-label' }, [label]),
]);
}
function quickCmd(cmd) {
return el('button', {
class: 'quick-cmd', onclick: () => {
const input = $('#zl-cmd-input');
if (input) { input.value = cmd; input.focus(); }
}
}, [cmd]);
}
function buildGenerateClientModal() {
return el('div', { id: 'generateModal', class: 'modal', style: 'display:none;' }, [
el('div', { class: 'modal-content' }, [
el('h3', { class: 'modal-title' }, [t('zombie.generateClient')]),
el('div', { class: 'form-grid' }, [
el('label', {}, [t('zombie.clientId')]),
el('input', { id: 'clientId', type: 'text', class: 'input', placeholder: 'zombie01' }),
el('label', {}, [t('common.platform')]),
el('select', { id: 'clientPlatform', class: 'select' }, [
el('option', { value: 'linux' }, ['Linux']),
el('option', { value: 'windows' }, ['Windows']),
el('option', { value: 'macos' }, ['macOS']),
el('option', { value: 'universal' }, ['Universal (Python)']),
]),
el('label', {}, [t('zombie.labCreds')]),
el('div', { class: 'grid-col-2' }, [
el('input', { id: 'labUser', type: 'text', class: 'input', placeholder: t('common.username') }),
el('input', { id: 'labPass', type: 'password', class: 'input', placeholder: t('common.password') }),
]),
]),
el('div', { class: 'deploy-options' }, [
el('h4', {}, [t('zombie.deployOptions')]),
el('label', { class: 'checkbox-label' }, [
el('input', { type: 'checkbox', id: 'deploySSH', onchange: (e) => { $('#sshOptions').classList.toggle('hidden', !e.target.checked); } }),
el('span', {}, [t('zombie.deployViaSSH')]),
]),
el('div', { id: 'sshOptions', class: 'hidden form-grid' }, [
el('label', {}, ['SSH Host']), el('input', { id: 'sshHost', type: 'text', class: 'input' }),
el('label', {}, ['SSH User']), el('input', { id: 'sshUser', type: 'text', class: 'input' }),
el('label', {}, ['SSH Pass']), el('input', { id: 'sshPass', type: 'password', class: 'input' }),
]),
]),
el('div', { class: 'modal-actions' }, [
el('button', { class: 'btn', onclick: () => $('#generateModal').style.display = 'none' }, [t('common.cancel')]),
el('button', { class: 'btn btn-primary', onclick: onConfirmGenerate }, [t('common.generate')]),
]),
]),
]);
}
function buildFileBrowserModal() {
return el('div', { id: 'fileBrowserModal', class: 'modal', style: 'display:none;' }, [
el('div', { class: 'modal-content' }, [
el('h3', { class: 'modal-title' }, [t('zombie.fileBrowser'), ' - ', el('span', { id: 'browserAgent' })]),
el('div', { class: 'file-browser-nav' }, [
el('input', { id: 'browserPath', type: 'text', class: 'input flex-grow' }),
el('button', { class: 'btn', onclick: browseDirectory }, [t('common.browse')]),
el('button', { class: 'btn', onclick: onUploadFile }, [t('common.upload')]),
]),
el('div', { id: 'fileList', class: 'file-list' }),
el('div', { class: 'modal-actions' }, [
el('button', { class: 'btn', onclick: () => $('#fileBrowserModal').style.display = 'none' }, [t('common.close')]),
]),
]),
]);
}
/* ================================================================
* Data fetching & SSE
* ================================================================ */
async function refreshState() {
try {
if (sseHealthy && eventSource && eventSource.readyState === EventSource.OPEN) return;
const [status, agentList] = await Promise.all([
api.get('/c2/status').catch(() => null),
api.get('/c2/agents').catch(() => null),
]);
if (status) { c2Running = !!status.running; c2Port = status.port || null; }
if (Array.isArray(agentList)) {
for (const a of agentList) {
const id = a.id || a.agent_id || a.client_id;
if (!id) continue;
const existing = agents.get(id) || {};
const merged = { ...existing, ...a, id, last_seen: maxTimestamp(existing.last_seen, a.last_seen) };
agents.set(id, merged);
}
}
renderAgents();
updateStats();
} catch (err) { console.warn(`[${PAGE}] refreshState error:`, err.message); }
}
function connectSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/c2/events');
eventSource.onopen = () => { sseHealthy = true; systemLog('info', 'Connected to C2 event stream'); };
eventSource.onerror = () => { sseHealthy = false; systemLog('error', 'C2 event stream connection lost'); };
eventSource.addEventListener('status', (e) => {
try { const data = JSON.parse(e.data); c2Running = !!data.running; c2Port = data.port || null; updateStats(); } catch { }
});
eventSource.addEventListener('telemetry', (e) => {
try {
const data = JSON.parse(e.data);
const id = data.id || data.agent_id; if (!id) return;
const now = Date.now();
const existing = agents.get(id) || {};
const agent = { ...existing, ...data, id, last_seen: now };
agents.set(id, agent);
if (computePresence(existing, now).status !== computePresence(agent, now).status) {
systemLog('success', `Agent ${agent.hostname || id} telemetry received.`);
}
const card = $('[data-agent-id="' + id + '"]');
if (card) { card.classList.add('pulse'); tracker.trackTimeout(() => card.classList.remove('pulse'), 600); }
renderAgents();
updateStats();
} catch { }
});
eventSource.addEventListener('log', (e) => { try { const d = JSON.parse(e.data); systemLog(d.level || 'info', d.text || ''); } catch { } });
eventSource.addEventListener('console', (e) => { try { const d = JSON.parse(e.data); consoleLog(d.kind || 'RX', d.text || '', d.target || null); } catch { } });
}
/* ================================================================
* Presence, Ticking, and Rendering
* ================================================================ */
function computePresence(agent, now) {
if (!agent || !agent.last_seen) return { status: 'offline', delta: null, color: 'red', bpm: 0 };
const last = parseTs(agent.last_seen);
if (isNaN(last)) return { status: 'offline', delta: null, color: 'red', bpm: 0 };
const delta = now - last;
if (delta < PRESENCE.GRACE) return { status: 'online', delta, color: 'green', bpm: 55 };
if (delta < PRESENCE.WARN) return { status: 'online', delta, color: 'green', bpm: 40 };
if (delta < PRESENCE.ORANGE) return { status: 'idle', delta, color: 'yellow', bpm: 22 };
if (delta < PRESENCE.RED) return { status: 'idle', delta, color: 'orange', bpm: 12 };
return { status: 'offline', delta, color: 'red', bpm: 0 };
}
function tickPresence() {
const now = Date.now();
document.querySelectorAll('.zl-agent-card').forEach(card => {
const agentId = card.dataset.agentId;
const agent = agents.get(agentId);
if (!agent) return;
const pres = computePresence(agent, now);
const counter = $('#zl-ecg-counter-' + agentId);
if (counter) counter.textContent = pres.delta != null ? Math.floor(pres.delta / 1000) + 's' : '--';
const ecgEl = $('#zl-ecg-' + agentId);
if (ecgEl) {
ecgEl.className = `ecg ${pres.color} ${pres.bpm === 0 ? 'flat' : ''}`;
const wrapper = ecgEl.querySelector('.ecg-wrapper');
if (wrapper) wrapper.style.animationDuration = `${pres.bpm > 0 ? 72 / pres.bpm : 3.2}s`;
}
const pill = card.querySelector('.zl-pill');
if (pill) { pill.className = `zl-pill ${pres.status}`; pill.textContent = pres.status; }
card.classList.toggle('agent-stale-yellow', pres.status === 'idle' && pres.color === 'yellow');
card.classList.toggle('agent-stale-orange', pres.status === 'idle' && pres.color === 'orange');
card.classList.toggle('agent-stale-red', pres.status === 'offline');
});
updateStats();
}
function renderAgents() {
const list = $('#zl-agents-list');
if (!list) return;
const now = Date.now();
const needle = searchTerm.toLowerCase();
const deduped = dedupeAgents(Array.from(agents.values()));
const filtered = deduped.filter(a => !needle || [a.id, a.hostname, a.ip, a.os, a.mac].filter(Boolean).join(' ').toLowerCase().includes(needle));
filtered.sort((a, b) => {
const pa = computePresence(a, now), pb = computePresence(b, now);
const rank = { online: 0, idle: 1, offline: 2 };
if (rank[pa.status] !== rank[pb.status]) return rank[pa.status] - rank[pb.status];
return (a.hostname || a.id || '').localeCompare(b.hostname || b.id || '');
});
empty(list);
if (filtered.length === 0) {
list.appendChild(el('div', { class: 'zl-empty' }, [searchTerm ? t('zombie.noAgentsMatchSearch') : t('zombie.noAgentsConnected')]));
} else {
filtered.forEach(agent => list.appendChild(createAgentCard(agent, now)));
}
updateTargetSelect();
const countEl = $('#zl-agent-count');
if (countEl) {
const onlineCount = filtered.filter(a => computePresence(a, now).status === 'online').length;
countEl.textContent = `${onlineCount}/${filtered.length}`;
}
if (window.lucide) window.lucide.createIcons();
}
function createAgentCard(agent, now) {
const id = agent.id;
const pres = computePresence(agent, now);
let staleClass = pres.status === 'idle' ? ` agent-stale-${pres.color}` : (pres.status === 'offline' ? ' agent-stale-red' : '');
const isSelected = selectedAgents.has(id);
return el('div', { class: `zl-agent-card ${isSelected ? 'selected' : ''}${staleClass}`, 'data-agent-id': id }, [
el('div', { class: 'zl-card-header' }, [
el('input', { type: 'checkbox', class: 'agent-checkbox', checked: isSelected, 'data-agent-id': id }),
el('div', { class: 'zl-card-identity' }, [
el('div', { class: 'zl-card-hostname' }, [agent.hostname || 'Unknown']),
el('div', { class: 'zl-card-id' }, [id]),
]),
el('span', { class: 'zl-pill ' + pres.status }, [pres.status]),
]),
el('div', { class: 'zl-card-info' }, [
infoRow(t('common.os'), agent.os || 'Unknown'),
infoRow(t('common.ip'), agent.ip || 'N/A'),
infoRow('CPU/RAM', `${agent.cpu || 0}% / ${agent.mem || 0}%`),
]),
el('div', { class: 'zl-ecg-row' }, [
createECG(id, pres.color, pres.bpm),
el('span', { class: 'zl-ecg-counter', id: 'zl-ecg-counter-' + id }, [pres.delta != null ? Math.floor(pres.delta / 1000) + 's' : '--']),
]),
el('div', { class: 'zl-card-actions' }, [
el('button', { class: 'btn btn-sm btn-icon', 'data-action': 'shell', title: t('zombie.terminal') }, [el('i', { 'data-lucide': 'terminal' })]),
el('button', { class: 'btn btn-sm btn-icon', 'data-action': 'browse', title: t('zombie.fileBrowser') }, [el('i', { 'data-lucide': 'folder' })]),
el('button', { class: 'btn btn-sm btn-icon btn-danger', 'data-action': 'remove', title: t('zombie.removeAgent') }, [el('i', { 'data-lucide': 'x' })]),
]),
]);
}
function createECG(id, colorClass, bpm) {
const ns = 'http://www.w3.org/2000/svg';
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', bpm > 0 ? ECG_PQRST : ECG_FLAT);
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 200 42');
svg.setAttribute('preserveAspectRatio', 'none');
svg.appendChild(path);
const wrapper = el('div', { class: 'ecg-wrapper', style: `animation-duration: ${bpm > 0 ? 72 / bpm : 3.2}s` }, [svg, svg.cloneNode(true), svg.cloneNode(true)]);
return el('div', { class: `ecg ${colorClass} ${bpm === 0 ? 'flat' : ''}`, id: 'zl-ecg-' + id }, [wrapper]);
}
function updateStats() {
const now = Date.now();
const all = Array.from(agents.values());
const onlineAgents = all.filter(a => computePresence(a, now).status === 'online');
$('#zl-stat-total').textContent = String(all.length);
$('#zl-stat-alive').textContent = String(onlineAgents.length);
const avgCPU = onlineAgents.length ? Math.round(onlineAgents.reduce((s, a) => s + (a.cpu || 0), 0) / onlineAgents.length) : 0;
const avgRAM = onlineAgents.length ? Math.round(onlineAgents.reduce((s, a) => s + (a.mem || 0), 0) / onlineAgents.length) : 0;
$('#zl-stat-avg-cpu').textContent = `${avgCPU}%`;
$('#zl-stat-avg-ram').textContent = `${avgRAM}%`;
const c2El = $('#zl-stat-c2');
if (c2El) {
c2El.textContent = c2Running ? `${t('status.online')} :${c2Port || '?'}` : t('status.offline');
c2El.className = `stat-value ${c2Running ? 'stat-online' : 'stat-offline'}`;
}
}
/* ================================================================
* Event Handlers
* ================================================================ */
function onAgentListClick(e) {
const card = e.target.closest('.zl-agent-card');
if (!card) return;
const agentId = card.dataset.agentId;
const agent = agents.get(agentId);
if (e.target.matches('.agent-checkbox')) {
if (e.target.checked) selectedAgents.add(agentId);
else selectedAgents.delete(agentId);
renderAgents();
} else if (e.target.dataset.action) {
switch (e.target.dataset.action) {
case 'shell': focusGlobalConsole(agentId); break;
case 'browse': openFileBrowser(agentId); break;
case 'remove': onRemoveAgent(agentId, agent.hostname || agentId); break;
}
}
}
function onSelectAll() {
document.querySelectorAll('.agent-checkbox').forEach(cb => {
selectedAgents.add(cb.dataset.agentId);
cb.checked = true;
});
renderAgents();
}
function onDeselectAll() {
selectedAgents.clear();
renderAgents();
}
function onSearch(e) {
searchTerm = (e.target.value || '').trim();
syncSearchClearButton();
renderAgents();
}
function clearSearch() {
const input = $('#zl-search');
if (input) input.value = '';
searchTerm = '';
renderAgents();
syncSearchClearButton();
}
function syncSearchClearButton() {
const clearBtn = $('.zl-search-clear');
if (clearBtn) clearBtn.style.display = searchTerm.length > 0 ? 'inline-block' : 'none';
}
function onRefresh() {
const wasSseHealthy = sseHealthy;
sseHealthy = false;
refreshState().finally(() => { sseHealthy = wasSseHealthy; });
toast(t('common.refreshed'));
}
async function onStartC2() {
const port = prompt(L('zombie.enterC2Port', 'Enter C2 port'), '5555');
if (!port) return;
try {
await api.post('/c2/start', { port: parseInt(port) });
toast(t('zombie.c2StartedOnPort', { port }), 2600, 'success');
await refreshState();
} catch (err) { toast(t('zombie.failedStartC2'), 2600, 'error'); }
}
async function onStopC2() {
if (!confirm(t('zombie.confirmStopC2'))) return;
try {
await api.post('/c2/stop');
toast(t('zombie.c2Stopped'), 2600, 'warning');
await refreshState();
} catch (err) { toast(t('zombie.failedStopC2'), 2600, 'error'); }
}
async function onCheckStale() {
try {
const result = await api.get('/c2/stale_agents?threshold=300');
toast(`${result.count} stale agent(s) found (>5min)`);
systemLog('info', `Stale check: ${result.count} inactive >5min.`);
} catch (err) { toast('Failed to fetch stale agents', 'error'); }
}
async function onPurgeStale() {
if (!confirm(t('zombie.confirmPurgeStale'))) return;
try {
const result = await api.post('/c2/purge_agents', { threshold: 86400 });
toast(t('zombie.agentsPurged', { count: result.purged || 0 }), 2600, 'warning');
await refreshState();
} catch (err) { toast(t('zombie.failedPurgeStale'), 2600, 'error'); }
}
function onGenerateClient() {
$('#generateModal').style.display = 'flex';
}
async function onConfirmGenerate() {
const clientId = $('#clientId').value.trim() || `zombie_${Date.now()}`;
const data = {
client_id: clientId,
platform: $('#clientPlatform').value,
lab_user: $('#labUser').value.trim(),
lab_password: $('#labPass').value.trim(),
};
try {
const result = await api.post('/c2/generate_client', data);
toast(`Client ${clientId} generated`, 'success');
if ($('#deploySSH').checked) {
await api.post('/c2/deploy', {
client_id: clientId,
ssh_host: $('#sshHost').value,
ssh_user: $('#sshUser').value,
ssh_pass: $('#sshPass').value,
lab_user: data.lab_user,
lab_password: data.lab_password,
});
toast(`Deployment to ${$('#sshHost').value} started`);
}
$('#generateModal').style.display = 'none';
if (result.filename) {
const a = el('a', { href: `/c2/download_client/${result.filename}`, download: result.filename });
a.click();
}
} catch (err) { toast(`Failed to generate: ${err.message}`, 'error'); }
}
/* ================================================================
* Console and Commands
* ================================================================ */
function consoleLog(type, message, target) {
const output = $('#zl-console-output'); if (!output) return;
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
if (typeof message === 'object') message = JSON.stringify(message, null, 2);
const line = el('div', { class: 'console-line' }, [
el('span', { class: 'console-time' }, [time]),
el('span', { class: 'console-type ' + String(type).toLowerCase() }, [type]),
target ? el('span', { class: 'console-target' }, ['[' + target + ']']) : null,
el('div', { class: 'console-content' }, [el('pre', {}, [message])]),
]);
output.appendChild(line);
output.scrollTop = output.scrollHeight;
}
function systemLog(level, message) {
const output = $('#zl-logs-output'); if (!output) return;
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
output.appendChild(el('div', { class: 'zl-log-line' }, [
el('span', { class: 'console-time' }, [time]),
el('span', { class: 'console-type ' + level.toLowerCase() }, [level.toUpperCase()]),
el('div', { class: 'zl-log-text' }, [message]),
]));
output.scrollTop = output.scrollHeight;
}
function clearConsole() { empty($('#zl-console-output')); }
function clearLogs() { empty($('#zl-logs-output')); }
function onCmdKeyDown(e) {
if (e.key === 'Enter') onSendCommand();
else if (e.key === 'ArrowUp') {
e.preventDefault();
if (historyIndex > 0) { historyIndex--; e.target.value = commandHistory[historyIndex] || ''; }
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (historyIndex < commandHistory.length - 1) { historyIndex++; e.target.value = commandHistory[historyIndex] || ''; }
else { historyIndex = commandHistory.length; e.target.value = ''; }
}
}
async function onSendCommand() {
const input = $('#zl-cmd-input');
const cmd = input.value.trim();
if (!cmd) return;
const target = $('#zl-target-select').value;
let targets = [];
if (target === 'broadcast') { /* targets remains empty for broadcast */ }
else if (target === 'selected') { targets = Array.from(selectedAgents); }
else { targets = [target]; }
if (target !== 'broadcast' && targets.length === 0) {
toast('No agents selected for command.', 'warning');
return;
}
await sendCommand(cmd, targets);
input.value = '';
}
async function sendCommand(command, targets = []) {
if (!command) return;
try {
const endpoint = targets.length === 0 ? '/c2/broadcast' : '/c2/command';
const payload = targets.length === 0 ? { command } : { command, targets };
consoleLog('TX', command, targets.length > 0 ? targets.join(',') : 'ALL');
await api.post(endpoint, payload);
toast(t(targets.length === 0 ? 'zombie.commandBroadcasted' : 'zombie.commandSent'), 2600, 'success');
commandHistory.push(command);
historyIndex = commandHistory.length;
} catch (err) { toast(t('zombie.failedSendCommand'), 2600, 'error'); systemLog('error', err.message); }
}
async function onRemoveAgent(agentId, name) {
if (!confirm(t('zombie.confirmRemoveAgent', { name }))) return;
try {
await api.post('/c2/remove_client', { client_id: agentId });
agents.delete(agentId); selectedAgents.delete(agentId);
renderAgents();
toast(t('zombie.agentRemoved', { name }), 2600, 'warning');
} catch (err) { toast(t('zombie.failedRemoveAgent', { name }), 2600, 'error'); }
}
/* ================================================================
* File Browser
* ================================================================ */
function openFileBrowser(agentId) {
const modal = $('#fileBrowserModal');
modal.style.display = 'flex';
modal.dataset.agentId = agentId;
$('#browserAgent').textContent = agentId;
$('#browserPath').value = '/';
browseDirectory();
}
async function browseDirectory() {
const agentId = $('#fileBrowserModal').dataset.agentId;
const path = $('#browserPath').value || '/';
const fileList = $('#fileList');
empty(fileList);
fileList.textContent = 'Loading...';
try {
await sendCommand(`ls -la ${path}`, [agentId]);
// The result will arrive via SSE and be handled by the 'console' event listener.
// For now, we assume it's coming to the main console. A better way would be a dedicated event.
// This is a limitation of the current design. We can refine it later.
toast('Browse command sent. Check console for output.');
} catch (err) {
toast('Failed to send browse command', 'error');
fileList.textContent = 'Error.';
}
}
function onUploadFile() {
const agentId = $('#fileBrowserModal').dataset.agentId;
const path = $('#browserPath').value || '/';
const input = el('input', {
type: 'file',
onchange: (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const base64 = btoa(event.target.result);
const filePath = `${path.endsWith('/') ? path : path + '/'}${file.name}`;
try {
await sendCommand(`upload ${filePath} ${base64}`, [agentId]);
toast(`File ${file.name} upload started.`);
} catch { toast('Failed to upload file.', 'error'); }
};
reader.readAsBinaryString(file);
}
});
input.click();
}
/* ================================================================
* Helpers
* ================================================================ */
function updateTargetSelect() {
const select = $('#zl-target-select');
if (!select) return;
const currentVal = select.value;
empty(select);
select.appendChild(el('option', { value: 'broadcast' }, [t('zombie.allAgents')]));
select.appendChild(el('option', { value: 'selected' }, [t('zombie.selectedAgents'), ` (${selectedAgents.size})`]));
const now = Date.now();
for (const agent of agents.values()) {
if (computePresence(agent, now).status === 'online') {
select.appendChild(el('option', { value: agent.id }, [agent.hostname || agent.id]));
}
}
select.value = currentVal; // Preserve selection if possible
}
function focusGlobalConsole(agentId) {
const sel = $('#zl-target-select');
if (sel) sel.value = agentId;
$('#zl-cmd-input')?.focus();
}
function infoRow(label, value) {
return el('div', { class: 'zl-info-row' }, [el('span', { class: 'zl-info-label' }, [label + ':']), el('span', { class: 'zl-info-value' }, [value])]);
}
function dedupeAgents(arr) {
const byHost = new Map();
arr.forEach(a => {
const key = (a.hostname || '').trim().toLowerCase() || a.id;
const prev = byHost.get(key);
if (!prev || parseTs(a.last_seen) >= parseTs(prev.last_seen)) byHost.set(key, a);
});
return Array.from(byHost.values());
}
function maxTimestamp(a, b) {
const ta = parseTs(a), tb = parseTs(b);
if (ta == null) return b; if (tb == null) return a;
return ta >= tb ? a : b;
}
function parseTs(v) {
if (v == null) return NaN;
if (typeof v === 'number') return v;
return Date.parse(v);
}