Add: Agent v2.1.2, WebSocket Live Shell, WebRTC Remote Desktop (Beta), Announcement Push
- Agent v2.1.2: WebSocket ShellService, ACK nach WS-Ankündigungen, shown_announcements Fix - Backend: shellServer.js mit WebSocket-Server (Shell + Announcement Push + RTC Signaling) - Backend: patch.controller.js Fix (command_id=0 Falsy-Bug beim Auto-Update) - Frontend: Remote Desktop Tab (WebRTC Beta) in AgentDetailPage für super_admin/admin - Frontend: PatchManagementPage auf v2.1.2 aktualisiert - WPF Notification: AllowsTransparency=False + kein DropShadowEffect (Dispatcher-Crash Fix) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import monitoringService from '../services/monitoringService';
|
||||
import assetService from '../services/assetService';
|
||||
import api from '../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// ─── CSS Variables injected inline (design from Device Detail.html) ─────────────
|
||||
|
||||
@@ -152,11 +153,313 @@ const Btn = ({ onClick, children, variant = 'default', disabled }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Remote Shell (WebSocket Live Terminal) ───────────────────────────────────
|
||||
|
||||
const API = process.env.REACT_APP_API_URL || '/api';
|
||||
const authFetch = (url, opts = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
return fetch(url, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
|
||||
};
|
||||
|
||||
// Einfacher ANSI-Code-Stripper für die Anzeige ohne xterm.js
|
||||
function stripAnsi(str) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, '').replace(/\x1b\[[0-9;]*[A-Z]/g, '');
|
||||
}
|
||||
|
||||
function RemoteShell({ agentId, agentHostname }) {
|
||||
const [input, setInput] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const [status, setStatus] = useState('disconnected'); // disconnected | connecting | connected
|
||||
const outputRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const getWsUrl = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const host = window.location.host;
|
||||
return `${proto}://${host}/ws?type=shell&agentId=${agentId}&token=${token}`;
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (wsRef.current && wsRef.current.readyState <= 1) return;
|
||||
setStatus('connecting');
|
||||
setOutput('');
|
||||
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => setStatus('connected');
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
setOutput(prev => prev + stripAnsi(e.data));
|
||||
setTimeout(() => {
|
||||
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}, 10);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('disconnected');
|
||||
setOutput(prev => prev + '\r\n[Verbindung getrennt]\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus('disconnected');
|
||||
};
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
|
||||
// Beim Unmount trennen
|
||||
useEffect(() => { return () => disconnect(); }, []);
|
||||
|
||||
const send = () => {
|
||||
if (!input.trim() || !wsRef.current || wsRef.current.readyState !== 1) return;
|
||||
wsRef.current.send(input + '\n');
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); send(); }
|
||||
if (e.key === 'c' && e.ctrlKey) {
|
||||
wsRef.current?.send('\x03'); // Ctrl+C
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = status === 'connected' ? '#34d399' : status === 'connecting' ? '#f59e0b' : '#6b7280';
|
||||
const statusLabel = status === 'connected' ? 'Verbunden' : status === 'connecting' ? 'Verbinde…' : 'Getrennt';
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24, borderRadius: 16, border: '1px solid var(--border-color)', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 15 }}>🖥️</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>Remote Shell</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>{agentHostname} · PowerShell (SYSTEM)</span>
|
||||
<span style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
|
||||
<span style={{ color: statusColor }}>{statusLabel}</span>
|
||||
</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
{status === 'disconnected' ? (
|
||||
<button onClick={connect} style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
|
||||
Verbinden
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={disconnect} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#cdd9e5', padding: '5px 14px', fontSize: 12, cursor: 'pointer' }}>
|
||||
Trennen
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setOutput('')} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#8b949e', padding: '5px 10px', fontSize: 12, cursor: 'pointer' }}>
|
||||
Leeren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={outputRef}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
style={{ fontFamily: 'ui-monospace, Cascadia Code, Consolas, monospace', fontSize: 12.5, lineHeight: 1.6, padding: '16px 20px', minHeight: 240, maxHeight: 480, overflowY: 'auto', background: '#0d1117', color: '#e6edf3', cursor: 'text', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
>
|
||||
{output || <span style={{ color: '#8b949e' }}>{status === 'disconnected' ? 'Auf "Verbinden" klicken um eine Shell-Sitzung zu starten.' : 'Warte auf Agent…'}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', borderTop: '1px solid #21262d', background: '#161b22' }}>
|
||||
<span style={{ color: statusColor, fontFamily: 'monospace', fontSize: 13, whiteSpace: 'nowrap' }}>PS></span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
disabled={status !== 'connected'}
|
||||
placeholder={status === 'connected' ? 'Befehl eingeben… (Enter = Senden, Ctrl+C = Abbrechen)' : 'Nicht verbunden'}
|
||||
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: '#e6edf3', fontFamily: 'ui-monospace, monospace', fontSize: 13, opacity: status !== 'connected' ? 0.4 : 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={status !== 'connected' || !input.trim()}
|
||||
style={{ background: C.teal, border: 'none', borderRadius: 8, color: '#fff', padding: '6px 14px', fontSize: 13, fontWeight: 600, cursor: status !== 'connected' ? 'not-allowed' : 'pointer', opacity: status !== 'connected' ? 0.4 : 1 }}
|
||||
>
|
||||
Senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Remote Desktop (WebRTC) ─────────────────────────────────────────────────────
|
||||
|
||||
function RemoteDesktop({ agentId, agentHostname }) {
|
||||
const [status, setStatus] = useState('idle'); // idle | signaling | connected | error
|
||||
const [error, setError] = useState('');
|
||||
const videoRef = useRef(null);
|
||||
const pcRef = useRef(null);
|
||||
const wsRef = useRef(null);
|
||||
const dcRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
const getWsUrl = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${proto}://${window.location.host}/ws?type=shell&agentId=${agentId}&token=${token}`;
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
setStatus('signaling');
|
||||
setError('');
|
||||
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
wsRef.current = ws;
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
});
|
||||
pcRef.current = pc;
|
||||
|
||||
// Video-Stream vom Agent empfangen
|
||||
pc.ontrack = (e) => {
|
||||
if (videoRef.current && e.streams[0]) {
|
||||
videoRef.current.srcObject = e.streams[0];
|
||||
setStatus('connected');
|
||||
}
|
||||
};
|
||||
|
||||
// ICE Candidates an Agent schicken
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'rtc_ice', candidate: e.candidate }));
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// DataChannel für Maus/Tastatur-Input
|
||||
const dc = pc.createDataChannel('input');
|
||||
dcRef.current = dc;
|
||||
|
||||
// Offer erstellen und an Agent senden
|
||||
ws.onopen = async () => {
|
||||
const offer = await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: false });
|
||||
await pc.setLocalDescription(offer);
|
||||
ws.send(JSON.stringify({ type: 'rtc_offer', sdp: offer.sdp }));
|
||||
};
|
||||
|
||||
ws.onmessage = async (e) => {
|
||||
// Shell-Output ignorieren, nur RTC-Messages verarbeiten
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'rtc_answer') {
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
|
||||
} else if (msg.type === 'rtc_ice' && msg.candidate) {
|
||||
await pc.addIceCandidate(msg.candidate);
|
||||
}
|
||||
} catch { /* kein JSON / Shell-Output → ignorieren */ }
|
||||
};
|
||||
|
||||
ws.onerror = () => { setStatus('error'); setError('WebSocket-Fehler'); };
|
||||
ws.onclose = () => { if (status !== 'connected') setStatus('idle'); };
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
pcRef.current?.close();
|
||||
wsRef.current?.close();
|
||||
pcRef.current = null;
|
||||
wsRef.current = null;
|
||||
if (videoRef.current) videoRef.current.srcObject = null;
|
||||
setStatus('idle');
|
||||
};
|
||||
|
||||
useEffect(() => () => disconnect(), []);
|
||||
|
||||
// Maus-Events auf Canvas → DataChannel
|
||||
const sendMouseEvent = (type, e) => {
|
||||
if (!dcRef.current || dcRef.current.readyState !== 'open') return;
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const scaleX = 1920 / rect.width;
|
||||
const scaleY = 1080 / rect.height;
|
||||
dcRef.current.send(JSON.stringify({
|
||||
type, x: Math.round((e.clientX - rect.left) * scaleX),
|
||||
y: Math.round((e.clientY - rect.top) * scaleY), button: e.button
|
||||
}));
|
||||
};
|
||||
|
||||
const statusColor = status === 'connected' ? '#34d399' : status === 'signaling' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#6b7280';
|
||||
const statusLabel = { idle: 'Getrennt', signaling: 'Verbinde…', connected: 'Verbunden', error: 'Fehler' }[status];
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24, borderRadius: 16, border: '1px solid var(--border-color)', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
|
||||
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 15 }}>🖥️</span>
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>Remote Desktop</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>{agentHostname} · WebRTC</span>
|
||||
<span style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
|
||||
<span style={{ color: statusColor }}>{statusLabel}</span>
|
||||
</span>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
||||
{status === 'idle' || status === 'error' ? (
|
||||
<button onClick={connect} style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
|
||||
Verbinden
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={disconnect} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#cdd9e5', padding: '5px 14px', fontSize: 12, cursor: 'pointer' }}>
|
||||
Trennen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === 'idle' || status === 'error' ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: 300, gap: 16, color: 'var(--text-muted)' }}>
|
||||
<span style={{ fontSize: 48 }}>🖥️</span>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>Remote Desktop (Beta)</div>
|
||||
<div style={{ fontSize: 13 }}>Direktübertragung via WebRTC — Agent muss v2.2.0+ haben</div>
|
||||
{error && <div style={{ marginTop: 8, color: '#ef4444', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ background: '#000', position: 'relative', lineHeight: 0 }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
style={{ width: '100%', display: 'block', maxHeight: 600, objectFit: 'contain' }}
|
||||
/>
|
||||
{/* Unsichtbarer Canvas für Maus-Koordinaten */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', cursor: 'none', opacity: 0 }}
|
||||
onMouseMove={e => sendMouseEvent('mousemove', e)}
|
||||
onMouseDown={e => sendMouseEvent('mousedown', e)}
|
||||
onMouseUp={e => sendMouseEvent('mouseup', e)}
|
||||
onClick={e => sendMouseEvent('click', e)}
|
||||
onContextMenu={e => { e.preventDefault(); sendMouseEvent('rightclick', e); }}
|
||||
/>
|
||||
{status === 'signaling' && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.7)', color: '#fff', fontSize: 14 }}>
|
||||
Verbinde…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
const { id, hostname } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isSuperAdmin, isAdmin } = useAuth();
|
||||
const [agent, setAgent] = useState(null);
|
||||
const [asset, setAsset] = useState(null);
|
||||
const [assignedUser, setAssignedUser] = useState(null);
|
||||
@@ -166,6 +469,7 @@ export default function AgentDetailPage() {
|
||||
const [annText, setAnnText] = useState('');
|
||||
const [showAnnModal, setShowAnnModal] = useState(false);
|
||||
const [patchHistory, setPatchHistory] = useState([]);
|
||||
const [shellTab, setShellTab] = useState('shell'); // 'shell' | 'rdp'
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
const agentRef = useRef(null);
|
||||
const refreshTimer = useRef(null);
|
||||
@@ -617,6 +921,35 @@ export default function AgentDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */}
|
||||
{(isSuperAdmin || isAdmin) && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 0, borderBottom: '1px solid var(--border-color)' }}>
|
||||
{[
|
||||
{ id: 'shell', label: '⌨️ Remote Shell' },
|
||||
{ id: 'rdp', label: '🖥️ Remote Desktop', badge: 'Beta' },
|
||||
].map(t => (
|
||||
<button key={t.id} onClick={() => setShellTab(t.id)} style={{
|
||||
background: shellTab === t.id ? 'var(--bg-secondary)' : 'transparent',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderBottom: shellTab === t.id ? '1px solid var(--bg-secondary)' : '1px solid var(--border-color)',
|
||||
borderRadius: '10px 10px 0 0', color: shellTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||
padding: '8px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6, marginBottom: -1,
|
||||
}}>
|
||||
{t.label}
|
||||
{t.badge && <span style={{ fontSize: 10, background: C.tealDim, color: C.teal, borderRadius: 4, padding: '1px 5px', fontWeight: 700 }}>{t.badge}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{shellTab === 'shell' && <RemoteShell agentId={agent.id} agentHostname={agent.hostname} />}
|
||||
{shellTab === 'rdp' && <RemoteDesktop agentId={agent.id} agentHostname={agent.hostname} />}
|
||||
</div>
|
||||
)}
|
||||
{!isSuperAdmin && !isAdmin && (
|
||||
<RemoteShell agentId={agent.id} agentHostname={agent.hostname} />
|
||||
)}
|
||||
|
||||
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
|
||||
{showAnnModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
|
||||
Reference in New Issue
Block a user