Files
IT-Nexus/frontend/src/components/common/RemoteDesktopPanel.jsx
Simon Grüssing 81b1c326fc
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled
Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen)
- JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen
- Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl)
- WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie
- Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt
- XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base)
- E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar)
- SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen)
- TV-Dashboard: Shared-Key statt komplett offenem Endpoint
- Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen
- Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json
- FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:27:17 +02:00

195 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
const SCREEN_OPTIONS = [
{ value: 0, label: 'Alle Bildschirme' },
{ value: 1, label: 'Bildschirm 1' },
{ value: 2, label: 'Bildschirm 2' },
{ value: 3, label: 'Bildschirm 3' },
{ value: 4, label: 'Bildschirm 4' },
];
export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect = false, fullscreen = false }) {
const [status, setStatus] = useState('idle'); // idle | connecting | waiting | connected | error
const [error, setError] = useState('');
const [fps, setFps] = useState(0);
const [resolution, setResolution] = useState('');
const [screenIdx, setScreenIdx] = useState(0);
const canvasRef = useRef(null);
const wsRef = useRef(null);
const fpsRef = useRef({ count: 0, last: Date.now() });
const connectedRef = useRef(false);
const getWsUrl = () => {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${window.location.host}/ws?type=rdp&agentId=${agentId}`;
};
const connect = (screen = screenIdx) => {
setStatus('connecting');
setError('');
const ws = new WebSocket(getWsUrl());
wsRef.current = ws;
connectedRef.current = false;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') }));
ws.send(JSON.stringify({ type: 'rdp_start', screen }));
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'rdp_consent_pending') {
setStatus('waiting');
} else if (msg.type === 'rdp_frame') {
if (!connectedRef.current) { connectedRef.current = true; setStatus('connected'); }
const img = new window.Image();
img.onload = () => {
const canvas = canvasRef.current;
if (!canvas) return;
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
canvas.getContext('2d').drawImage(img, 0, 0);
if (msg.w && msg.h) setResolution(`${msg.w}×${msg.h}`);
const now = Date.now();
fpsRef.current.count++;
if (now - fpsRef.current.last >= 1000) {
setFps(fpsRef.current.count);
fpsRef.current = { count: 0, last: now };
}
};
img.src = 'data:image/jpeg;base64,' + msg.data;
} else if (msg.type === 'rdp_denied') {
setStatus('error');
setError(msg.reason === 'consent_spawn_failed'
? 'Consent-Dialog konnte nicht geöffnet werden (schtasks-Fehler)'
: 'Zugriff wurde vom Benutzer abgelehnt');
ws.close();
} else if (msg.type === 'rdp_disconnected') {
setStatus('idle');
setError('Agent hat die Verbindung getrennt');
}
} catch { }
};
ws.onerror = () => { setStatus('error'); setError('WebSocket-Fehler'); };
ws.onclose = () => setStatus(s => (s === 'connected' || s === 'waiting' || s === 'connecting') ? 'idle' : s);
};
const disconnect = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'rdp_stop' }));
wsRef.current.close();
}
wsRef.current = null;
const canvas = canvasRef.current;
if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
setStatus('idle');
setFps(0);
setResolution('');
};
useEffect(() => {
if (autoConnect) connect(screenIdx);
return () => { wsRef.current?.close(); };
}, []);
const statusColor = { connected: '#34d399', connecting: '#f59e0b', waiting: '#f59e0b', error: '#ef4444', idle: '#6b7280' }[status] ?? '#6b7280';
const statusLabel = { connected: 'Verbunden', connecting: 'Verbinde…', waiting: 'Warte auf Zustimmung…', error: 'Fehler', idle: 'Getrennt' }[status] ?? '';
const containerStyle = fullscreen
? { display: 'flex', flexDirection: 'column', height: '100vh', background: '#0d1117' }
: { marginTop: 24, borderRadius: 16, border: '1px solid var(--border-color)', overflow: 'hidden', background: 'var(--bg-secondary)' };
return (
<div style={containerStyle}>
{/* Header */}
<div style={{ padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', flexShrink: 0 }}>
<span style={{ fontSize: 15 }}>🖥</span>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>Remote Desktop</span>
{agentHostname && <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{agentHostname}</span>}
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
<span style={{ color: statusColor }}>{statusLabel}</span>
</span>
{status === 'connected' && resolution && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', background: 'rgba(255,255,255,0.06)', borderRadius: 6, padding: '2px 8px' }}>
{resolution} · {fps} fps
</span>
)}
{/* Screen-Selektor */}
<select
value={screenIdx}
onChange={e => {
const newIdx = Number(e.target.value);
setScreenIdx(newIdx);
if (status === 'connected' && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'rdp_switch_screen', screen: newIdx }));
}
}}
disabled={status === 'connecting' || status === 'waiting'}
style={{ fontSize: 12, padding: '3px 8px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-tertiary)', color: 'var(--text-primary)', cursor: 'pointer', marginLeft: 4 }}
>
{SCREEN_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{!fullscreen && (
<button
onClick={() => window.open(`/rdp/${agentId}?screen=${screenIdx}`, '_blank')}
title="In neuem Tab öffnen"
style={{ background: 'transparent', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-muted)', padding: '4px 10px', fontSize: 12, cursor: 'pointer' }}
>
</button>
)}
{status === 'idle' || status === 'error' ? (
<button onClick={() => connect(screenIdx)} 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>
{/* Canvas-Bereich */}
{(status === 'idle' || status === 'error') ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: fullscreen ? 0 : 320, flex: fullscreen ? 1 : undefined, gap: 14, color: 'var(--text-muted)' }}>
<span style={{ fontSize: 44 }}>🖥</span>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 4 }}>Remote Desktop</div>
<div style={{ fontSize: 12 }}>Bildschirmübertragung via WebSocket · Agent v2.2.0+</div>
{error && <div style={{ marginTop: 8, color: '#ef4444', fontSize: 12 }}>{error}</div>}
</div>
</div>
) : status === 'waiting' ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: fullscreen ? 0 : 320, flex: fullscreen ? 1 : undefined, gap: 14, color: 'var(--text-muted)' }}>
<span style={{ fontSize: 44 }}></span>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#f59e0b', marginBottom: 4 }}>Warte auf Zustimmung</div>
<div style={{ fontSize: 12 }}>Der Benutzer muss den Bildschirmzugriff erst erlauben.</div>
</div>
</div>
) : (
<div style={{ background: '#000', lineHeight: 0, position: 'relative', flex: fullscreen ? 1 : undefined, overflow: fullscreen ? 'hidden' : undefined }}>
<canvas
ref={canvasRef}
style={{ width: '100%', height: fullscreen ? '100%' : 'auto', display: 'block', objectFit: 'contain' }}
/>
{status === 'connecting' && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.8)', color: '#fff', fontSize: 14, gap: 10 }}>
<span style={{ animation: 'spin 1s linear infinite', display: 'inline-block' }}></span> Verbinde
</div>
)}
</div>
)}
</div>
);
}