Compare commits
10 Commits
58d7df64e9
...
81b1c326fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b1c326fc | |||
| 3ea28def4c | |||
| cb59cfe9cf | |||
| e2521f405d | |||
| 7449b64398 | |||
| 99830e25f4 | |||
| 0b2943087d | |||
| c276b23293 | |||
| 54114b5b9f | |||
| 50bbfc6b22 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -35,6 +35,7 @@ agent/*.exe
|
|||||||
agent/*.msi
|
agent/*.msi
|
||||||
agent-cs/bin/
|
agent-cs/bin/
|
||||||
agent-cs/obj/
|
agent-cs/obj/
|
||||||
|
agent-cs/publish/
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
backend/uploads/
|
backend/uploads/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace ITNexusAgent;
|
|||||||
|
|
||||||
public class AgentWorker
|
public class AgentWorker
|
||||||
{
|
{
|
||||||
private const string Version = "2.2.0";
|
private const string Version = "2.7.0";
|
||||||
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
|
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
|
||||||
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
||||||
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
||||||
@@ -24,6 +24,7 @@ public class AgentWorker
|
|||||||
{
|
{
|
||||||
_exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
_exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
||||||
Directory.CreateDirectory(DataDir);
|
Directory.CreateDirectory(DataDir);
|
||||||
|
SecureDataDir(DataDir);
|
||||||
|
|
||||||
if (!File.Exists(ConfigPath))
|
if (!File.Exists(ConfigPath))
|
||||||
{
|
{
|
||||||
@@ -176,6 +177,30 @@ public class AgentWorker
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verzeichnis-ACL: nur SYSTEM/Administratoren — verhindert dass normale lokale User
|
||||||
|
// agent.log/status.json lesen (Hostname, letzter User, RDP-/Patch-Aktivität) oder manipulieren.
|
||||||
|
private static void SecureDataDir(string dir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var di = new System.IO.DirectoryInfo(dir);
|
||||||
|
var acl = di.GetAccessControl();
|
||||||
|
acl.SetAccessRuleProtection(true, false);
|
||||||
|
acl.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
"SYSTEM", System.Security.AccessControl.FileSystemRights.FullControl,
|
||||||
|
System.Security.AccessControl.InheritanceFlags.ContainerInherit | System.Security.AccessControl.InheritanceFlags.ObjectInherit,
|
||||||
|
System.Security.AccessControl.PropagationFlags.None,
|
||||||
|
System.Security.AccessControl.AccessControlType.Allow));
|
||||||
|
acl.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
"Administrators", System.Security.AccessControl.FileSystemRights.FullControl,
|
||||||
|
System.Security.AccessControl.InheritanceFlags.ContainerInherit | System.Security.AccessControl.InheritanceFlags.ObjectInherit,
|
||||||
|
System.Security.AccessControl.PropagationFlags.None,
|
||||||
|
System.Security.AccessControl.AccessControlType.Allow));
|
||||||
|
di.SetAccessControl(acl);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private static void SecureConfigFile(string path)
|
private static void SecureConfigFile(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public static class CaptureModeRunner
|
|||||||
private static readonly ImageCodecInfo JpegCodec =
|
private static readonly ImageCodecInfo JpegCodec =
|
||||||
ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
|
ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
|
||||||
|
|
||||||
public static void Run(string portStr, int screenIdx = 0)
|
public static void Run(string portStr, int screenIdx, string secret)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(portStr, out var port) || port <= 0) return;
|
if (!int.TryParse(portStr, out var port) || port <= 0) return;
|
||||||
|
|
||||||
@@ -20,11 +20,11 @@ public static class CaptureModeRunner
|
|||||||
using var tcp = new TcpClient();
|
using var tcp = new TcpClient();
|
||||||
tcp.Connect("127.0.0.1", port);
|
tcp.Connect("127.0.0.1", port);
|
||||||
var stream = tcp.GetStream();
|
var stream = tcp.GetStream();
|
||||||
|
stream.Write(System.Text.Encoding.ASCII.GetBytes(secret));
|
||||||
|
|
||||||
var encParams = new EncoderParameters(1);
|
var encParams = new EncoderParameters(1);
|
||||||
encParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, 60L);
|
encParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, 60L);
|
||||||
|
|
||||||
// Capture-Region bestimmen: 0 = alle Screens, 1-N = spezifischer Screen
|
|
||||||
var allScreens = System.Windows.Forms.Screen.AllScreens;
|
var allScreens = System.Windows.Forms.Screen.AllScreens;
|
||||||
Rectangle captureRect;
|
Rectangle captureRect;
|
||||||
if (screenIdx <= 0 || screenIdx > allScreens.Length)
|
if (screenIdx <= 0 || screenIdx > allScreens.Length)
|
||||||
@@ -40,14 +40,27 @@ public static class CaptureModeRunner
|
|||||||
captureRect = allScreens[screenIdx - 1].Bounds;
|
captureRect = allScreens[screenIdx - 1].Bounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uint lastHash = 0;
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
while (tcp.Connected)
|
while (tcp.Connected)
|
||||||
{
|
{
|
||||||
|
sw.Restart();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var bmp = new Bitmap(captureRect.Width, captureRect.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
|
using var bmp = new Bitmap(captureRect.Width, captureRect.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
|
||||||
using (var g = Graphics.FromImage(bmp))
|
using (var g = Graphics.FromImage(bmp))
|
||||||
g.CopyFromScreen(captureRect.X, captureRect.Y, 0, 0, new Size(captureRect.Width, captureRect.Height));
|
g.CopyFromScreen(captureRect.X, captureRect.Y, 0, 0, new Size(captureRect.Width, captureRect.Height));
|
||||||
|
|
||||||
|
var hash = SampleHash(bmp);
|
||||||
|
if (hash == lastHash)
|
||||||
|
{
|
||||||
|
var elapsed = (int)sw.ElapsedMilliseconds;
|
||||||
|
if (elapsed < 16) Thread.Sleep(16 - elapsed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastHash = hash;
|
||||||
|
|
||||||
byte[] jpeg;
|
byte[] jpeg;
|
||||||
using (var ms = new MemoryStream())
|
using (var ms = new MemoryStream())
|
||||||
{
|
{
|
||||||
@@ -55,16 +68,32 @@ public static class CaptureModeRunner
|
|||||||
jpeg = ms.ToArray();
|
jpeg = ms.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4-Byte Länge (LE) + JPEG-Daten
|
|
||||||
stream.Write(BitConverter.GetBytes(jpeg.Length));
|
stream.Write(BitConverter.GetBytes(jpeg.Length));
|
||||||
stream.Write(jpeg);
|
stream.Write(jpeg);
|
||||||
stream.Flush();
|
stream.Flush();
|
||||||
}
|
}
|
||||||
catch { break; }
|
catch { break; }
|
||||||
|
|
||||||
Thread.Sleep(150); // ~6-7 fps
|
var frameMs = (int)sw.ElapsedMilliseconds;
|
||||||
|
if (frameMs < 16) Thread.Sleep(16 - frameMs); // cap at ~60 fps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* Verbindung fehlgeschlagen → Exit */ }
|
catch { /* Verbindung fehlgeschlagen → Exit */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static uint SampleHash(Bitmap bmp)
|
||||||
|
{
|
||||||
|
// Sample 64 evenly-distributed pixels — fast change detection, O(1)
|
||||||
|
uint h = 2166136261u;
|
||||||
|
int w = bmp.Width, ht = bmp.Height;
|
||||||
|
int stepX = Math.Max(1, w / 8);
|
||||||
|
int stepY = Math.Max(1, ht / 8);
|
||||||
|
for (int y = 0; y < ht; y += stepY)
|
||||||
|
for (int x = 0; x < w; x += stepX)
|
||||||
|
{
|
||||||
|
var c = bmp.GetPixel(x, y);
|
||||||
|
h = (h ^ (uint)c.ToArgb()) * 16777619u;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ namespace ITNexusAgent;
|
|||||||
// Läuft als User-Prozess (via schtasks), zeigt Consent-Dialog und sendet Antwort via TCP
|
// Läuft als User-Prozess (via schtasks), zeigt Consent-Dialog und sendet Antwort via TCP
|
||||||
public static class ConsentModeRunner
|
public static class ConsentModeRunner
|
||||||
{
|
{
|
||||||
public static void Run(string portStr)
|
public static void Run(string portStr, string secret)
|
||||||
{
|
{
|
||||||
if (!int.TryParse(portStr, out var port) || port <= 0) return;
|
if (!int.TryParse(portStr, out var port) || port <= 0) return;
|
||||||
|
|
||||||
var app = new System.Windows.Application();
|
var app = new System.Windows.Application();
|
||||||
app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose;
|
app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose;
|
||||||
|
|
||||||
var win = new RdpConsentWindow(port);
|
var win = new RdpConsentWindow(port, secret);
|
||||||
win.Topmost = true;
|
win.Topmost = true;
|
||||||
win.Show();
|
win.Show();
|
||||||
win.Activate();
|
win.Activate();
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<AssemblyName>IT-Nexus-Agent</AssemblyName>
|
<AssemblyName>IT-Nexus-Agent</AssemblyName>
|
||||||
<RootNamespace>ITNexusAgent</RootNamespace>
|
<RootNamespace>ITNexusAgent</RootNamespace>
|
||||||
<Version>2.0.0</Version>
|
<Version>2.7.0</Version>
|
||||||
<AssemblyVersion>2.0.0.0</AssemblyVersion>
|
<AssemblyVersion>2.7.0.0</AssemblyVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||||
|
|||||||
20
agent-cs/IndicatorModeRunner.cs
Normal file
20
agent-cs/IndicatorModeRunner.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using ITNexusAgent.UI;
|
||||||
|
|
||||||
|
namespace ITNexusAgent;
|
||||||
|
|
||||||
|
// Läuft als User-Prozess (via SessionSpawner), zeigt dauerhaftes "Bildschirm wird übertragen"-Overlay
|
||||||
|
// solange RDP-Session aktiv ist. User kann selbst trennen (TCP-Signal an Service).
|
||||||
|
public static class IndicatorModeRunner
|
||||||
|
{
|
||||||
|
public static void Run(string portStr, string secret)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(portStr, out var port) || port <= 0) return;
|
||||||
|
|
||||||
|
var app = new System.Windows.Application();
|
||||||
|
app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose;
|
||||||
|
|
||||||
|
var win = new RdpActiveIndicatorWindow(port, secret);
|
||||||
|
win.Show();
|
||||||
|
app.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,22 @@ internal class Program
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
case "--rdp-consent":
|
case "--rdp-consent":
|
||||||
ConsentModeRunner.Run(args.Length > 1 ? args[1] : "");
|
ConsentModeRunner.Run(
|
||||||
|
args.Length > 1 ? args[1] : "",
|
||||||
|
args.Length > 2 ? args[2] : "");
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "--rdp-capture":
|
case "--rdp-capture":
|
||||||
CaptureModeRunner.Run(
|
CaptureModeRunner.Run(
|
||||||
args.Length > 1 ? args[1] : "",
|
args.Length > 1 ? args[1] : "",
|
||||||
args.Length > 2 && int.TryParse(args[2], out var si) ? si : 0);
|
args.Length > 2 && int.TryParse(args[2], out var si) ? si : 0,
|
||||||
|
args.Length > 3 ? args[3] : "");
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "--rdp-indicator":
|
||||||
|
IndicatorModeRunner.Run(
|
||||||
|
args.Length > 1 ? args[1] : "",
|
||||||
|
args.Length > 2 ? args[2] : "");
|
||||||
return;
|
return;
|
||||||
|
|
||||||
case "--dashboard":
|
case "--dashboard":
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ public class ApiService(string serverUrl, string agentKey)
|
|||||||
return await resp.Content.ReadAsByteArrayAsync();
|
return await resp.Content.ReadAsByteArrayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]> DownloadSetupAsync()
|
public async Task<byte[]> DownloadSetupAsync(string hostname)
|
||||||
{
|
{
|
||||||
var req = BuildRequest(HttpMethod.Get, "/api/monitoring/agent-setup");
|
var req = BuildRequest(HttpMethod.Get, $"/api/monitoring/agent-setup?hostname={Uri.EscapeDataString(hostname)}");
|
||||||
var resp = await _http.SendAsync(req);
|
var resp = await _http.SendAsync(req);
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
return await resp.Content.ReadAsByteArrayAsync();
|
return await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
|
|||||||
|
|
||||||
private async Task<string> UpdateAgent()
|
private async Task<string> UpdateAgent()
|
||||||
{
|
{
|
||||||
var bytes = await _api.DownloadSetupAsync();
|
var bytes = await _api.DownloadSetupAsync(_hostname);
|
||||||
if (bytes.Length < 512 * 1024) return "Download fehlgeschlagen - Installer zu klein";
|
if (bytes.Length < 512 * 1024) return "Download fehlgeschlagen - Installer zu klein";
|
||||||
|
|
||||||
var setupPath = Path.Combine(_dataDir, "IT-Nexus-Agent-Setup-Update.exe");
|
var setupPath = Path.Combine(_dataDir, "IT-Nexus-Agent-Setup-Update.exe");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -15,6 +16,9 @@ public class RtcService
|
|||||||
private readonly string _serverUrl;
|
private readonly string _serverUrl;
|
||||||
private readonly string _agentKey;
|
private readonly string _agentKey;
|
||||||
private readonly string _hostname;
|
private readonly string _hostname;
|
||||||
|
private int _indicatorPid = -1;
|
||||||
|
private TcpListener? _indicatorListener;
|
||||||
|
private CancellationTokenSource? _userDisconnectCts;
|
||||||
|
|
||||||
public RtcService(string serverUrl, string agentKey, string hostname)
|
public RtcService(string serverUrl, string agentKey, string hostname)
|
||||||
{
|
{
|
||||||
@@ -70,31 +74,81 @@ public class RtcService
|
|||||||
_ = ConsentAndCaptureAsync(ws, screenIdx, captureCts.Token);
|
_ = ConsentAndCaptureAsync(ws, screenIdx, captureCts.Token);
|
||||||
AgentWorker.Log($"RDP: Consent angefordert (screen={screenIdx})");
|
AgentWorker.Log($"RDP: Consent angefordert (screen={screenIdx})");
|
||||||
}
|
}
|
||||||
|
else if (type == "rdp_switch_screen" && captureCts != null)
|
||||||
|
{
|
||||||
|
// Consent bereits erteilt — nur Screen wechseln, kein erneuter Dialog
|
||||||
|
var newScreen = obj["screen"]?.ToObject<int>() ?? 0;
|
||||||
|
captureCts.Cancel();
|
||||||
|
captureCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
var switchToken = _userDisconnectCts != null
|
||||||
|
? CancellationTokenSource.CreateLinkedTokenSource(captureCts.Token, _userDisconnectCts.Token).Token
|
||||||
|
: captureCts.Token;
|
||||||
|
_ = CapturePipeLoopAsync(ws, newScreen, switchToken);
|
||||||
|
AgentWorker.Log($"RDP: Screen gewechselt zu {newScreen}");
|
||||||
|
}
|
||||||
else if (type == "rdp_stop" && captureCts != null)
|
else if (type == "rdp_stop" && captureCts != null)
|
||||||
{
|
{
|
||||||
captureCts.Cancel();
|
captureCts.Cancel();
|
||||||
captureCts = null;
|
captureCts = null;
|
||||||
|
StopIndicator();
|
||||||
AgentWorker.Log("RDP: Screen-Capture gestoppt");
|
AgentWorker.Log("RDP: Screen-Capture gestoppt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
captureCts?.Cancel();
|
captureCts?.Cancel();
|
||||||
|
StopIndicator();
|
||||||
AgentWorker.Log("RDP: Getrennt");
|
AgentWorker.Log("RDP: Getrennt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void StopIndicator()
|
||||||
|
{
|
||||||
|
_userDisconnectCts?.Cancel();
|
||||||
|
_userDisconnectCts = null;
|
||||||
|
try { _indicatorListener?.Stop(); } catch { }
|
||||||
|
_indicatorListener = null;
|
||||||
|
if (_indicatorPid > 0)
|
||||||
|
{
|
||||||
|
try { Process.GetProcessById(_indicatorPid).Kill(); } catch { }
|
||||||
|
_indicatorPid = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateSecret() => Convert.ToHexString(RandomNumberGenerator.GetBytes(16));
|
||||||
|
|
||||||
|
// Liest exakt secret.Length Bytes und vergleicht zeitkonstant — verhindert, dass ein beliebiger
|
||||||
|
// lokaler Prozess sich als der gespawnte Helper ausgibt und Consent/Disconnect/Frames vortäuscht.
|
||||||
|
private static async Task<bool> ValidateSecretAsync(NetworkStream stream, string secret, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var expected = Encoding.ASCII.GetBytes(secret);
|
||||||
|
var buf = new byte[expected.Length];
|
||||||
|
var read = 0;
|
||||||
|
while (read < buf.Length)
|
||||||
|
{
|
||||||
|
int n;
|
||||||
|
try { n = await stream.ReadAsync(buf.AsMemory(read, buf.Length - read), ct); }
|
||||||
|
catch { return false; }
|
||||||
|
if (n == 0) return false;
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
return CryptographicOperations.FixedTimeEquals(buf, expected);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ConsentAndCaptureAsync(ClientWebSocket ws, int screenIdx, CancellationToken ct)
|
private async Task ConsentAndCaptureAsync(ClientWebSocket ws, int screenIdx, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
||||||
|
var secret = GenerateSecret();
|
||||||
|
|
||||||
var consentListener = new TcpListener(IPAddress.Loopback, 0);
|
var consentListener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
consentListener.Start();
|
consentListener.Start();
|
||||||
var consentPort = ((IPEndPoint)consentListener.LocalEndpoint).Port;
|
var consentPort = ((IPEndPoint)consentListener.LocalEndpoint).Port;
|
||||||
|
|
||||||
if (!SpawnConsentHelper(exePath, consentPort.ToString()))
|
if (!SpawnConsentHelper(exePath, consentPort.ToString(), secret))
|
||||||
{
|
{
|
||||||
consentListener.Stop();
|
consentListener.Stop();
|
||||||
AgentWorker.Log("RDP: Consent-Helper fehlgeschlagen, starte ohne Consent");
|
AgentWorker.Log("RDP: Consent-Helper fehlgeschlagen, verweigere Zugriff");
|
||||||
await CapturePipeLoopAsync(ws, screenIdx, ct);
|
var failed = Encoding.UTF8.GetBytes("{\"type\":\"rdp_denied\",\"reason\":\"consent_spawn_failed\"}");
|
||||||
|
if (ws.State == WebSocketState.Open)
|
||||||
|
await ws.SendAsync(new ArraySegment<byte>(failed), WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +162,15 @@ public class RtcService
|
|||||||
{
|
{
|
||||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(35));
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(35));
|
||||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
||||||
using var tcp = await consentListener.AcceptTcpClientAsync(linked.Token);
|
while (!linked.IsCancellationRequested)
|
||||||
var b = tcp.GetStream().ReadByte();
|
{
|
||||||
accepted = b == 1;
|
using var tcp = await consentListener.AcceptTcpClientAsync(linked.Token);
|
||||||
|
var stream = tcp.GetStream();
|
||||||
|
if (!await ValidateSecretAsync(stream, secret, linked.Token)) continue; // fremder Connect-Versuch ohne gültiges Secret — ignorieren, weiter warten
|
||||||
|
var b = stream.ReadByte();
|
||||||
|
accepted = b == 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch { accepted = false; }
|
catch { accepted = false; }
|
||||||
finally { consentListener.Stop(); }
|
finally { consentListener.Stop(); }
|
||||||
@@ -125,72 +185,71 @@ public class RtcService
|
|||||||
}
|
}
|
||||||
|
|
||||||
AgentWorker.Log("RDP: User hat Zugriff erlaubt, starte Capture");
|
AgentWorker.Log("RDP: User hat Zugriff erlaubt, starte Capture");
|
||||||
await CapturePipeLoopAsync(ws, screenIdx, ct);
|
SpawnIndicator(exePath);
|
||||||
|
|
||||||
|
var combinedToken = _userDisconnectCts != null
|
||||||
|
? CancellationTokenSource.CreateLinkedTokenSource(ct, _userDisconnectCts.Token).Token
|
||||||
|
: ct;
|
||||||
|
await CapturePipeLoopAsync(ws, screenIdx, combinedToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool SpawnConsentHelper(string exePath, string portStr)
|
// Zeigt dauerhaftes "Bildschirm wird übertragen"-Overlay (TeamViewer-Style) solange Capture läuft.
|
||||||
|
// User kann per Klick selbst trennen — Signal landet hier als CancellationTokenSource.Cancel().
|
||||||
|
private void SpawnIndicator(string exePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fullUser = NotificationService.GetLoggedOnUser();
|
_indicatorListener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
if (string.IsNullOrEmpty(fullUser))
|
_indicatorListener.Start();
|
||||||
{
|
var port = ((IPEndPoint)_indicatorListener.LocalEndpoint).Port;
|
||||||
AgentWorker.Log("RDP: Kein eingeloggter User für Consent");
|
var secret = GenerateSecret();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var taskName = $"ITNexus-RDPConsent-{portStr}";
|
|
||||||
Process.Start(new ProcessStartInfo("schtasks.exe", $"/delete /tn \"{taskName}\" /f")
|
|
||||||
{ CreateNoWindow = true })?.WaitForExit();
|
|
||||||
|
|
||||||
var triggerTime = DateTime.Now.AddMinutes(60).ToString("HH:mm:ss");
|
|
||||||
var ruArg = fullUser.StartsWith("AzureAD\\", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "/ru \"INTERACTIVE\""
|
|
||||||
: $"/ru \"{fullUser}\"";
|
|
||||||
|
|
||||||
var args = $"/create /tn \"{taskName}\" /tr \"\\\"{exePath}\\\" --rdp-consent {portStr}\" " +
|
|
||||||
$"/sc ONCE /st {triggerTime} {ruArg} /it /f";
|
|
||||||
|
|
||||||
var p = Process.Start(new ProcessStartInfo("schtasks.exe", args)
|
|
||||||
{ CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false });
|
|
||||||
p?.WaitForExit();
|
|
||||||
|
|
||||||
if (p?.ExitCode != 0)
|
|
||||||
{
|
|
||||||
AgentWorker.Log($"RDP: schtasks consent fehlgeschlagen (ExitCode={p?.ExitCode})");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo("schtasks.exe", $"/run /tn \"{taskName}\"")
|
|
||||||
{ CreateNoWindow = true })?.WaitForExit();
|
|
||||||
|
|
||||||
|
_userDisconnectCts = new CancellationTokenSource();
|
||||||
|
var listener = _indicatorListener;
|
||||||
|
var disconnectCts = _userDisconnectCts;
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Task.Delay(40000);
|
try
|
||||||
Process.Start(new ProcessStartInfo("schtasks.exe", $"/delete /tn \"{taskName}\" /f")
|
{
|
||||||
{ CreateNoWindow = true })?.WaitForExit();
|
while (true)
|
||||||
|
{
|
||||||
|
using var tcp = await listener.AcceptTcpClientAsync();
|
||||||
|
var stream = tcp.GetStream();
|
||||||
|
if (!await ValidateSecretAsync(stream, secret, CancellationToken.None)) continue; // fremder Connect-Versuch ohne gültiges Secret
|
||||||
|
stream.ReadByte();
|
||||||
|
disconnectCts.Cancel();
|
||||||
|
AgentWorker.Log("RDP: User hat über Overlay getrennt");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
});
|
});
|
||||||
|
|
||||||
AgentWorker.Log($"RDP: Consent-Helper gestartet als '{fullUser}'");
|
_indicatorPid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-indicator {port} {secret}");
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AgentWorker.Log($"RDP: SpawnConsentHelper Fehler: {ex.Message}");
|
AgentWorker.Log($"RDP: Indicator-Start fehlgeschlagen: {ex.Message}");
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool SpawnConsentHelper(string exePath, string portStr, string secret)
|
||||||
|
{
|
||||||
|
var pid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-consent {portStr} {secret}");
|
||||||
|
return pid > 0;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CapturePipeLoopAsync(ClientWebSocket ws, int screenIdx, CancellationToken ct)
|
private async Task CapturePipeLoopAsync(ClientWebSocket ws, int screenIdx, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
||||||
|
var secret = GenerateSecret();
|
||||||
|
|
||||||
// TCP Loopback: kein ACL-Problem zwischen SYSTEM-Service und User-Prozess
|
// TCP Loopback: kein ACL-Problem zwischen SYSTEM-Service und User-Prozess
|
||||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||||
listener.Start();
|
listener.Start();
|
||||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
|
||||||
if (!SpawnCaptureHelper(exePath, port.ToString(), screenIdx))
|
if (!SpawnCaptureHelper(exePath, port.ToString(), screenIdx, secret))
|
||||||
{
|
{
|
||||||
listener.Stop();
|
listener.Stop();
|
||||||
AgentWorker.Log("RDP: Helper-Start fehlgeschlagen");
|
AgentWorker.Log("RDP: Helper-Start fehlgeschlagen");
|
||||||
@@ -202,7 +261,16 @@ public class RtcService
|
|||||||
{
|
{
|
||||||
using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, connectCts.Token);
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, connectCts.Token);
|
||||||
tcp = await listener.AcceptTcpClientAsync(linked.Token);
|
while (true)
|
||||||
|
{
|
||||||
|
var candidate = await listener.AcceptTcpClientAsync(linked.Token);
|
||||||
|
if (await ValidateSecretAsync(candidate.GetStream(), secret, linked.Token))
|
||||||
|
{
|
||||||
|
tcp = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
candidate.Dispose(); // fremder Connect-Versuch ohne gültiges Secret — verwerfen, weiter warten
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -260,58 +328,9 @@ public class RtcService
|
|||||||
AgentWorker.Log("RDP: Frame-Loop beendet");
|
AgentWorker.Log("RDP: Frame-Loop beendet");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool SpawnCaptureHelper(string exePath, string portStr, int screenIdx = 0)
|
private static bool SpawnCaptureHelper(string exePath, string portStr, int screenIdx, string secret)
|
||||||
{
|
{
|
||||||
try
|
var pid = SessionSpawner.SpawnInUserSession(exePath, $"--rdp-capture {portStr} {screenIdx} {secret}");
|
||||||
{
|
return pid > 0;
|
||||||
var fullUser = NotificationService.GetLoggedOnUser();
|
|
||||||
if (string.IsNullOrEmpty(fullUser))
|
|
||||||
{
|
|
||||||
AgentWorker.Log("RDP: Kein eingeloggter User gefunden");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var taskName = $"ITNexus-RDP-{portStr}";
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo("schtasks.exe", $"/delete /tn \"{taskName}\" /f")
|
|
||||||
{ CreateNoWindow = true })?.WaitForExit();
|
|
||||||
|
|
||||||
var triggerTime = DateTime.Now.AddMinutes(60).ToString("HH:mm:ss");
|
|
||||||
var ruArg = fullUser.StartsWith("AzureAD\\", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "/ru \"INTERACTIVE\""
|
|
||||||
: $"/ru \"{fullUser}\"";
|
|
||||||
|
|
||||||
var args = $"/create /tn \"{taskName}\" /tr \"\\\"{exePath}\\\" --rdp-capture {portStr} {screenIdx}\" " +
|
|
||||||
$"/sc ONCE /st {triggerTime} {ruArg} /it /f";
|
|
||||||
|
|
||||||
var p = Process.Start(new ProcessStartInfo("schtasks.exe", args)
|
|
||||||
{ CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false });
|
|
||||||
p?.WaitForExit();
|
|
||||||
|
|
||||||
if (p?.ExitCode != 0)
|
|
||||||
{
|
|
||||||
AgentWorker.Log($"RDP: schtasks create fehlgeschlagen (ExitCode={p?.ExitCode})");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo("schtasks.exe", $"/run /tn \"{taskName}\"")
|
|
||||||
{ CreateNoWindow = true })?.WaitForExit();
|
|
||||||
|
|
||||||
// Aufräumen nach kurzer Zeit
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(5000);
|
|
||||||
Process.Start(new ProcessStartInfo("schtasks.exe", $"/delete /tn \"{taskName}\" /f")
|
|
||||||
{ CreateNoWindow = true })?.WaitForExit();
|
|
||||||
});
|
|
||||||
|
|
||||||
AgentWorker.Log($"RDP: Helper gestartet als '{fullUser}' (Task: {taskName})");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AgentWorker.Log($"RDP: SpawnHelper Fehler: {ex.Message}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
160
agent-cs/Services/SessionSpawner.cs
Normal file
160
agent-cs/Services/SessionSpawner.cs
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Services;
|
||||||
|
|
||||||
|
// Startet Prozesse in der aktiven User-Session (aus SYSTEM-Service heraus)
|
||||||
|
// Korrekte Win32-Methode: WTSQueryUserToken + CreateProcessAsUser
|
||||||
|
public static class SessionSpawner
|
||||||
|
{
|
||||||
|
#region Win32 P/Invoke
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll")] static extern uint WTSGetActiveConsoleSessionId();
|
||||||
|
|
||||||
|
[DllImport("Wtsapi32.dll", SetLastError = true)]
|
||||||
|
static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);
|
||||||
|
|
||||||
|
[DllImport("Wtsapi32.dll", SetLastError = true)]
|
||||||
|
static extern bool WTSEnumerateSessions(IntPtr hServer, uint reserved, uint version,
|
||||||
|
out IntPtr ppSessionInfo, out uint pCount);
|
||||||
|
|
||||||
|
[DllImport("Wtsapi32.dll")] static extern void WTSFreeMemory(IntPtr pMemory);
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true)]
|
||||||
|
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess,
|
||||||
|
IntPtr lpTokenAttributes, int impersonationLevel, int tokenType, out IntPtr phNewToken);
|
||||||
|
|
||||||
|
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||||
|
static extern bool CreateProcessAsUser(IntPtr hToken, string? lpApplicationName,
|
||||||
|
string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
|
||||||
|
bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment,
|
||||||
|
string? lpCurrentDirectory, ref STARTUPINFO lpStartupInfo,
|
||||||
|
out PROCESS_INFORMATION lpProcessInformation);
|
||||||
|
|
||||||
|
[DllImport("userenv.dll", SetLastError = true)]
|
||||||
|
static extern bool CreateEnvironmentBlock(out IntPtr lpEnvironment, IntPtr hToken, bool bInherit);
|
||||||
|
|
||||||
|
[DllImport("userenv.dll", SetLastError = true)]
|
||||||
|
static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
|
||||||
|
|
||||||
|
[DllImport("kernel32.dll", SetLastError = true)]
|
||||||
|
static extern bool CloseHandle(IntPtr hObject);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
struct STARTUPINFO
|
||||||
|
{
|
||||||
|
public int cb; public string? lpReserved; public string? lpDesktop; public string? lpTitle;
|
||||||
|
public uint dwX, dwY, dwXSize, dwYSize, dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
|
||||||
|
public ushort wShowWindow, cbReserved2; public IntPtr lpReserved2;
|
||||||
|
public IntPtr hStdInput, hStdOutput, hStdError;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
struct PROCESS_INFORMATION
|
||||||
|
{
|
||||||
|
public IntPtr hProcess, hThread;
|
||||||
|
public uint dwProcessId, dwThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
struct WTS_SESSION_INFO
|
||||||
|
{
|
||||||
|
public uint SessionId; [MarshalAs(UnmanagedType.LPStr)] public string? pWinStationName;
|
||||||
|
public int State; // 0=Active
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
// Spawnt Prozess als eingeloggter Desktop-User. Gibt PID zurück oder -1 bei Fehler.
|
||||||
|
public static int SpawnInUserSession(string exePath, string args)
|
||||||
|
{
|
||||||
|
var sessionId = FindActiveUserSession();
|
||||||
|
if (sessionId == uint.MaxValue)
|
||||||
|
{
|
||||||
|
AgentWorker.Log("SessionSpawner: Keine aktive User-Session gefunden");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!WTSQueryUserToken(sessionId, out var userToken))
|
||||||
|
{
|
||||||
|
AgentWorker.Log($"SessionSpawner: WTSQueryUserToken fehlgeschlagen (Session={sessionId}, Error={Marshal.GetLastWin32Error()})");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!DuplicateTokenEx(userToken, 0x10000000u, IntPtr.Zero, 2, 1, out var dupToken))
|
||||||
|
{
|
||||||
|
AgentWorker.Log($"SessionSpawner: DuplicateTokenEx fehlgeschlagen (Error={Marshal.GetLastWin32Error()})");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
CreateEnvironmentBlock(out var envBlock, dupToken, false);
|
||||||
|
|
||||||
|
var si = new STARTUPINFO
|
||||||
|
{
|
||||||
|
cb = Marshal.SizeOf<STARTUPINFO>(),
|
||||||
|
lpDesktop = "winsta0\\default",
|
||||||
|
dwFlags = 1, // STARTF_USESHOWWINDOW
|
||||||
|
wShowWindow = 1 // SW_SHOWNORMAL
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmdLine = $"\"{exePath}\" {args}";
|
||||||
|
bool ok = CreateProcessAsUser(dupToken, null, cmdLine,
|
||||||
|
IntPtr.Zero, IntPtr.Zero, false,
|
||||||
|
0x0400, // CREATE_UNICODE_ENVIRONMENT
|
||||||
|
envBlock, null, ref si, out var pi);
|
||||||
|
|
||||||
|
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
AgentWorker.Log($"SessionSpawner: CreateProcessAsUser fehlgeschlagen (Error={Marshal.GetLastWin32Error()})");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(pi.hThread);
|
||||||
|
CloseHandle(pi.hProcess);
|
||||||
|
AgentWorker.Log($"SessionSpawner: Prozess gestartet (PID={pi.dwProcessId}, Session={sessionId})");
|
||||||
|
return (int)pi.dwProcessId;
|
||||||
|
}
|
||||||
|
finally { CloseHandle(dupToken); }
|
||||||
|
}
|
||||||
|
finally { CloseHandle(userToken); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint FindActiveUserSession()
|
||||||
|
{
|
||||||
|
// Zuerst Console-Session versuchen
|
||||||
|
var consoleSession = WTSGetActiveConsoleSessionId();
|
||||||
|
if (consoleSession != uint.MaxValue && TryGetTokenForSession(consoleSession))
|
||||||
|
return consoleSession;
|
||||||
|
|
||||||
|
// Alle Sessions durchsuchen → erste aktive (State=0) nehmen
|
||||||
|
if (!WTSEnumerateSessions(IntPtr.Zero, 0, 1, out var pInfo, out var count))
|
||||||
|
return uint.MaxValue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var size = Marshal.SizeOf<WTS_SESSION_INFO>();
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var info = Marshal.PtrToStructure<WTS_SESSION_INFO>(IntPtr.Add(pInfo, i * size));
|
||||||
|
if (info.State == 0 && info.SessionId != 0) // Active, nicht Session 0
|
||||||
|
return info.SessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { WTSFreeMemory(pInfo); }
|
||||||
|
|
||||||
|
return uint.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryGetTokenForSession(uint sessionId)
|
||||||
|
{
|
||||||
|
if (!WTSQueryUserToken(sessionId, out var tok)) return false;
|
||||||
|
CloseHandle(tok);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ public class ShellService
|
|||||||
+ $"/ws?type=agent&key={Uri.EscapeDataString(_agentKey)}&hostname={Uri.EscapeDataString(_hostname)}";
|
+ $"/ws?type=agent&key={Uri.EscapeDataString(_agentKey)}&hostname={Uri.EscapeDataString(_hostname)}";
|
||||||
|
|
||||||
using var ws = new ClientWebSocket();
|
using var ws = new ClientWebSocket();
|
||||||
ws.Options.SetRequestHeader("User-Agent", "IT-Nexus-Agent/2.1.2");
|
ws.Options.SetRequestHeader("User-Agent", "IT-Nexus-Agent/2.3.0");
|
||||||
|
|
||||||
await ws.ConnectAsync(new Uri(wsUrl), ct);
|
await ws.ConnectAsync(new Uri(wsUrl), ct);
|
||||||
AgentWorker.Log("SHELL: WebSocket verbunden");
|
AgentWorker.Log("SHELL: WebSocket verbunden");
|
||||||
|
|||||||
57
agent-cs/UI/RdpActiveIndicatorWindow.xaml
Normal file
57
agent-cs/UI/RdpActiveIndicatorWindow.xaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<Window x:Class="ITNexusAgent.UI.RdpActiveIndicatorWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="IT Nexus – Bildschirmübertragung aktiv"
|
||||||
|
Width="300" Height="64"
|
||||||
|
WindowStartupLocation="Manual"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Background="Transparent"
|
||||||
|
Topmost="True"
|
||||||
|
ShowInTaskbar="False"
|
||||||
|
FontFamily="Segoe UI">
|
||||||
|
|
||||||
|
<Border Background="#1A1D2E" BorderBrush="#E67E22" BorderThickness="1.5" CornerRadius="10">
|
||||||
|
<Grid Margin="14,10,14,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Pulsierender roter Punkt -->
|
||||||
|
<Ellipse x:Name="PulseDot" Grid.Column="0" Width="10" Height="10" Fill="#E74C3C"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0">
|
||||||
|
<Ellipse.Triggers>
|
||||||
|
</Ellipse.Triggers>
|
||||||
|
</Ellipse>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="Bildschirm wird übertragen" FontSize="12" FontWeight="Bold" Foreground="White"/>
|
||||||
|
<TextBlock x:Name="DurationText" Text="IT-Abteilung sieht zu · 00:00" FontSize="10.5" Foreground="#9099B5" Margin="0,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button x:Name="DisconnectButton" Grid.Column="2" Click="DisconnectButton_Click"
|
||||||
|
Background="#3D1414" Foreground="#FF8A80" BorderThickness="1" BorderBrush="#6B2020"
|
||||||
|
Padding="10,6" Cursor="Hand" VerticalAlignment="Center" FontSize="11" FontWeight="Bold">
|
||||||
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="6" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
<TextBlock Text="Trennen"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<Storyboard x:Key="PulseAnim" RepeatBehavior="Forever">
|
||||||
|
<DoubleAnimation Storyboard.TargetName="PulseDot" Storyboard.TargetProperty="Opacity"
|
||||||
|
From="1.0" To="0.25" Duration="0:0:0.9" AutoReverse="True"/>
|
||||||
|
</Storyboard>
|
||||||
|
</Window.Resources>
|
||||||
|
</Window>
|
||||||
60
agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs
Normal file
60
agent-cs/UI/RdpActiveIndicatorWindow.xaml.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media.Animation;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.UI;
|
||||||
|
|
||||||
|
public partial class RdpActiveIndicatorWindow : Window
|
||||||
|
{
|
||||||
|
private readonly int _port;
|
||||||
|
private readonly string _secret;
|
||||||
|
private readonly DateTime _startedAt = DateTime.Now;
|
||||||
|
private readonly System.Windows.Threading.DispatcherTimer _timer = new();
|
||||||
|
private bool _signaled = false;
|
||||||
|
|
||||||
|
public RdpActiveIndicatorWindow(int port, string secret)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_port = port;
|
||||||
|
_secret = secret;
|
||||||
|
|
||||||
|
Loaded += (s, e) =>
|
||||||
|
{
|
||||||
|
var area = SystemParameters.WorkArea;
|
||||||
|
Left = area.Right - Width - 16;
|
||||||
|
Top = area.Bottom - Height - 16;
|
||||||
|
((Storyboard)Resources["PulseAnim"]).Begin(PulseDot);
|
||||||
|
};
|
||||||
|
|
||||||
|
_timer.Interval = TimeSpan.FromSeconds(1);
|
||||||
|
_timer.Tick += (s, e) =>
|
||||||
|
{
|
||||||
|
var elapsed = DateTime.Now - _startedAt;
|
||||||
|
DurationText.Text = $"IT-Abteilung sieht zu · {elapsed:mm\\:ss}";
|
||||||
|
};
|
||||||
|
_timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisconnectButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
SendDisconnectSignal();
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendDisconnectSignal()
|
||||||
|
{
|
||||||
|
if (_signaled) return;
|
||||||
|
_signaled = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var tcp = new TcpClient();
|
||||||
|
tcp.Connect("127.0.0.1", _port);
|
||||||
|
var stream = tcp.GetStream();
|
||||||
|
stream.Write(Encoding.ASCII.GetBytes(_secret));
|
||||||
|
stream.WriteByte(1);
|
||||||
|
stream.Flush();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
<Window x:Class="ITNexusAgent.UI.RdpConsentWindow"
|
<Window x:Class="ITNexusAgent.UI.RdpConsentWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="IT Nexus – Bildschirmzugriff"
|
Title="IT Nexus – Bildschirmzugriff angefordert"
|
||||||
Width="480" Height="Auto"
|
Width="480" Height="Auto"
|
||||||
SizeToContent="Height"
|
SizeToContent="Height"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
ResizeMode="NoResize"
|
ResizeMode="NoResize"
|
||||||
WindowStyle="None"
|
WindowStyle="ToolWindow"
|
||||||
AllowsTransparency="False"
|
AllowsTransparency="False"
|
||||||
Background="#1A1D2E"
|
Background="#1A1D2E"
|
||||||
Topmost="True"
|
Topmost="True"
|
||||||
|
ShowInTaskbar="True"
|
||||||
FontFamily="Segoe UI">
|
FontFamily="Segoe UI">
|
||||||
|
|
||||||
<Window.Resources>
|
<Window.Resources>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
|
||||||
namespace ITNexusAgent.UI;
|
namespace ITNexusAgent.UI;
|
||||||
@@ -6,13 +7,15 @@ namespace ITNexusAgent.UI;
|
|||||||
public partial class RdpConsentWindow : Window
|
public partial class RdpConsentWindow : Window
|
||||||
{
|
{
|
||||||
private readonly int _port;
|
private readonly int _port;
|
||||||
|
private readonly string _secret;
|
||||||
private bool _answered = false;
|
private bool _answered = false;
|
||||||
private System.Threading.CancellationTokenSource _countdownCts = new();
|
private System.Threading.CancellationTokenSource _countdownCts = new();
|
||||||
|
|
||||||
public RdpConsentWindow(int port)
|
public RdpConsentWindow(int port, string secret)
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_port = port;
|
_port = port;
|
||||||
|
_secret = secret;
|
||||||
_ = RunCountdownAsync(_countdownCts.Token);
|
_ = RunCountdownAsync(_countdownCts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +56,10 @@ public partial class RdpConsentWindow : Window
|
|||||||
{
|
{
|
||||||
using var tcp = new TcpClient();
|
using var tcp = new TcpClient();
|
||||||
tcp.Connect("127.0.0.1", _port);
|
tcp.Connect("127.0.0.1", _port);
|
||||||
tcp.GetStream().WriteByte((byte)(accepted ? 1 : 0));
|
var stream = tcp.GetStream();
|
||||||
tcp.GetStream().Flush();
|
stream.Write(Encoding.ASCII.GetBytes(_secret));
|
||||||
|
stream.WriteByte((byte)(accepted ? 1 : 0));
|
||||||
|
stream.Flush();
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#define MyAppName "IT Nexus Agent"
|
#define MyAppName "IT Nexus Agent"
|
||||||
#define MyAppVersion "2.2.0"
|
#define MyAppVersion "2.7.0"
|
||||||
#define MyAppPublisher "Cereda Systems GmbH"
|
#define MyAppPublisher "Cereda Systems GmbH"
|
||||||
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||||
#define MyAppExeName "IT-Nexus-Agent.exe"
|
#define MyAppExeName "IT-Nexus-Agent.exe"
|
||||||
@@ -64,17 +64,31 @@ Type: files; Name: "{commonappdata}\IT Nexus Agent\agent.log"
|
|||||||
procedure CurStepChanged(CurStep: TSetupStep);
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
|
AppData: String;
|
||||||
begin
|
begin
|
||||||
if CurStep = ssInstall then begin
|
if CurStep = ssInstall then begin
|
||||||
// Service stoppen (neue Architektur)
|
// Service stoppen (neue Architektur)
|
||||||
Exec('net.exe', 'stop "IT Nexus Agent"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec('net.exe', 'stop "IT Nexus Agent"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
// Alte Scheduled Tasks (v1.x PowerShell-Agent) entfernen
|
Sleep(1500);
|
||||||
|
|
||||||
|
// Verwaiste Prozesse hart killen (überlebt Service-Stop manchmal, z.B. Consent/Capture-Helper)
|
||||||
|
Exec('taskkill.exe', '/F /IM IT-Nexus-Agent.exe /T', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
Sleep(1000);
|
||||||
|
|
||||||
|
// Reste eines laufenden Self-Update-Zyklus entfernen (update.cmd + heruntergeladener Installer)
|
||||||
|
AppData := ExpandConstant('{commonappdata}\IT Nexus Agent');
|
||||||
|
if FileExists(AppData + '\update.cmd') then
|
||||||
|
DeleteFile(AppData + '\update.cmd');
|
||||||
|
if FileExists(AppData + '\IT-Nexus-Agent-Setup-Update.exe') then
|
||||||
|
DeleteFile(AppData + '\IT-Nexus-Agent-Setup-Update.exe');
|
||||||
|
|
||||||
|
// Alte Scheduled Tasks (v1.x PowerShell-Agent + RDP-Helper-Reste) entfernen
|
||||||
Exec('schtasks.exe', '/delete /tn "IT Nexus Agent" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec('schtasks.exe', '/delete /tn "IT Nexus Agent" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
Exec('schtasks.exe', '/delete /tn "IT Nexus Agent Watcher" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec('schtasks.exe', '/delete /tn "IT Nexus Agent Watcher" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
Exec('schtasks.exe', '/delete /tn "ITNexusAgent" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec('schtasks.exe', '/delete /tn "ITNexusAgent" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
Exec('schtasks.exe', '/delete /tn "ITNexusAgentWatcher" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
Exec('schtasks.exe', '/delete /tn "ITNexusAgentWatcher" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
// Autostart-Eintrag aus vorheriger Version entfernen (falls vorhanden)
|
// Autostart-Eintrag aus vorheriger Version entfernen (falls vorhanden)
|
||||||
RegDeleteValue(HKCU, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Run', 'IT Nexus Agent');
|
RegDeleteValue(HKCU, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Run', 'IT Nexus Agent');
|
||||||
Sleep(2000);
|
Sleep(1500);
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|||||||
20
backend/package-lock.json
generated
20
backend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"botbuilder": "^4.23.3",
|
"botbuilder": "^4.23.3",
|
||||||
"bwip-js": "^4.8.0",
|
"bwip-js": "^4.8.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -1371,6 +1372,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"botbuilder": "^4.23.3",
|
"botbuilder": "^4.23.3",
|
||||||
"bwip-js": "^4.8.0",
|
"bwip-js": "^4.8.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
console.error('❌ FATAL: JWT_SECRET ist nicht gesetzt. Server wird nicht mit einem unsicheren Default-Secret gestartet.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
const JWT_CONFIG = {
|
const JWT_CONFIG = {
|
||||||
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
|
secret: process.env.JWT_SECRET,
|
||||||
expiresIn: process.env.JWT_EXPIRATION || '8h',
|
expiresIn: process.env.JWT_EXPIRATION || '8h',
|
||||||
algorithm: 'HS256'
|
algorithm: 'HS256'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate that JWT_SECRET is set
|
|
||||||
if (!process.env.JWT_SECRET) {
|
|
||||||
console.warn('⚠️ WARNING: JWT_SECRET not set in .env file. Using default secret (INSECURE!)');
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = JWT_CONFIG;
|
module.exports = JWT_CONFIG;
|
||||||
|
|||||||
@@ -160,8 +160,12 @@ class AiController {
|
|||||||
const { url } = req.body;
|
const { url } = req.body;
|
||||||
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||||
|
|
||||||
// Only allow http/https
|
const { assertPublicUrl } = require('../utils/ssrfGuard');
|
||||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
try {
|
||||||
|
await assertPublicUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
throw new AppError(e.message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
let html;
|
let html;
|
||||||
try {
|
try {
|
||||||
@@ -204,7 +208,12 @@ class AiController {
|
|||||||
const AiService = require('../services/ai.service');
|
const AiService = require('../services/ai.service');
|
||||||
const { url, maxPages = 20 } = req.body;
|
const { url, maxPages = 20 } = req.body;
|
||||||
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||||
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
const { assertPublicUrl } = require('../utils/ssrfGuard');
|
||||||
|
try {
|
||||||
|
await assertPublicUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
throw new AppError(e.message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
|
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
|
||||||
const baseUrl = new URL(url);
|
const baseUrl = new URL(url);
|
||||||
@@ -236,6 +245,7 @@ class AiController {
|
|||||||
visited.add(currentUrl);
|
visited.add(currentUrl);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
try { await assertPublicUrl(currentUrl); } catch { continue; } // DNS-Rebinding-Schutz
|
||||||
const response = await fetch(currentUrl, {
|
const response = await fetch(currentUrl, {
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const AuthService = require('../services/auth.service');
|
const AuthService = require('../services/auth.service');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const { asyncHandler } = require('../middleware/errorHandler');
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { setAuthCookie, clearAuthCookie } = require('../utils/authCookie');
|
||||||
|
|
||||||
class AuthController {
|
class AuthController {
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +19,11 @@ class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await AuthService.login(username, password);
|
const result = await AuthService.login(username, password);
|
||||||
|
setAuthCookie(res, result.token);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
data: result
|
data: { user: result.user }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,8 +106,7 @@ class AuthController {
|
|||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
*/
|
*/
|
||||||
static logout = asyncHandler(async (req, res) => {
|
static logout = asyncHandler(async (req, res) => {
|
||||||
// Client-side will handle token removal
|
clearAuthCookie(res);
|
||||||
// This endpoint is just for consistency and potential future server-side session handling
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: 'Logged out successfully'
|
message: 'Logged out successfully'
|
||||||
|
|||||||
@@ -212,7 +212,26 @@ const downloadSetup = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const version = process.env.AGENT_VERSION || '2.0.0';
|
|
||||||
|
// Zielversion: Gruppen-spezifisch (staged rollout) oder globaler Default — gleiche Logik wie checkin()
|
||||||
|
let version = process.env.AGENT_VERSION || '2.0.0';
|
||||||
|
const hostname = req.query.hostname;
|
||||||
|
if (hostname) {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
|
||||||
|
const groupRow = agent && db.prepare(`
|
||||||
|
SELECT pg.target_agent_version
|
||||||
|
FROM patch_agent_groups pag
|
||||||
|
JOIN patch_groups pg ON pg.id = pag.group_id
|
||||||
|
WHERE pag.agent_id = ?
|
||||||
|
ORDER BY pg.sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(agent.id);
|
||||||
|
if (groupRow?.target_agent_version) version = groupRow.target_agent_version;
|
||||||
|
} catch { /* kein Gruppe zugewiesen → global */ }
|
||||||
|
}
|
||||||
|
|
||||||
const setupPath = path.join(__dirname, `../../agent/IT-Nexus-Agent-Setup-v${version}.exe`);
|
const setupPath = path.join(__dirname, `../../agent/IT-Nexus-Agent-Setup-v${version}.exe`);
|
||||||
if (!fs.existsSync(setupPath)) {
|
if (!fs.existsSync(setupPath)) {
|
||||||
return res.status(404).json({ status: 'error', message: 'Setup nicht gefunden' });
|
return res.status(404).json({ status: 'error', message: 'Setup nicht gefunden' });
|
||||||
|
|||||||
@@ -1,4 +1,72 @@
|
|||||||
const { getDatabase } = require('../config/database');
|
const { getDatabase } = require('../config/database');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// In-memory CVE cache — refresh every hour
|
||||||
|
let cveCache = { ts: 0, data: [] };
|
||||||
|
|
||||||
|
function nvdGet(path) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.get({
|
||||||
|
hostname: 'services.nvd.nist.gov',
|
||||||
|
path,
|
||||||
|
headers: { 'User-Agent': 'IT-Nexus-TV/1.0' },
|
||||||
|
}, (res) => {
|
||||||
|
let raw = '';
|
||||||
|
res.on('data', c => raw += c);
|
||||||
|
res.on('end', () => { try { resolve(JSON.parse(raw)); } catch (e) { reject(e); } });
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
setTimeout(() => { req.destroy(); reject(new Error('timeout')); }, 15000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCVE(v) {
|
||||||
|
const cve = v.cve;
|
||||||
|
const m31 = cve.metrics?.cvssMetricV31 || [];
|
||||||
|
const m30 = cve.metrics?.cvssMetricV30 || [];
|
||||||
|
const m2 = cve.metrics?.cvssMetricV2 || [];
|
||||||
|
const m = m31[0] || m30[0] || m2[0];
|
||||||
|
const score = m?.cvssData?.baseScore || 0;
|
||||||
|
let sevRaw = (m?.cvssData?.baseSeverity || m?.baseSeverity || 'medium').toLowerCase();
|
||||||
|
if (!['critical','high','medium','low'].includes(sevRaw)) {
|
||||||
|
sevRaw = score >= 9 ? 'critical' : score >= 7 ? 'high' : score >= 4 ? 'medium' : 'low';
|
||||||
|
}
|
||||||
|
const desc = cve.descriptions?.find(d => d.lang === 'en')?.value || '';
|
||||||
|
const sentence = desc.split(/\.\s/)[0].replace(/\s+/g, ' ').trim();
|
||||||
|
const title = sentence.length > 110 ? sentence.substring(0, 110) + '…' : sentence || cve.id;
|
||||||
|
const pub = new Date(cve.published);
|
||||||
|
const diffH = Math.round((Date.now() - pub.getTime()) / 3600000);
|
||||||
|
const published = diffH < 1 ? '< 1 Std.' : diffH < 24 ? `${diffH} Std.` : `${Math.round(diffH / 24)} Tag${Math.round(diffH / 24) !== 1 ? 'e' : ''}`;
|
||||||
|
let vendor = 'NVD';
|
||||||
|
const cpe = cve.configurations?.[0]?.nodes?.[0]?.cpeMatch?.[0]?.criteria || '';
|
||||||
|
const cm = cpe.match(/cpe:2\.3:[ao]:([^:]+):/);
|
||||||
|
if (cm) vendor = cm[1].charAt(0).toUpperCase() + cm[1].slice(1).replace(/_/g, ' ');
|
||||||
|
return { id: cve.id, sev: sevRaw, cvss: score, title, vendor, published };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCVEs() {
|
||||||
|
try {
|
||||||
|
// Step 1: get total count (fast, no filters)
|
||||||
|
const r1 = await nvdGet('/rest/json/cves/2.0?resultsPerPage=1');
|
||||||
|
const total = r1.totalResults || 0;
|
||||||
|
if (total < 5) return null;
|
||||||
|
|
||||||
|
// Step 2: fetch the last ~60 entries (newest published CVEs)
|
||||||
|
const startIndex = Math.max(0, total - 60);
|
||||||
|
const r2 = await nvdGet(`/rest/json/cves/2.0?resultsPerPage=60&startIndex=${startIndex}`);
|
||||||
|
const vulns = (r2.vulnerabilities || []).reverse(); // newest first
|
||||||
|
|
||||||
|
const mapped = vulns.map(mapCVE).filter(c => c.cvss >= 5);
|
||||||
|
// Prefer high/critical, then fill with medium
|
||||||
|
const high = mapped.filter(c => ['critical','high'].includes(c.sev));
|
||||||
|
const medium = mapped.filter(c => c.sev === 'medium');
|
||||||
|
const merged = [...high, ...medium].slice(0, 5);
|
||||||
|
|
||||||
|
return merged.length >= 3 ? merged : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getStats(req, res) {
|
async function getStats(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -73,4 +141,20 @@ async function getStats(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getStats };
|
async function getCVEs(req, res) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - cveCache.ts < 3600000 && cveCache.data.length >= 3) {
|
||||||
|
return res.json({ status: 'success', data: cveCache.data, cached: true });
|
||||||
|
}
|
||||||
|
const fresh = await fetchCVEs();
|
||||||
|
if (fresh) {
|
||||||
|
cveCache = { ts: now, data: fresh };
|
||||||
|
return res.json({ status: 'success', data: fresh, cached: false });
|
||||||
|
}
|
||||||
|
if (cveCache.data.length >= 3) {
|
||||||
|
return res.json({ status: 'success', data: cveCache.data, cached: true });
|
||||||
|
}
|
||||||
|
res.status(503).json({ status: 'error', message: 'CVE-Feed nicht erreichbar' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStats, getCVEs };
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ async function initializeDatabase() {
|
|||||||
`ALTER TABLE fido_keys ADD COLUMN last_used_at DATETIME`,
|
`ALTER TABLE fido_keys ADD COLUMN last_used_at DATETIME`,
|
||||||
`ALTER TABLE fido_keys ADD COLUMN manufacturer TEXT`,
|
`ALTER TABLE fido_keys ADD COLUMN manufacturer TEXT`,
|
||||||
`ALTER TABLE fido_keys ADD COLUMN connection_type TEXT`,
|
`ALTER TABLE fido_keys ADD COLUMN connection_type TEXT`,
|
||||||
|
`ALTER TABLE fido_keys ADD COLUMN pin TEXT`,
|
||||||
// Asset-Agent-Sync
|
// Asset-Agent-Sync
|
||||||
`ALTER TABLE assets ADD COLUMN os TEXT`,
|
`ALTER TABLE assets ADD COLUMN os TEXT`,
|
||||||
`ALTER TABLE assets ADD COLUMN ip_address TEXT`,
|
`ALTER TABLE assets ADD COLUMN ip_address TEXT`,
|
||||||
@@ -552,6 +553,23 @@ async function initializeDatabase() {
|
|||||||
}
|
}
|
||||||
console.log('✅ Database migrations completed');
|
console.log('✅ Database migrations completed');
|
||||||
|
|
||||||
|
// Special migration: bestehende Klartext-PINs in fido_keys nachverschlüsseln (DSGVO Art. 32)
|
||||||
|
try {
|
||||||
|
const { encrypt } = require('../utils/crypto');
|
||||||
|
const plainPinRows = db.prepare(
|
||||||
|
`SELECT id, pin FROM fido_keys WHERE pin IS NOT NULL AND pin != '' AND instr(pin, ':') = 0`
|
||||||
|
).all();
|
||||||
|
if (plainPinRows.length > 0) {
|
||||||
|
const updatePin = db.prepare('UPDATE fido_keys SET pin = ? WHERE id = ?');
|
||||||
|
for (const row of plainPinRows) {
|
||||||
|
updatePin.run(encrypt(row.pin), row.id);
|
||||||
|
}
|
||||||
|
console.log(`🔐 ${plainPinRows.length} Klartext-PIN(s) in fido_keys nachverschlüsselt`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('⚠️ PIN-Verschlüsselungs-Migration fehlgeschlagen:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Special migration: rebuild network_devices to add SNMP + AP support
|
// Special migration: rebuild network_devices to add SNMP + AP support
|
||||||
try {
|
try {
|
||||||
const ndDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='network_devices'").get();
|
const ndDef = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='network_devices'").get();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const JWT_CONFIG = require('../config/jwt');
|
const JWT_CONFIG = require('../config/jwt');
|
||||||
|
|
||||||
|
// Endpunkte, die trotz erzwungenem Passwortwechsel erreichbar bleiben müssen
|
||||||
|
const PASSWORD_CHANGE_EXEMPT_PATHS = ['/api/auth/me', '/api/auth/change-password'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to verify JWT token and attach user to request
|
* Middleware to verify JWT token and attach user to request
|
||||||
*/
|
*/
|
||||||
@@ -18,6 +21,19 @@ function authenticateToken(req, res, next) {
|
|||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_CONFIG.secret);
|
const decoded = jwt.verify(token, JWT_CONFIG.secret);
|
||||||
req.user = decoded; // { id, username, email, role, roleId }
|
req.user = decoded; // { id, username, email, role, roleId }
|
||||||
|
|
||||||
|
if (!PASSWORD_CHANGE_EXEMPT_PATHS.includes(req.originalUrl.split('?')[0])) {
|
||||||
|
const User = require('../models/User');
|
||||||
|
const dbUser = User.getById(decoded.id);
|
||||||
|
if (dbUser?.must_change_password) {
|
||||||
|
return res.status(403).json({
|
||||||
|
status: 'error',
|
||||||
|
code: 'PASSWORD_CHANGE_REQUIRED',
|
||||||
|
message: 'Passwortänderung erforderlich, bevor weitere Aktionen möglich sind'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'TokenExpiredError') {
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ class AuditLog {
|
|||||||
return stmt.all(limit);
|
return stmt.all(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht Audit-Log-Einträge älter als retentionDays (DSGVO Art. 5 Abs. 1 lit. e - Speicherbegrenzung)
|
||||||
|
*/
|
||||||
|
static cleanupOld(retentionDays = 180) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const stmt = db.prepare(`DELETE FROM audit_log WHERE created_at < datetime('now', '-' || ? || ' days')`);
|
||||||
|
const result = stmt.run(retentionDays);
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to log user actions
|
* Helper method to log user actions
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
const { getDatabase } = require('../config/database');
|
const { getDatabase } = require('../config/database');
|
||||||
|
const { encrypt, decrypt } = require('../utils/crypto');
|
||||||
|
|
||||||
|
function withDecryptedPin(row) {
|
||||||
|
if (!row) return row;
|
||||||
|
return { ...row, pin: decrypt(row.pin) };
|
||||||
|
}
|
||||||
|
|
||||||
class FidoKey {
|
class FidoKey {
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +25,7 @@ class FidoKey {
|
|||||||
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
|
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
|
||||||
ORDER BY fk.created_at DESC
|
ORDER BY fk.created_at DESC
|
||||||
`);
|
`);
|
||||||
return stmt.all();
|
return stmt.all().map(withDecryptedPin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,7 +46,7 @@ class FidoKey {
|
|||||||
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
|
LEFT JOIN users uu ON fk.updated_by_user_id = uu.id
|
||||||
WHERE fk.id = ?
|
WHERE fk.id = ?
|
||||||
`);
|
`);
|
||||||
return stmt.get(id);
|
return withDecryptedPin(stmt.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +64,7 @@ class FidoKey {
|
|||||||
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
|
LEFT JOIN users cu ON fk.created_by_user_id = cu.id
|
||||||
WHERE fk.serial_number = ?
|
WHERE fk.serial_number = ?
|
||||||
`);
|
`);
|
||||||
return stmt.get(serialNumber);
|
return withDecryptedPin(stmt.get(serialNumber));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +83,7 @@ class FidoKey {
|
|||||||
WHERE fk.status = ?
|
WHERE fk.status = ?
|
||||||
ORDER BY fk.created_at DESC
|
ORDER BY fk.created_at DESC
|
||||||
`);
|
`);
|
||||||
return stmt.all(status);
|
return stmt.all(status).map(withDecryptedPin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,7 +100,7 @@ class FidoKey {
|
|||||||
WHERE fk.assigned_to_user_id = ?
|
WHERE fk.assigned_to_user_id = ?
|
||||||
ORDER BY fk.created_at DESC
|
ORDER BY fk.created_at DESC
|
||||||
`);
|
`);
|
||||||
return stmt.all(userId);
|
return stmt.all(userId).map(withDecryptedPin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,9 +114,10 @@ class FidoKey {
|
|||||||
serial_number,
|
serial_number,
|
||||||
status,
|
status,
|
||||||
description,
|
description,
|
||||||
|
pin,
|
||||||
assigned_to_user_id,
|
assigned_to_user_id,
|
||||||
created_by_user_id
|
created_by_user_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const result = stmt.run(
|
const result = stmt.run(
|
||||||
@@ -118,6 +125,7 @@ class FidoKey {
|
|||||||
keyData.serial_number,
|
keyData.serial_number,
|
||||||
keyData.status,
|
keyData.status,
|
||||||
keyData.description || null,
|
keyData.description || null,
|
||||||
|
encrypt(keyData.pin) || null,
|
||||||
keyData.assigned_to_user_id || null,
|
keyData.assigned_to_user_id || null,
|
||||||
keyData.created_by_user_id
|
keyData.created_by_user_id
|
||||||
);
|
);
|
||||||
@@ -150,6 +158,10 @@ class FidoKey {
|
|||||||
fields.push('description = ?');
|
fields.push('description = ?');
|
||||||
values.push(keyData.description);
|
values.push(keyData.description);
|
||||||
}
|
}
|
||||||
|
if (keyData.pin !== undefined) {
|
||||||
|
fields.push('pin = ?');
|
||||||
|
values.push(encrypt(keyData.pin));
|
||||||
|
}
|
||||||
if (keyData.assigned_to_user_id !== undefined) {
|
if (keyData.assigned_to_user_id !== undefined) {
|
||||||
fields.push('assigned_to_user_id = ?');
|
fields.push('assigned_to_user_id = ?');
|
||||||
values.push(keyData.assigned_to_user_id);
|
values.push(keyData.assigned_to_user_id);
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ class OffboardingProtocol {
|
|||||||
e.first_name as employee_first_name,
|
e.first_name as employee_first_name,
|
||||||
e.last_name as employee_last_name,
|
e.last_name as employee_last_name,
|
||||||
e.email as employee_email,
|
e.email as employee_email,
|
||||||
|
e.department as employee_department,
|
||||||
r.name as employee_role_name,
|
r.name as employee_role_name,
|
||||||
cu.username as created_by_username,
|
cu.username as created_by_username,
|
||||||
uu.username as updated_by_username
|
uu.username as updated_by_username,
|
||||||
|
(SELECT d.id FROM onboarding_departments d
|
||||||
|
WHERE LOWER(d.name) LIKE LOWER('%' || COALESCE(e.department,'') || '%')
|
||||||
|
AND COALESCE(e.department,'') != ''
|
||||||
|
ORDER BY d.id LIMIT 1) as employee_department_id
|
||||||
FROM offboarding_protocols op
|
FROM offboarding_protocols op
|
||||||
INNER JOIN users e ON op.employee_user_id = e.id
|
INNER JOIN users e ON op.employee_user_id = e.id
|
||||||
INNER JOIN roles r ON e.role_id = r.id
|
INNER JOIN roles r ON e.role_id = r.id
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
|
const { setAuthCookie } = require('../utils/authCookie');
|
||||||
|
|
||||||
const TENANT_ID = () => process.env.AZURE_TENANT_ID;
|
const TENANT_ID = () => process.env.AZURE_TENANT_ID;
|
||||||
const CLIENT_ID = () => process.env.AZURE_CLIENT_ID;
|
const CLIENT_ID = () => process.env.AZURE_CLIENT_ID;
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getStats } = require('../controllers/tv.controller');
|
const { getStats, getCVEs } = require('../controllers/tv.controller');
|
||||||
|
|
||||||
router.get('/stats', getStats);
|
// Kein normaler Login (TV-Display im Büro) — aber ein Shared-Key statt komplett offen ins Netz.
|
||||||
|
function requireTvKey(req, res, next) {
|
||||||
|
if (!process.env.TV_DASHBOARD_KEY || req.query.key !== process.env.TV_DASHBOARD_KEY) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/stats', requireTvKey, getStats);
|
||||||
|
router.get('/cves', requireTvKey, getCVEs);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ const authLimiter = rateLimit({
|
|||||||
legacyHeaders: false
|
legacyHeaders: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Striktes Limit nur für /login — verhindert Brute-Force auf Passwörter
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 8,
|
||||||
|
message: { status: 'error', message: 'Zu viele Login-Versuche, bitte später erneut versuchen' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: true
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ROUTES
|
// ROUTES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -127,6 +137,7 @@ app.get('/api/health/history', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
|
app.use('/api/auth/login', loginLimiter);
|
||||||
app.use('/api/auth', authLimiter, authRoutes);
|
app.use('/api/auth', authLimiter, authRoutes);
|
||||||
app.use('/api/users', userRoutes);
|
app.use('/api/users', userRoutes);
|
||||||
app.use('/api/fido-keys', fidoKeyRoutes);
|
app.use('/api/fido-keys', fidoKeyRoutes);
|
||||||
@@ -411,6 +422,12 @@ async function startServer() {
|
|||||||
NetworkDevice.cleanupOldChecks();
|
NetworkDevice.cleanupOldChecks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DSGVO-Speicherbegrenzung: alte patch_commands (inkl. Shell-Output) + audit_log (täglich 03:30)
|
||||||
|
cron.schedule('30 3 * * *', () => {
|
||||||
|
const { runRetentionCleanup } = require('./services/dataRetention.service');
|
||||||
|
runRetentionCleanup();
|
||||||
|
});
|
||||||
|
|
||||||
// Proxmox Monitoring (alle 5 Minuten)
|
// Proxmox Monitoring (alle 5 Minuten)
|
||||||
if (process.env.PROXMOX_HOST && process.env.PROXMOX_TOKEN) {
|
if (process.env.PROXMOX_HOST && process.env.PROXMOX_TOKEN) {
|
||||||
const { pollProxmox } = require('./services/proxmoxService');
|
const { pollProxmox } = require('./services/proxmoxService');
|
||||||
|
|||||||
26
backend/src/services/dataRetention.service.js
Normal file
26
backend/src/services/dataRetention.service.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const AuditLog = require('../models/AuditLog');
|
||||||
|
|
||||||
|
// DSGVO Art. 5 Abs. 1 lit. e (Speicherbegrenzung) — Daten nur so lange aufbewahren wie nötig.
|
||||||
|
const PATCH_COMMANDS_RETENTION_DAYS = parseInt(process.env.PATCH_COMMANDS_RETENTION_DAYS || '90', 10);
|
||||||
|
const AUDIT_LOG_RETENTION_DAYS = parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || '180', 10);
|
||||||
|
|
||||||
|
function cleanupPatchCommands() {
|
||||||
|
const db = getDatabase();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`DELETE FROM patch_commands WHERE created_at < datetime('now', '-' || ? || ' days') AND status IN ('done', 'failed')`
|
||||||
|
);
|
||||||
|
return stmt.run(PATCH_COMMANDS_RETENTION_DAYS).changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runRetentionCleanup() {
|
||||||
|
try {
|
||||||
|
const patchDeleted = cleanupPatchCommands();
|
||||||
|
const auditDeleted = AuditLog.cleanupOld(AUDIT_LOG_RETENTION_DAYS);
|
||||||
|
console.log(`[DataRetention] Bereinigt: ${patchDeleted} patch_commands (>${PATCH_COMMANDS_RETENTION_DAYS}d), ${auditDeleted} audit_log Einträge (>${AUDIT_LOG_RETENTION_DAYS}d)`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DataRetention] Fehler:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runRetentionCleanup, cleanupPatchCommands };
|
||||||
@@ -431,9 +431,9 @@ async function sendTicketCreatedConfirmation(ticket) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
${infoCard([
|
${infoCard([
|
||||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||||
['Betreff', `<strong style="color:#111827;">${ticket.title}</strong>`],
|
['Betreff', `<strong style="color:#111827;">${escHtml(ticket.title)}</strong>`],
|
||||||
['Kategorie', `<span style="color:#374151;">${ticket.category}</span>`],
|
['Kategorie', `<span style="color:#374151;">${escHtml(ticket.category)}</span>`],
|
||||||
['Priorität', priorityBadge(ticket.priority)],
|
['Priorität', priorityBadge(ticket.priority)],
|
||||||
['Status', statusBadge(ticket.status)],
|
['Status', statusBadge(ticket.status)],
|
||||||
])}
|
])}
|
||||||
@@ -494,11 +494,11 @@ async function sendTicketAssignedNotification(ticket, assignedUser) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
${infoCard([
|
${infoCard([
|
||||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||||
['Betreff', `<strong style="color:#111827;">${ticket.title}</strong>`],
|
['Betreff', `<strong style="color:#111827;">${escHtml(ticket.title)}</strong>`],
|
||||||
['Kategorie', `<span style="color:#374151;">${ticket.category}</span>`],
|
['Kategorie', `<span style="color:#374151;">${escHtml(ticket.category)}</span>`],
|
||||||
['Priorität', priorityBadge(ticket.priority)],
|
['Priorität', priorityBadge(ticket.priority)],
|
||||||
['Von', `<span style="color:#374151;">${ticket.requester_name || 'Unbekannt'}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}</span>`],
|
['Von', `<span style="color:#374151;">${escHtml(ticket.requester_name || 'Unbekannt')}${ticket.requester_email ? ` <${escHtml(ticket.requester_email)}>` : ''}</span>`],
|
||||||
])}`;
|
])}`;
|
||||||
|
|
||||||
await sendMail(
|
await sendMail(
|
||||||
@@ -554,10 +554,10 @@ async function sendCommentNotification(ticket, comment) {
|
|||||||
<table cellpadding="0" cellspacing="0" border="0">
|
<table cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td bgcolor="#0d9488" style="background-color:#0d9488;border-radius:6px;width:28px;height:28px;text-align:center;vertical-align:middle;padding:0 8px;">
|
<td bgcolor="#0d9488" style="background-color:#0d9488;border-radius:6px;width:28px;height:28px;text-align:center;vertical-align:middle;padding:0 8px;">
|
||||||
<span style="font-size:13px;font-weight:800;color:#ffffff;font-family:Inter,Helvetica,Arial,sans-serif;line-height:28px;">${authorName.charAt(0).toUpperCase()}</span>
|
<span style="font-size:13px;font-weight:800;color:#ffffff;font-family:Inter,Helvetica,Arial,sans-serif;line-height:28px;">${escHtml(authorName.charAt(0).toUpperCase())}</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding-left:10px;vertical-align:middle;">
|
<td style="padding-left:10px;vertical-align:middle;">
|
||||||
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${authorName}</span>
|
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${escHtml(authorName)}</span>
|
||||||
<span style="font-size:11px;color:#9ca3af;font-family:Inter,Helvetica,Arial,sans-serif;padding-left:6px;">· IT Support</span>
|
<span style="font-size:11px;color:#9ca3af;font-family:Inter,Helvetica,Arial,sans-serif;padding-left:6px;">· IT Support</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -567,14 +567,14 @@ async function sendCommentNotification(ticket, comment) {
|
|||||||
<!-- Message body -->
|
<!-- Message body -->
|
||||||
<tr>
|
<tr>
|
||||||
<td bgcolor="#ffffff" style="background-color:#ffffff;padding:16px;font-size:14px;color:#1f2937;line-height:1.75;white-space:pre-line;font-family:Inter,Helvetica,Arial,sans-serif;">
|
<td bgcolor="#ffffff" style="background-color:#ffffff;padding:16px;font-size:14px;color:#1f2937;line-height:1.75;white-space:pre-line;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||||
${comment.comment.replace(/</g, '<').replace(/>/g, '>')}
|
${escHtml(comment.comment)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
${infoCard([
|
${infoCard([
|
||||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||||
['Betreff', `<span style="color:#374151;">${ticket.title}</span>`],
|
['Betreff', `<span style="color:#374151;">${escHtml(ticket.title)}</span>`],
|
||||||
['Status', statusBadge(ticket.status)],
|
['Status', statusBadge(ticket.status)],
|
||||||
])}`;
|
])}`;
|
||||||
|
|
||||||
@@ -611,19 +611,19 @@ async function sendStaffCommentNotification(ticket, comment) {
|
|||||||
const content = `
|
const content = `
|
||||||
<h2 style="margin:0 0 8px;font-size:21px;font-weight:800;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">Neue Antwort im Ticket</h2>
|
<h2 style="margin:0 0 8px;font-size:21px;font-weight:800;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">Neue Antwort im Ticket</h2>
|
||||||
<p style="margin:0 0 20px;font-size:14px;color:#6b7280;line-height:1.7;font-family:Inter,Helvetica,Arial,sans-serif;">
|
<p style="margin:0 0 20px;font-size:14px;color:#6b7280;line-height:1.7;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||||
<strong>${requesterName}</strong> hat auf Ticket <strong style="color:#0d9488;">${ticket.ticket_number}</strong> geantwortet.
|
<strong>${escHtml(requesterName)}</strong> hat auf Ticket <strong style="color:#0d9488;">${escHtml(ticket.ticket_number)}</strong> geantwortet.
|
||||||
</p>
|
</p>
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;margin-bottom:22px;">
|
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;margin-bottom:22px;">
|
||||||
<tr><td bgcolor="#f8fafc" style="background-color:#f8fafc;padding:10px 16px;border-bottom:1px solid #e5e7eb;">
|
<tr><td bgcolor="#f8fafc" style="background-color:#f8fafc;padding:10px 16px;border-bottom:1px solid #e5e7eb;">
|
||||||
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${requesterName}</span>
|
<span style="font-size:13px;font-weight:700;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">${escHtml(requesterName)}</span>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
<tr><td bgcolor="#ffffff" style="background-color:#ffffff;padding:16px;font-size:14px;color:#1f2937;line-height:1.75;white-space:pre-line;font-family:Inter,Helvetica,Arial,sans-serif;">
|
<tr><td bgcolor="#ffffff" style="background-color:#ffffff;padding:16px;font-size:14px;color:#1f2937;line-height:1.75;white-space:pre-line;font-family:Inter,Helvetica,Arial,sans-serif;">
|
||||||
${comment.comment.replace(/</g, '<').replace(/>/g, '>')}
|
${escHtml(comment.comment)}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
${infoCard([
|
${infoCard([
|
||||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||||
['Betreff', `<span style="color:#374151;">${ticket.title}</span>`],
|
['Betreff', `<span style="color:#374151;">${escHtml(ticket.title)}</span>`],
|
||||||
['Status', statusBadge(ticket.status)],
|
['Status', statusBadge(ticket.status)],
|
||||||
])}`;
|
])}`;
|
||||||
|
|
||||||
@@ -660,10 +660,10 @@ async function sendStaffTicketCreatedNotification(ticket) {
|
|||||||
const content = `
|
const content = `
|
||||||
<h2 style="margin:0 0 8px;font-size:21px;font-weight:800;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">Neues Ticket eingegangen</h2>
|
<h2 style="margin:0 0 8px;font-size:21px;font-weight:800;color:#111827;font-family:Inter,Helvetica,Arial,sans-serif;">Neues Ticket eingegangen</h2>
|
||||||
${infoCard([
|
${infoCard([
|
||||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||||
['Betreff', `<strong style="color:#111827;">${ticket.title}</strong>`],
|
['Betreff', `<strong style="color:#111827;">${escHtml(ticket.title)}</strong>`],
|
||||||
['Von', `<span style="color:#374151;">${ticket.requester_name || ''}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}</span>`],
|
['Von', `<span style="color:#374151;">${escHtml(ticket.requester_name || '')}${ticket.requester_email ? ` <${escHtml(ticket.requester_email)}>` : ''}</span>`],
|
||||||
['Kategorie', `<span style="color:#374151;">${ticket.category || '—'}</span>`],
|
['Kategorie', `<span style="color:#374151;">${escHtml(ticket.category || '—')}</span>`],
|
||||||
['Priorität', priorityBadge(ticket.priority)],
|
['Priorität', priorityBadge(ticket.priority)],
|
||||||
])}`;
|
])}`;
|
||||||
|
|
||||||
@@ -735,8 +735,8 @@ async function sendStatusChangeNotification(ticket, oldStatus, newStatus) {
|
|||||||
${statusChangeVisual}
|
${statusChangeVisual}
|
||||||
|
|
||||||
${infoCard([
|
${infoCard([
|
||||||
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${ticket.ticket_number}</strong>`],
|
['Ticket-Nr.', `<strong style="font-family:'Courier New',Courier,monospace;color:#0d9488;font-size:13px;">${escHtml(ticket.ticket_number)}</strong>`],
|
||||||
['Betreff', `<span style="color:#374151;">${ticket.title}</span>`],
|
['Betreff', `<span style="color:#374151;">${escHtml(ticket.title)}</span>`],
|
||||||
['Priorität', priorityBadge(ticket.priority)],
|
['Priorität', priorityBadge(ticket.priority)],
|
||||||
])}
|
])}
|
||||||
|
|
||||||
@@ -1032,10 +1032,10 @@ async function sendEscalationEmail(ticket) {
|
|||||||
${introHtml}
|
${introHtml}
|
||||||
</p>
|
</p>
|
||||||
<table width="100%" cellpadding="12" style="background:#fff8f8;border:1px solid #fca5a5;border-radius:8px;margin:0 0 16px;">
|
<table width="100%" cellpadding="12" style="background:#fff8f8;border:1px solid #fca5a5;border-radius:8px;margin:0 0 16px;">
|
||||||
<tr><td><strong>Ticket:</strong> ${ticket.ticket_number}</td></tr>
|
<tr><td><strong>Ticket:</strong> ${escHtml(ticket.ticket_number)}</td></tr>
|
||||||
<tr><td><strong>Titel:</strong> ${ticket.title}</td></tr>
|
<tr><td><strong>Titel:</strong> ${escHtml(ticket.title)}</td></tr>
|
||||||
<tr><td><strong>Priorität:</strong> ${priorityBadge(ticket.priority)}</td></tr>
|
<tr><td><strong>Priorität:</strong> ${priorityBadge(ticket.priority)}</td></tr>
|
||||||
<tr><td><strong>Ersteller:</strong> ${ticket.requester_name || ticket.requester_email || 'Unbekannt'}</td></tr>
|
<tr><td><strong>Ersteller:</strong> ${escHtml(ticket.requester_name || ticket.requester_email || 'Unbekannt')}</td></tr>
|
||||||
<tr><td><strong>Erstellt:</strong> ${new Date(ticket.created_at + 'Z').toLocaleString('de-DE')}</td></tr>
|
<tr><td><strong>Erstellt:</strong> ${new Date(ticket.created_at + 'Z').toLocaleString('de-DE')}</td></tr>
|
||||||
</table>`;
|
</table>`;
|
||||||
|
|
||||||
|
|||||||
20
backend/src/utils/authCookie.js
Normal file
20
backend/src/utils/authCookie.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const COOKIE_NAME = 'token';
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
// httpOnly-Cookie statt Token in JS-lesbarem localStorage — verhindert dass ein XSS-Treffer
|
||||||
|
// das Session-Token einfach per document.cookie/localStorage ausliest.
|
||||||
|
function setAuthCookie(res, token, maxAgeMs = 8 * 60 * 60 * 1000) {
|
||||||
|
res.cookie(COOKIE_NAME, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isProd,
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: maxAgeMs,
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuthCookie(res) {
|
||||||
|
res.clearCookie(COOKIE_NAME, { httpOnly: true, secure: isProd, sameSite: 'lax', path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setAuthCookie, clearAuthCookie, COOKIE_NAME };
|
||||||
34
backend/src/utils/crypto.js
Normal file
34
backend/src/utils/crypto.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
|
||||||
|
function getKey() {
|
||||||
|
const secret = process.env.ENCRYPTION_KEY || process.env.JWT_SECRET || 'itnexus-fallback-key';
|
||||||
|
return crypto.createHash('sha256').update(secret).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encrypt(plainText) {
|
||||||
|
if (plainText === null || plainText === undefined || plainText === '') return null;
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(String(plainText), 'utf8'), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(cipherText) {
|
||||||
|
if (!cipherText) return null;
|
||||||
|
const parts = cipherText.split(':');
|
||||||
|
if (parts.length !== 3) return cipherText; // unverschlüsselter Altbestand
|
||||||
|
try {
|
||||||
|
const [ivB64, authTagB64, dataB64] = parts;
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), Buffer.from(ivB64, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
|
||||||
|
const decrypted = Buffer.concat([decipher.update(Buffer.from(dataB64, 'base64')), decipher.final()]);
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { encrypt, decrypt };
|
||||||
47
backend/src/utils/ssrfGuard.js
Normal file
47
backend/src/utils/ssrfGuard.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const dns = require('dns').promises;
|
||||||
|
|
||||||
|
function isPrivateIp(ip) {
|
||||||
|
if (ip.includes(':')) {
|
||||||
|
// IPv6: loopback, link-local, unique-local
|
||||||
|
return ip === '::1' || /^fe80:/i.test(ip) || /^fc[0-9a-f]{2}:/i.test(ip) || /^fd[0-9a-f]{2}:/i.test(ip);
|
||||||
|
}
|
||||||
|
const parts = ip.split('.').map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some(p => Number.isNaN(p))) return true; // unparsable → sicherheitshalber blocken
|
||||||
|
const [a, b] = parts;
|
||||||
|
if (a === 127) return true; // Loopback
|
||||||
|
if (a === 10) return true; // Private
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // Private
|
||||||
|
if (a === 192 && b === 168) return true; // Private
|
||||||
|
if (a === 169 && b === 254) return true; // Link-local
|
||||||
|
if (a === 0) return true; // "this network"
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wirft, falls die URL auf interne/private Adressen oder Loopback zeigt — verhindert SSRF
|
||||||
|
// über den Knowledge-Base-URL-Import (Server würde sonst beliebige interne Endpunkte abrufen).
|
||||||
|
async function assertPublicUrl(urlString) {
|
||||||
|
const parsed = new URL(urlString);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('Nur HTTP/HTTPS URLs erlaubt');
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname;
|
||||||
|
if (hostname === 'localhost' || hostname.endsWith('.local')) {
|
||||||
|
throw new Error('Interne/lokale Adressen sind nicht erlaubt');
|
||||||
|
}
|
||||||
|
|
||||||
|
let addresses;
|
||||||
|
try {
|
||||||
|
addresses = await dns.lookup(hostname, { all: true });
|
||||||
|
} catch {
|
||||||
|
throw new Error('Hostname konnte nicht aufgelöst werden');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { address } of addresses) {
|
||||||
|
if (isPrivateIp(address)) {
|
||||||
|
throw new Error('Interne/private Adressen sind nicht erlaubt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { assertPublicUrl, isPrivateIp };
|
||||||
@@ -90,29 +90,38 @@ function handleAgent(ws, url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token kommt NICHT mehr als URL-Query-Param (landet sonst im Klartext in nginx-Access-Logs),
|
||||||
|
// sondern als erste WS-Message ({type:'auth',token}) — erst danach wird die Verbindung freigeschaltet.
|
||||||
function handleBrowser(ws, url) {
|
function handleBrowser(ws, url) {
|
||||||
const token = url.searchParams.get('token');
|
|
||||||
try {
|
|
||||||
jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
} catch {
|
|
||||||
ws.close(1008, 'unauthorized');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const agentId = parseInt(url.searchParams.get('agentId'));
|
const agentId = parseInt(url.searchParams.get('agentId'));
|
||||||
if (!agentId) { ws.close(1008, 'agentId required'); return; }
|
if (!agentId) { ws.close(1008, 'agentId required'); return; }
|
||||||
|
|
||||||
browserSockets.set(agentId, ws);
|
let authenticated = false;
|
||||||
|
const authTimer = setTimeout(() => { if (!authenticated) ws.close(1008, 'auth timeout'); }, 5000);
|
||||||
const aws = agentSockets.get(agentId);
|
|
||||||
if (aws?.readyState === WebSocket.OPEN) {
|
|
||||||
aws.send(JSON.stringify({ type: 'start_shell' }));
|
|
||||||
ws.send('\x1b[32m[Verbunden]\x1b[0m\r\n');
|
|
||||||
} else {
|
|
||||||
ws.send('\x1b[33m[Warte auf Agent-Verbindung...]\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
|
if (!authenticated) {
|
||||||
|
clearTimeout(authTimer);
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type !== 'auth') throw new Error();
|
||||||
|
decoded = jwt.verify(msg.token, process.env.JWT_SECRET);
|
||||||
|
} catch { ws.close(1008, 'unauthorized'); return; }
|
||||||
|
if (!['admin', 'super_admin'].includes(decoded.role)) { ws.close(1008, 'forbidden'); return; }
|
||||||
|
authenticated = true;
|
||||||
|
|
||||||
|
browserSockets.set(agentId, ws);
|
||||||
|
const aws = agentSockets.get(agentId);
|
||||||
|
if (aws?.readyState === WebSocket.OPEN) {
|
||||||
|
aws.send(JSON.stringify({ type: 'start_shell' }));
|
||||||
|
ws.send('\x1b[32m[Verbunden]\x1b[0m\r\n');
|
||||||
|
} else {
|
||||||
|
ws.send('\x1b[33m[Warte auf Agent-Verbindung...]\x1b[0m\r\n');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const aws = agentSockets.get(agentId);
|
const aws = agentSockets.get(agentId);
|
||||||
if (!aws || aws.readyState !== WebSocket.OPEN) return;
|
if (!aws || aws.readyState !== WebSocket.OPEN) return;
|
||||||
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
|
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
|
||||||
@@ -164,18 +173,30 @@ function handleRdpAgent(ws, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRdpBrowser(ws, url) {
|
function handleRdpBrowser(ws, url) {
|
||||||
const token = url.searchParams.get('token');
|
|
||||||
try { jwt.verify(token, process.env.JWT_SECRET); }
|
|
||||||
catch { ws.close(1008, 'unauthorized'); return; }
|
|
||||||
|
|
||||||
const agentId = parseInt(url.searchParams.get('agentId'));
|
const agentId = parseInt(url.searchParams.get('agentId'));
|
||||||
if (!agentId) { ws.close(1008, 'agentId required'); return; }
|
if (!agentId) { ws.close(1008, 'agentId required'); return; }
|
||||||
|
|
||||||
const oldBws = rdpBrowserSockets.get(agentId);
|
let authenticated = false;
|
||||||
if (oldBws?.readyState === WebSocket.OPEN) oldBws.close();
|
const authTimer = setTimeout(() => { if (!authenticated) ws.close(1008, 'auth timeout'); }, 5000);
|
||||||
rdpBrowserSockets.set(agentId, ws);
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
|
if (!authenticated) {
|
||||||
|
clearTimeout(authTimer);
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data.toString());
|
||||||
|
if (msg.type !== 'auth') throw new Error();
|
||||||
|
decoded = jwt.verify(msg.token, process.env.JWT_SECRET);
|
||||||
|
} catch { ws.close(1008, 'unauthorized'); return; }
|
||||||
|
if (!['admin', 'super_admin'].includes(decoded.role)) { ws.close(1008, 'forbidden'); return; }
|
||||||
|
authenticated = true;
|
||||||
|
|
||||||
|
const oldBws = rdpBrowserSockets.get(agentId);
|
||||||
|
if (oldBws?.readyState === WebSocket.OPEN) oldBws.close();
|
||||||
|
rdpBrowserSockets.set(agentId, ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const aws = rdpAgentSockets.get(agentId);
|
const aws = rdpAgentSockets.get(agentId);
|
||||||
if (aws?.readyState === WebSocket.OPEN) aws.send(data.toString());
|
if (aws?.readyState === WebSocket.OPEN) aws.send(data.toString());
|
||||||
});
|
});
|
||||||
|
|||||||
12
frontend/package-lock.json
generated
12
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -3958,7 +3959,7 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
@@ -7074,6 +7075,15 @@
|
|||||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||||
|
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/domutils": {
|
"node_modules/domutils": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -1234,21 +1234,16 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
let shares = [];
|
let shares = [];
|
||||||
let demoMode = false;
|
let demoMode = false;
|
||||||
|
|
||||||
/* ---------- Auth token ---------- */
|
/* ---------- Auth (httpOnly-Cookie, kein Token in localStorage/URL) ---------- */
|
||||||
const TOKEN_KEY = "token";
|
let demoModeFlag = false;
|
||||||
const getToken = () => localStorage.getItem(TOKEN_KEY);
|
|
||||||
const setToken = (t) => localStorage.setItem(TOKEN_KEY, t);
|
|
||||||
const clearToken = () => localStorage.removeItem(TOKEN_KEY);
|
|
||||||
|
|
||||||
/* ---------- Fetch helper ---------- */
|
/* ---------- Fetch helper ---------- */
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const headers = new Headers(opts.headers || {});
|
const headers = new Headers(opts.headers || {});
|
||||||
const tk = getToken();
|
|
||||||
if (tk) headers.set("Authorization", "Bearer " + tk);
|
|
||||||
if (opts.body && !(opts.body instanceof FormData) && !headers.has("Content-Type")) {
|
if (opts.body && !(opts.body instanceof FormData) && !headers.has("Content-Type")) {
|
||||||
headers.set("Content-Type", "application/json");
|
headers.set("Content-Type", "application/json");
|
||||||
}
|
}
|
||||||
const res = await fetch(path, { ...opts, headers });
|
const res = await fetch(path, { ...opts, headers, credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = new Error("HTTP " + res.status);
|
const err = new Error("HTTP " + res.status);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
@@ -1259,19 +1254,6 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
return ct.includes("application/json") ? res.json() : res.text();
|
return ct.includes("application/json") ? res.json() : res.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Capture ?token= from URL ---------- */
|
|
||||||
function captureUrlToken() {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const t = params.get("token");
|
|
||||||
if (t) {
|
|
||||||
setToken(t);
|
|
||||||
params.delete("token");
|
|
||||||
const qs = params.toString();
|
|
||||||
const newUrl = location.pathname + (qs ? "?" + qs : "") + location.hash;
|
|
||||||
history.replaceState(null, "", newUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- Theme toggle ---------- */
|
/* ---------- Theme toggle ---------- */
|
||||||
(function initTheme() {
|
(function initTheme() {
|
||||||
const KEY = "cereda-theme";
|
const KEY = "cereda-theme";
|
||||||
@@ -1290,17 +1272,12 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
|
|
||||||
/* ---------- Init ---------- */
|
/* ---------- Init ---------- */
|
||||||
async function init() {
|
async function init() {
|
||||||
captureUrlToken();
|
|
||||||
const tk = getToken();
|
|
||||||
if (!tk) return showLogin();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const me = await api("/api/auth/me");
|
const me = await api("/api/auth/me");
|
||||||
showApp(me);
|
showApp(me);
|
||||||
await loadShares();
|
await loadShares();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401 || e.status === 403) {
|
if (e.status === 401 || e.status === 403) {
|
||||||
clearToken();
|
|
||||||
showLogin();
|
showLogin();
|
||||||
} else {
|
} else {
|
||||||
enterDemoMode();
|
enterDemoMode();
|
||||||
@@ -1311,7 +1288,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
/* ---------- Demo fallback ---------- */
|
/* ---------- Demo fallback ---------- */
|
||||||
function enterDemoMode() {
|
function enterDemoMode() {
|
||||||
demoMode = true;
|
demoMode = true;
|
||||||
setToken("demo-token");
|
demoModeFlag = true;
|
||||||
showApp({ username: "m.schmidt", display_name: "Marco Schmidt" });
|
showApp({ username: "m.schmidt", display_name: "Marco Schmidt" });
|
||||||
demoBadge.classList.add("on");
|
demoBadge.classList.add("on");
|
||||||
shares = seedShares();
|
shares = seedShares();
|
||||||
@@ -1391,12 +1368,11 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
});
|
});
|
||||||
if (res && res.token) {
|
if (res && res.status === "success") {
|
||||||
setToken(res.token);
|
|
||||||
const me = await api("/api/auth/me").catch(() => null);
|
const me = await api("/api/auth/me").catch(() => null);
|
||||||
showApp(me || { username });
|
showApp(me || { username });
|
||||||
await loadShares();
|
await loadShares();
|
||||||
} else throw new Error("No token in response.");
|
} else throw new Error("Login fehlgeschlagen.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401 || err.status === 403) {
|
if (err.status === 401 || err.status === 403) {
|
||||||
// Real auth rejection — show the error
|
// Real auth rejection — show the error
|
||||||
@@ -1413,7 +1389,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
|
|
||||||
/* ---------- Logout ---------- */
|
/* ---------- Logout ---------- */
|
||||||
logoutBtn.addEventListener("click", () => {
|
logoutBtn.addEventListener("click", () => {
|
||||||
clearToken();
|
if (!demoModeFlag) { api("/api/auth/logout", { method: "POST" }).catch(() => {}); }
|
||||||
shares = [];
|
shares = [];
|
||||||
pickedFile = null;
|
pickedFile = null;
|
||||||
demoMode = false;
|
demoMode = false;
|
||||||
@@ -1593,7 +1569,7 @@ button, input, textarea, select { font: inherit; color: inherit; }
|
|||||||
const list = await api("/api/shares");
|
const list = await api("/api/shares");
|
||||||
shares = Array.isArray(list) ? list : (list?.shares || []);
|
shares = Array.isArray(list) ? list : (list?.shares || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401) { clearToken(); showLogin(); return; }
|
if (e.status === 401) { showLogin(); return; }
|
||||||
// 403 = not admin, just show empty list — user can still create shares
|
// 403 = not admin, just show empty list — user can still create shares
|
||||||
shares = [];
|
shares = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import aiService from '../../services/aiService';
|
import aiService from '../../services/aiService';
|
||||||
import { marked } from 'marked';
|
import { renderMd } from '../../utils/sanitizeMarkdown';
|
||||||
|
|
||||||
marked.use({ breaks: true, gfm: true });
|
|
||||||
const renderMd = (text) => {
|
|
||||||
try {
|
|
||||||
const html = marked.parse(String(text || ''), { async: false });
|
|
||||||
return { __html: typeof html === 'string' ? html : String(html) };
|
|
||||||
} catch {
|
|
||||||
return { __html: String(text || '').replace(/\n/g, '<br>') };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter'];
|
const STAFF_ROLES = ['super_admin', 'admin', 'support', 'bearbeiter'];
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,8 @@ export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect
|
|||||||
const connectedRef = useRef(false);
|
const connectedRef = useRef(false);
|
||||||
|
|
||||||
const getWsUrl = () => {
|
const getWsUrl = () => {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
return `${proto}://${window.location.host}/ws?type=rdp&agentId=${agentId}&token=${token}`;
|
return `${proto}://${window.location.host}/ws?type=rdp&agentId=${agentId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = (screen = screenIdx) => {
|
const connect = (screen = screenIdx) => {
|
||||||
@@ -33,6 +32,7 @@ export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect
|
|||||||
|
|
||||||
connectedRef.current = false;
|
connectedRef.current = false;
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') }));
|
||||||
ws.send(JSON.stringify({ type: 'rdp_start', screen }));
|
ws.send(JSON.stringify({ type: 'rdp_start', screen }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +61,9 @@ export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect
|
|||||||
img.src = 'data:image/jpeg;base64,' + msg.data;
|
img.src = 'data:image/jpeg;base64,' + msg.data;
|
||||||
} else if (msg.type === 'rdp_denied') {
|
} else if (msg.type === 'rdp_denied') {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setError('Zugriff wurde vom Benutzer abgelehnt');
|
setError(msg.reason === 'consent_spawn_failed'
|
||||||
|
? 'Consent-Dialog konnte nicht geöffnet werden (schtasks-Fehler)'
|
||||||
|
: 'Zugriff wurde vom Benutzer abgelehnt');
|
||||||
ws.close();
|
ws.close();
|
||||||
} else if (msg.type === 'rdp_disconnected') {
|
} else if (msg.type === 'rdp_disconnected') {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
@@ -119,8 +121,14 @@ export default function RemoteDesktopPanel({ agentId, agentHostname, autoConnect
|
|||||||
{/* Screen-Selektor */}
|
{/* Screen-Selektor */}
|
||||||
<select
|
<select
|
||||||
value={screenIdx}
|
value={screenIdx}
|
||||||
onChange={e => setScreenIdx(Number(e.target.value))}
|
onChange={e => {
|
||||||
disabled={status === 'connected' || status === 'connecting' || status === 'waiting'}
|
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 }}
|
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 => (
|
{SCREEN_OPTIONS.map(o => (
|
||||||
|
|||||||
@@ -177,10 +177,9 @@ function RemoteShell({ agentId, agentHostname }) {
|
|||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
const getWsUrl = () => {
|
const getWsUrl = () => {
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const host = window.location.host;
|
const host = window.location.host;
|
||||||
return `${proto}://${host}/ws?type=shell&agentId=${agentId}&token=${token}`;
|
return `${proto}://${host}/ws?type=shell&agentId=${agentId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
@@ -191,7 +190,10 @@ function RemoteShell({ agentId, agentHostname }) {
|
|||||||
const ws = new WebSocket(getWsUrl());
|
const ws = new WebSocket(getWsUrl());
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => setStatus('connected');
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') }));
|
||||||
|
setStatus('connected');
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
setOutput(prev => prev + stripAnsi(e.data));
|
setOutput(prev => prev + stripAnsi(e.data));
|
||||||
@@ -766,7 +768,7 @@ export default function AgentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */}
|
{/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */}
|
||||||
{(isSuperAdmin || isAdmin) && (
|
{(isSuperAdmin() || isAdmin()) && (
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 0, borderBottom: '1px solid var(--border-color)' }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 0, borderBottom: '1px solid var(--border-color)' }}>
|
||||||
{[
|
{[
|
||||||
@@ -790,10 +792,6 @@ export default function AgentDetailPage() {
|
|||||||
{shellTab === 'rdp' && <RemoteDesktop agentId={agent.id} agentHostname={agent.hostname} />}
|
{shellTab === 'rdp' && <RemoteDesktop agentId={agent.id} agentHostname={agent.hostname} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isSuperAdmin && !isAdmin && (
|
|
||||||
<RemoteShell agentId={agent.id} agentHostname={agent.hostname} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
|
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
|
||||||
{showAnnModal && (
|
{showAnnModal && (
|
||||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import aiService from '../services/aiService';
|
import aiService from '../services/aiService';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Marked } from 'marked';
|
import { renderMd } from '../utils/sanitizeMarkdown';
|
||||||
|
|
||||||
const marked = new Marked({ breaks: true, gfm: true });
|
|
||||||
const renderMd = (text) => ({ __html: marked.parse(text) });
|
|
||||||
|
|
||||||
const WELCOME_MSG = {
|
const WELCOME_MSG = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Marked } from 'marked';
|
import { Marked } from 'marked';
|
||||||
|
import { sanitizeHtml } from '../utils/sanitizeMarkdown';
|
||||||
|
|
||||||
const marked = new Marked({ breaks: true, gfm: true });
|
const marked = new Marked({ breaks: true, gfm: true });
|
||||||
|
|
||||||
@@ -42,10 +43,11 @@ export default function DocsPage() {
|
|||||||
}
|
}
|
||||||
// Add IDs to headings for anchor links
|
// Add IDs to headings for anchor links
|
||||||
const html = marked.parse(text);
|
const html = marked.parse(text);
|
||||||
return html.replace(/<h([23])>(.*?)<\/h\1>/g, (_, level, heading) => {
|
const withIds = html.replace(/<h([23])>(.*?)<\/h\1>/g, (_, level, heading) => {
|
||||||
const id = slugify(heading.replace(/<[^>]+>/g, ''));
|
const id = slugify(heading.replace(/<[^>]+>/g, ''));
|
||||||
return `<h${level} id="${id}">${heading}</h${level}>`;
|
return `<h${level} id="${id}">${heading}</h${level}>`;
|
||||||
});
|
});
|
||||||
|
return sanitizeHtml(withIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
|
|||||||
@@ -25,8 +25,17 @@ const FidoKeysPage = () => {
|
|||||||
serial_number: '',
|
serial_number: '',
|
||||||
status: 'aktiv',
|
status: 'aktiv',
|
||||||
description: '',
|
description: '',
|
||||||
|
pin: '',
|
||||||
assigned_to_user_id: '',
|
assigned_to_user_id: '',
|
||||||
});
|
});
|
||||||
|
const [revealedPins, setRevealedPins] = useState({});
|
||||||
|
|
||||||
|
const togglePinReveal = (id) => {
|
||||||
|
setRevealedPins(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
if (!revealedPins[id]) {
|
||||||
|
setTimeout(() => setRevealedPins(prev => ({ ...prev, [id]: false })), 8000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadKeys();
|
loadKeys();
|
||||||
@@ -69,7 +78,7 @@ const FidoKeysPage = () => {
|
|||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingKey(null);
|
setEditingKey(null);
|
||||||
setFormData({ name: '', serial_number: '', status: 'aktiv', description: '', assigned_to_user_id: '' });
|
setFormData({ name: '', serial_number: '', status: 'aktiv', description: '', pin: '', assigned_to_user_id: '' });
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,6 +89,7 @@ const FidoKeysPage = () => {
|
|||||||
serial_number: key.serial_number,
|
serial_number: key.serial_number,
|
||||||
status: key.status,
|
status: key.status,
|
||||||
description: key.description || '',
|
description: key.description || '',
|
||||||
|
pin: key.pin || '',
|
||||||
assigned_to_user_id: key.assigned_to_user_id || '',
|
assigned_to_user_id: key.assigned_to_user_id || '',
|
||||||
});
|
});
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -181,6 +191,7 @@ const FidoKeysPage = () => {
|
|||||||
<th>Seriennummer</th>
|
<th>Seriennummer</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Zugewiesen an</th>
|
<th>Zugewiesen an</th>
|
||||||
|
<th>PIN</th>
|
||||||
<th>Beschreibung</th>
|
<th>Beschreibung</th>
|
||||||
<th>Erstellt von</th>
|
<th>Erstellt von</th>
|
||||||
<th>Aktionen</th>
|
<th>Aktionen</th>
|
||||||
@@ -189,13 +200,20 @@ const FidoKeysPage = () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{filteredKeys.length === 0 ? (
|
{filteredKeys.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan="7" className="text-center">Keine FIDO-Keys gefunden</td>
|
<td colSpan="8" className="text-center">Keine FIDO-Keys gefunden</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredKeys.map((key) => (
|
filteredKeys.map((key) => (
|
||||||
<tr key={key.id}>
|
<tr key={key.id}>
|
||||||
<td>{key.name}</td>
|
<td>
|
||||||
<td><code style={{fontSize:12}}>{key.serial_number}</code></td>
|
<div style={{display:'flex',alignItems:'center',gap:10,fontWeight:600}}>
|
||||||
|
<div style={{width:32,height:32,borderRadius:8,background:'rgba(63,163,163,0.12)',display:'flex',alignItems:'center',justifyContent:'center',flexShrink:0}}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="var(--cereda-primary)" strokeWidth="2" width="16" height="16"><circle cx="8" cy="8" r="5"/><path d="M10.5 12.5 19 21M16 16l2-2M19 19l2-2"/></svg>
|
||||||
|
</div>
|
||||||
|
{key.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><span style={{fontFamily:'Consolas,monospace',background:'var(--bg-tertiary)',border:'1px solid var(--border-color)',borderRadius:6,padding:'3px 8px',fontSize:12,color:'var(--text-muted)'}}>{key.serial_number}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`status-badge status-${key.status}`}>{key.status}</span>
|
<span className={`status-badge status-${key.status}`}>{key.status}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -218,20 +236,50 @@ const FidoKeysPage = () => {
|
|||||||
<span style={{color:'var(--text-muted)',fontSize:12}}>— nicht zugewiesen</span>
|
<span style={{color:'var(--text-muted)',fontSize:12}}>— nicht zugewiesen</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{key.pin ? (
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:8}}>
|
||||||
|
<span style={{fontFamily:'Consolas,monospace',background:'var(--bg-tertiary)',border:'1px solid var(--border-color)',borderRadius:6,padding:'3px 10px',fontSize:13,letterSpacing:'0.15em',minWidth:64,textAlign:'center',display:'inline-block'}}>
|
||||||
|
{revealedPins[key.id] ? key.pin : '••••••'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => togglePinReveal(key.id)}
|
||||||
|
title={revealedPins[key.id] ? 'PIN verbergen' : 'PIN anzeigen'}
|
||||||
|
style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-muted)',padding:2,display:'flex',alignItems:'center'}}
|
||||||
|
>
|
||||||
|
{revealedPins[key.id] ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-7-11-7a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 7 11 7a18.5 18.5 0 0 1-2.16 3.19M14.12 14.12a3 3 0 1 1-4.24-4.24"/><path d="M1 1l22 22"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="16" height="16"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span style={{color:'var(--text-muted)',fontSize:12}}>— keine PIN</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>{key.description || '-'}</td>
|
<td>{key.description || '-'}</td>
|
||||||
<td>{key.created_by_username}</td>
|
<td>{key.created_by_username}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="table-actions">
|
<div className="table-actions">
|
||||||
{canModifyFidoKeys() && (
|
{canModifyFidoKeys() && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => handleEdit(key)} className="btn btn-primary btn-small">Bearbeiten</button>
|
<button onClick={() => handleEdit(key)} title="Bearbeiten" className="btn btn-secondary btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||||
<button onClick={() => handleStatusToggle(key)} className="btn btn-secondary btn-small">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>
|
||||||
{key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'}
|
</button>
|
||||||
|
<button onClick={() => handleStatusToggle(key)} title={key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'} className="btn btn-secondary btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||||
|
{key.status === 'aktiv' ? (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><path d="M5 3l16 9-16 9V3z"/></svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isAdmin() && (
|
{isAdmin() && (
|
||||||
<button onClick={() => handleDelete(key.id)} className="btn btn-danger btn-small">Löschen</button>
|
<button onClick={() => handleDelete(key.id)} title="Löschen" className="btn btn-danger btn-small" style={{width:32,padding:0,display:'flex',alignItems:'center',justifyContent:'center'}}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6"/></svg>
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -261,6 +309,20 @@ const FidoKeysPage = () => {
|
|||||||
<input type="text" className="form-input" value={formData.serial_number} onChange={(e) => setFormData({ ...formData, serial_number: e.target.value })} required />
|
<input type="text" className="form-input" value={formData.serial_number} onChange={(e) => setFormData({ ...formData, serial_number: e.target.value })} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">PIN</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
maxLength={6}
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
placeholder="6-stellige PIN"
|
||||||
|
value={formData.pin}
|
||||||
|
onChange={(e) => setFormData({ ...formData, pin: e.target.value.replace(/\D/g, '').slice(0, 6) })}
|
||||||
|
style={{fontFamily:'Consolas,monospace',letterSpacing:'0.2em'}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label className="form-label">Status*</label>
|
<label className="form-label">Status*</label>
|
||||||
<select className="form-select" value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value })} required>
|
<select className="form-select" value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value })} required>
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import aiService from '../services/aiService';
|
import aiService from '../services/aiService';
|
||||||
import ticketService from '../services/ticketService';
|
import ticketService from '../services/ticketService';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Marked } from 'marked';
|
import { renderMd } from '../utils/sanitizeMarkdown';
|
||||||
const marked = new Marked({ breaks: true, gfm: true });
|
|
||||||
const renderMd = (text) => ({ __html: marked.parse(text || '') });
|
|
||||||
|
|
||||||
const CATEGORIES = ['Allgemein', 'Software', 'Hardware', 'Netzwerk', 'SelectLine', 'Sonstiges'];
|
const CATEGORIES = ['Allgemein', 'Software', 'Hardware', 'Netzwerk', 'SelectLine', 'Sonstiges'];
|
||||||
const CAT_COLOR = { Software: '#6366f1', Hardware: '#f59e0b', SelectLine: '#10b981', Netzwerk: '#3b82f6', Allgemein: '#64748b', Sonstiges: '#8b5cf6' };
|
const CAT_COLOR = { Software: '#6366f1', Hardware: '#f59e0b', SelectLine: '#10b981', Netzwerk: '#3b82f6', Allgemein: '#64748b', Sonstiges: '#8b5cf6' };
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const TEAM_ICONS = { it: '💻', hr: '🧑💼', buchhaltung: '💶' };
|
|||||||
const TEAM_LABELS = { it: 'IT', hr: 'HR / Personal', buchhaltung: 'Buchhaltung' };
|
const TEAM_LABELS = { it: 'IT', hr: 'HR / Personal', buchhaltung: 'Buchhaltung' };
|
||||||
const TAG_COLORS = { critical: '#ef4444', important: '#f59e0b', normal: '#64748b' };
|
const TAG_COLORS = { critical: '#ef4444', important: '#f59e0b', normal: '#64748b' };
|
||||||
|
|
||||||
|
const ROLE_TO_TEAM = { hr_personal: 'hr', buchhaltung: 'buchhaltung', produktion: 'it' };
|
||||||
|
|
||||||
const STATUS_LABEL = { pending: 'Ausstehend', in_progress: 'In Bearbeitung', completed: 'Abgeschlossen' };
|
const STATUS_LABEL = { pending: 'Ausstehend', in_progress: 'In Bearbeitung', completed: 'Abgeschlossen' };
|
||||||
const STATUS_COLOR = { pending: '#f59e0b', in_progress: '#3b82f6', completed: '#34d399' };
|
const STATUS_COLOR = { pending: '#f59e0b', in_progress: '#3b82f6', completed: '#34d399' };
|
||||||
|
|
||||||
@@ -29,18 +31,22 @@ function StatusBadge({ status }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProcessChecklist({ processes, checkedItems, onChange, disabled }) {
|
function ProcessChecklist({ processes, checkedItems, onChange, disabled, userTeam = null }) {
|
||||||
if (!processes?.length) return (
|
if (!processes?.length) return (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Keine Prozesse konfiguriert.</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Keine Prozesse konfiguriert.</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
const teams = ['it', 'hr', 'buchhaltung'];
|
// Wenn userTeam gesetzt: nur dieses Team anzeigen
|
||||||
|
const visibleTeams = userTeam ? [userTeam] : ['it', 'hr', 'buchhaltung'];
|
||||||
|
const visibleProcesses = userTeam ? processes.filter(p => p.responsible_team === userTeam) : processes;
|
||||||
|
|
||||||
|
const teams = visibleTeams;
|
||||||
const teamMap = {};
|
const teamMap = {};
|
||||||
teams.forEach(t => { teamMap[t] = []; });
|
teams.forEach(t => { teamMap[t] = []; });
|
||||||
processes.forEach(p => { if (teamMap[p.responsible_team]) teamMap[p.responsible_team].push(p); });
|
visibleProcesses.forEach(p => { if (teamMap[p.responsible_team]) teamMap[p.responsible_team].push(p); });
|
||||||
|
|
||||||
const total = processes.length;
|
const total = visibleProcesses.length;
|
||||||
const checked = processes.filter(p => !!checkedItems[`proc_${p.id}`]).length;
|
const checked = visibleProcesses.filter(p => !!checkedItems[`proc_${p.id}`]).length;
|
||||||
const pct = total > 0 ? Math.round(checked / total * 100) : 0;
|
const pct = total > 0 ? Math.round(checked / total * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -134,17 +140,17 @@ export default function OffboardingDetailPage() {
|
|||||||
const [assetReturns, setAssetReturns] = useState([]);
|
const [assetReturns, setAssetReturns] = useState([]);
|
||||||
|
|
||||||
const canManage = isAdmin() || isSuperAdmin();
|
const canManage = isAdmin() || isSuperAdmin();
|
||||||
const canAct = canViewLifecycle(); // hr + support + admin dürfen handeln
|
const canAct = canViewLifecycle();
|
||||||
|
const userTeam = (canManage) ? null : (ROLE_TO_TEAM[user?.role_name] || null);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [id]);
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [p, procs] = await Promise.all([
|
const p = await offboardingService.getById(id);
|
||||||
offboardingService.getById(id),
|
const deptId = p.employee_department_id || undefined;
|
||||||
onboardingProcessService.getChecklistProcesses('offboarding').catch(() => []),
|
const procs = await onboardingProcessService.getChecklistProcesses('offboarding', deptId).catch(() => []);
|
||||||
]);
|
|
||||||
setProtocol(p);
|
setProtocol(p);
|
||||||
setChecklist(p.checklist_data ? JSON.parse(p.checklist_data) : {});
|
setChecklist(p.checklist_data ? JSON.parse(p.checklist_data) : {});
|
||||||
setNotes(p.notes || '');
|
setNotes(p.notes || '');
|
||||||
@@ -228,8 +234,9 @@ export default function OffboardingDetailPage() {
|
|||||||
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
|
if (loading) return <div className="main-content"><LoadingSpinner /></div>;
|
||||||
if (!protocol) return null;
|
if (!protocol) return null;
|
||||||
|
|
||||||
const total = processes.length;
|
const visibleProcs = userTeam ? processes.filter(p => p.responsible_team === userTeam) : processes;
|
||||||
const checked = processes.filter(p => !!checklist[`proc_${p.id}`]).length;
|
const total = visibleProcs.length;
|
||||||
|
const checked = visibleProcs.filter(p => !!checklist[`proc_${p.id}`]).length;
|
||||||
const pct = total > 0 ? Math.round(checked / total * 100) : 0;
|
const pct = total > 0 ? Math.round(checked / total * 100) : 0;
|
||||||
const statusColor = STATUS_COLOR[protocol.status] || '#6b7280';
|
const statusColor = STATUS_COLOR[protocol.status] || '#6b7280';
|
||||||
const initials = (protocol.employee_name || protocol.employee_email || '??').slice(0, 2).toUpperCase();
|
const initials = (protocol.employee_name || protocol.employee_email || '??').slice(0, 2).toUpperCase();
|
||||||
@@ -365,6 +372,45 @@ export default function OffboardingDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Team-Fortschritt */}
|
||||||
|
<div style={{ gridColumn: '1 / -1', background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 14 }}>👥 Checklisten-Fortschritt nach Team</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
{['it', 'hr', 'buchhaltung'].map(team => {
|
||||||
|
const teamProcs = processes.filter(p => p.responsible_team === team);
|
||||||
|
if (!teamProcs.length) return null;
|
||||||
|
const done = teamProcs.filter(p => !!checklist[`proc_${p.id}`]).length;
|
||||||
|
const pctT = Math.round(done / teamProcs.length * 100);
|
||||||
|
const tc = TEAM_COLORS[team];
|
||||||
|
const isMyTeam = userTeam === team;
|
||||||
|
return (
|
||||||
|
<div key={team} style={{ border: `1px solid ${isMyTeam ? tc : 'var(--border-color)'}`, borderLeft: `3px solid ${tc}`, borderRadius: 8, padding: '12px 14px', background: isMyTeam ? `${tc}0a` : 'transparent' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<span style={{ fontSize: 16 }}>{TEAM_ICONS[team]}</span>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: tc }}>{TEAM_LABELS[team]}</span>
|
||||||
|
{isMyTeam && <span style={{ fontSize: 10, background: `${tc}20`, color: tc, borderRadius: 4, padding: '1px 6px', fontWeight: 700, marginLeft: 'auto' }}>Dein Team</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{done} / {teamProcs.length} erledigt</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: pctT === 100 ? '#10b981' : 'var(--text-primary)' }}>{pctT}%</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: 'var(--border-color)', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ height: '100%', width: `${pctT}%`, background: pctT === 100 ? '#10b981' : tc, borderRadius: 3, transition: 'width .3s' }} />
|
||||||
|
</div>
|
||||||
|
{pctT < 100 && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#f59e0b' }}>
|
||||||
|
⚠ {teamProcs.length - done} Aufgabe{teamProcs.length - done !== 1 ? 'n' : ''} offen
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pctT === 100 && (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 11, color: '#10b981' }}>✓ Vollständig abgehakt</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Allgemeine Infos */}
|
{/* Allgemeine Infos */}
|
||||||
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
|
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 18 }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 12 }}>📋 Allgemeine Infos</div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px', marginBottom: 12 }}>📋 Allgemeine Infos</div>
|
||||||
@@ -411,6 +457,7 @@ export default function OffboardingDetailPage() {
|
|||||||
checkedItems={checklist}
|
checkedItems={checklist}
|
||||||
onChange={setChecklist}
|
onChange={setChecklist}
|
||||||
disabled={protocol.status === 'completed'}
|
disabled={protocol.status === 'completed'}
|
||||||
|
userTeam={userTeam}
|
||||||
/>
|
/>
|
||||||
{protocol.status !== 'completed' && canAct && (
|
{protocol.status !== 'completed' && canAct && (
|
||||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
@@ -481,25 +528,31 @@ export default function OffboardingDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 20 }}>
|
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 20 }}>
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 16, textTransform: 'uppercase', letterSpacing: '.5px' }}>📄 PDF-Dokument</div>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
{protocol.pdf_path ? (
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '.5px' }}>📄 PDF-Dokument</div>
|
||||||
<div>
|
{protocol.pdf_file_path && (
|
||||||
<div style={{ fontSize: 13, color: 'var(--text-primary)', marginBottom: 14 }}>PDF vorhanden</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<a href={offboardingService.downloadPdf(protocol.pdf_path)} target="_blank" rel="noreferrer"
|
<a href={offboardingService.downloadPdf(protocol.pdf_file_path)} target="_blank" rel="noreferrer"
|
||||||
style={{ background: 'var(--cereda-primary)', border: 'none', borderRadius: 8, color: '#fff', padding: '7px 16px', fontSize: 13, textDecoration: 'none', fontWeight: 600 }}>
|
style={{ background: 'var(--cereda-primary)', border: 'none', borderRadius: 6, color: '#fff', padding: '5px 12px', fontSize: 12, textDecoration: 'none', fontWeight: 600 }}>
|
||||||
📥 Download
|
📥 Download
|
||||||
</a>
|
</a>
|
||||||
{canAct && (
|
{canAct && (
|
||||||
<button onClick={regeneratePdf}
|
<button onClick={regeneratePdf}
|
||||||
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 8, color: 'var(--text-primary)', padding: '7px 14px', fontSize: 13, cursor: 'pointer' }}>
|
style={{ background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 6, color: 'var(--text-primary)', padding: '5px 12px', fontSize: 12, cursor: 'pointer' }}>
|
||||||
🔄 Neu erstellen
|
🔄 Neu
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
{protocol.pdf_file_path ? (
|
||||||
|
<iframe
|
||||||
|
src={offboardingService.downloadPdf(protocol.pdf_file_path)}
|
||||||
|
title="Offboarding PDF"
|
||||||
|
style={{ width: '100%', height: 600, border: 'none', borderRadius: 8, background: '#fff' }}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: '24px 0', color: 'var(--text-muted)' }}>
|
<div style={{ textAlign: 'center', padding: '32px 0', color: 'var(--text-muted)' }}>
|
||||||
<div style={{ fontSize: 36, marginBottom: 10 }}>📄</div>
|
<div style={{ fontSize: 36, marginBottom: 10 }}>📄</div>
|
||||||
<div style={{ fontSize: 13, marginBottom: 14 }}>Noch kein PDF generiert</div>
|
<div style={{ fontSize: 13, marginBottom: 14 }}>Noch kein PDF generiert</div>
|
||||||
{canAct && (
|
{canAct && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
|
|
||||||
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: 'ℹ️', label: 'Info', color: '#6366f1' } };
|
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: 'ℹ️', label: 'Info', color: '#6366f1' } };
|
||||||
|
|
||||||
const LATEST_AGENT_VERSION = '2.2.0';
|
const LATEST_AGENT_VERSION = '2.7.0';
|
||||||
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
|
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
|
||||||
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
|
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
|
||||||
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
|
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,7 @@ import userService from '../services/userService';
|
|||||||
import assetService from '../services/assetService';
|
import assetService from '../services/assetService';
|
||||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { marked } from 'marked';
|
import { renderMd, sanitizeHtml } from '../utils/sanitizeMarkdown';
|
||||||
|
|
||||||
marked.use({ breaks: true, gfm: true });
|
|
||||||
const renderMd = (text) => {
|
|
||||||
try {
|
|
||||||
const html = marked.parse(String(text || ''), { async: false });
|
|
||||||
return { __html: typeof html === 'string' ? html : String(html) };
|
|
||||||
} catch {
|
|
||||||
return { __html: String(text || '').replace(/\n/g, '<br>') };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_CONFIG = {
|
const STATUS_CONFIG = {
|
||||||
offen: { label: 'Offen', css: 'status-pending' },
|
offen: { label: 'Offen', css: 'status-pending' },
|
||||||
@@ -549,7 +539,7 @@ const TicketDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const boldLine = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
const boldLine = line.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
return <div key={i} style={{ minHeight: line ? undefined : '0.5em' }} dangerouslySetInnerHTML={{ __html: boldLine || ' ' }} />;
|
return <div key={i} style={{ minHeight: line ? undefined : '0.5em' }} dangerouslySetInnerHTML={{ __html: sanitizeHtml(boldLine || ' ') }} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -712,6 +712,9 @@ const FidoTab = ({ user }) => {
|
|||||||
const [showAssign, setShowAssign] = useState(false);
|
const [showAssign, setShowAssign] = useState(false);
|
||||||
const [assignId, setAssignId] = useState('');
|
const [assignId, setAssignId] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', pin: '', status: 'aktiv' });
|
||||||
|
const [pinRevealed, setPinRevealed] = useState({});
|
||||||
|
|
||||||
const loadKeys = () => {
|
const loadKeys = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -751,6 +754,29 @@ const FidoTab = ({ user }) => {
|
|||||||
loadKeys();
|
loadKeys();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startEdit = (key) => {
|
||||||
|
setEditingId(key.id);
|
||||||
|
setEditForm({ name: key.name, pin: key.pin || '', status: key.status });
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (key) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await authFetch(`${API}/fido-keys/${key.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ ...key, ...editForm }),
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
loadKeys();
|
||||||
|
} catch {
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePin = (id) => {
|
||||||
|
setPinRevealed(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
|
if (!pinRevealed[id]) setTimeout(() => setPinRevealed(prev => ({ ...prev, [id]: false })), 8000);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) return <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade FIDO-Keys…</div>;
|
if (loading) return <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade FIDO-Keys…</div>;
|
||||||
|
|
||||||
const hasEnoughKeys = keys.length >= 2;
|
const hasEnoughKeys = keys.length >= 2;
|
||||||
@@ -760,27 +786,80 @@ const FidoTab = ({ user }) => {
|
|||||||
<div className="bv-fido-hero">
|
<div className="bv-fido-hero">
|
||||||
{keys.map((key, i) => (
|
{keys.map((key, i) => (
|
||||||
<div key={key.id} className="bv-fkc-card" style={{position:'relative'}}>
|
<div key={key.id} className="bv-fkc-card" style={{position:'relative'}}>
|
||||||
<button
|
<div style={{position:'absolute',top:8,right:8,display:'flex',gap:6}}>
|
||||||
onClick={() => handleUnassign(key)}
|
<button
|
||||||
title="Zuweisung aufheben"
|
onClick={() => startEdit(key)}
|
||||||
style={{position:'absolute',top:8,right:8,background:'rgba(239,68,68,.12)',border:'1px solid rgba(239,68,68,.3)',color:'#ef4444',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
|
title="Bearbeiten"
|
||||||
>✕ Entfernen</button>
|
style={{background:'rgba(63,163,163,.12)',border:'1px solid rgba(63,163,163,.3)',color:'var(--cereda-primary)',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
|
||||||
|
>✎ Bearbeiten</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnassign(key)}
|
||||||
|
title="Zuweisung aufheben"
|
||||||
|
style={{background:'rgba(239,68,68,.12)',border:'1px solid rgba(239,68,68,.3)',color:'#ef4444',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
|
||||||
|
>✕ Entfernen</button>
|
||||||
|
</div>
|
||||||
<div className="bv-fkc-header">
|
<div className="bv-fkc-header">
|
||||||
<div className="bv-fkc-visual"><KeyIcon /></div>
|
<div className="bv-fkc-visual"><KeyIcon /></div>
|
||||||
<span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span>
|
<span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bv-fkc-name">{key.name}</div>
|
|
||||||
<div className="bv-fkc-sub">{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}</div>
|
{editingId === key.id ? (
|
||||||
<div className="bv-fkc-stats">
|
<div style={{display:'flex',flexDirection:'column',gap:8,marginTop:4}}>
|
||||||
<div className="bv-fkc-stat">
|
<input
|
||||||
<div className="bv-fkcs-v">—</div>
|
className="form-input"
|
||||||
<div className="bv-fkcs-l">Auth. Gesamt</div>
|
style={{fontSize:13}}
|
||||||
|
value={editForm.name}
|
||||||
|
onChange={e => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
placeholder="Name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
style={{fontSize:13,fontFamily:'Consolas,monospace',letterSpacing:'0.15em'}}
|
||||||
|
value={editForm.pin}
|
||||||
|
maxLength={6}
|
||||||
|
onChange={e => setEditForm({ ...editForm, pin: e.target.value.replace(/\D/g,'').slice(0,6) })}
|
||||||
|
placeholder="PIN (6-stellig)"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
style={{fontSize:13}}
|
||||||
|
value={editForm.status}
|
||||||
|
onChange={e => setEditForm({ ...editForm, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="aktiv">Aktiv</option>
|
||||||
|
<option value="inaktiv">Inaktiv</option>
|
||||||
|
</select>
|
||||||
|
<div style={{display:'flex',gap:8}}>
|
||||||
|
<button className="btn btn-primary btn-small" onClick={() => saveEdit(key)} disabled={saving}>{saving ? '…' : 'Speichern'}</button>
|
||||||
|
<button className="btn btn-secondary btn-small" onClick={() => setEditingId(null)}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bv-fkc-stat">
|
) : (
|
||||||
<div className="bv-fkcs-v">{fmtTime(key.last_used_at)}</div>
|
<>
|
||||||
<div className="bv-fkcs-l">Letzte Nutzung</div>
|
<div className="bv-fkc-name">{key.name}</div>
|
||||||
</div>
|
<div className="bv-fkc-sub">{key.manufacturer || 'Yubico'} · {key.connection_type || 'USB'} · SN {key.serial_number}</div>
|
||||||
</div>
|
{key.pin && (
|
||||||
|
<div style={{display:'flex',alignItems:'center',gap:6,marginTop:6}}>
|
||||||
|
<span style={{fontFamily:'Consolas,monospace',background:'rgba(0,0,0,.2)',borderRadius:6,padding:'2px 8px',fontSize:12,letterSpacing:'0.15em'}}>
|
||||||
|
{pinRevealed[key.id] ? key.pin : '••••••'}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => togglePin(key.id)} title="PIN anzeigen/verbergen" style={{background:'none',border:'none',cursor:'pointer',color:'inherit',opacity:0.7,padding:0,display:'flex'}}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="13" height="13"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bv-fkc-stats">
|
||||||
|
<div className="bv-fkc-stat">
|
||||||
|
<div className="bv-fkcs-v">—</div>
|
||||||
|
<div className="bv-fkcs-l">Auth. Gesamt</div>
|
||||||
|
</div>
|
||||||
|
<div className="bv-fkc-stat">
|
||||||
|
<div className="bv-fkcs-v">{fmtTime(key.last_used_at)}</div>
|
||||||
|
<div className="bv-fkcs-l">Letzte Nutzung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{showAssign ? (
|
{showAssign ? (
|
||||||
|
|||||||
@@ -7,17 +7,7 @@ import fidoKeyService from '../services/fidoKeyService';
|
|||||||
import aiService from '../services/aiService';
|
import aiService from '../services/aiService';
|
||||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { marked } from 'marked';
|
import { renderMd } from '../utils/sanitizeMarkdown';
|
||||||
|
|
||||||
marked.use({ breaks: true, gfm: true });
|
|
||||||
const renderMd = (text) => {
|
|
||||||
try {
|
|
||||||
const html = marked.parse(String(text || ''), { async: false });
|
|
||||||
return { __html: typeof html === 'string' ? html : String(html) };
|
|
||||||
} catch {
|
|
||||||
return { __html: String(text || '').replace(/\n/g, '<br>') };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────────────────────────── */
|
/* ── Helpers ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -3,33 +3,16 @@ import axios from 'axios';
|
|||||||
const API_URL = process.env.REACT_APP_API_URL || '/api';
|
const API_URL = process.env.REACT_APP_API_URL || '/api';
|
||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
|
// Auth läuft über ein httpOnly-Cookie (vom Server gesetzt) — kein Token in JS-lesbarem Storage,
|
||||||
|
// damit ein XSS-Treffer das Session-Token nicht einfach auslesen kann.
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor to add token to requests
|
|
||||||
api.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
if (token) {
|
|
||||||
if (typeof config.headers?.set === 'function') {
|
|
||||||
config.headers.set('Authorization', `Bearer ${token}`);
|
|
||||||
} else if (config.headers) {
|
|
||||||
config.headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
} else {
|
|
||||||
config.headers = { 'Authorization': `Bearer ${token}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Response interceptor to handle errors globally
|
// Response interceptor to handle errors globally
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
@@ -39,7 +22,6 @@ api.interceptors.response.use(
|
|||||||
if (error.response) {
|
if (error.response) {
|
||||||
// Handle 401 Unauthorized - token expired or invalid
|
// Handle 401 Unauthorized - token expired or invalid
|
||||||
if (error.response.status === 401) {
|
if (error.response.status === 401) {
|
||||||
localStorage.removeItem('token');
|
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|||||||
20
frontend/src/utils/sanitizeMarkdown.js
Normal file
20
frontend/src/utils/sanitizeMarkdown.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Marked } from 'marked';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
const marked = new Marked({ breaks: true, gfm: true });
|
||||||
|
|
||||||
|
// Rendert Markdown zu HTML und entfernt anschließend aktive Inhalte (script, on*-Attribute,
|
||||||
|
// javascript:-URLs etc.) — verhindert Stored XSS über KI-Antworten/Kommentare/Knowledge-Base.
|
||||||
|
export function renderMd(text) {
|
||||||
|
try {
|
||||||
|
const html = marked.parse(String(text || ''), { async: false });
|
||||||
|
const raw = typeof html === 'string' ? html : String(html);
|
||||||
|
return { __html: DOMPurify.sanitize(raw) };
|
||||||
|
} catch {
|
||||||
|
return { __html: DOMPurify.sanitize(String(text || '').replace(/\n/g, '<br>')) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHtml(html) {
|
||||||
|
return DOMPurify.sanitize(String(html || ''));
|
||||||
|
}
|
||||||
26
shell-client.js
Normal file
26
shell-client.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
const WebSocket = require('ws');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const token = process.argv[2];
|
||||||
|
const agentId = process.argv[3];
|
||||||
|
const command = process.argv[4].startsWith('@') ? fs.readFileSync(process.argv[4].slice(1), 'utf8') : process.argv[4];
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://localhost:5000/ws?type=shell&agentId=${agentId}`);
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token }));
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.send(command + '\r\n');
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('message', (data) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const waitMs = parseInt(process.argv[5]) || 8000;
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(buffer);
|
||||||
|
process.exit(0);
|
||||||
|
}, waitMs);
|
||||||
Reference in New Issue
Block a user