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:
2026-06-10 13:31:11 +02:00
parent d584e65226
commit c23091fa6e
27 changed files with 3481 additions and 556 deletions

View File

@@ -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&gt;</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' }}