Add: Agent v2.1.2, WebSocket Live Shell, WebRTC Remote Desktop (Beta), Announcement Push

- Agent v2.1.2: WebSocket ShellService, ACK nach WS-Ankündigungen, shown_announcements Fix
- Backend: shellServer.js mit WebSocket-Server (Shell + Announcement Push + RTC Signaling)
- Backend: patch.controller.js Fix (command_id=0 Falsy-Bug beim Auto-Update)
- Frontend: Remote Desktop Tab (WebRTC Beta) in AgentDetailPage für super_admin/admin
- Frontend: PatchManagementPage auf v2.1.2 aktualisiert
- WPF Notification: AllowsTransparency=False + kein DropShadowEffect (Dispatcher-Crash Fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:31:11 +02:00
parent d584e65226
commit c23091fa6e
27 changed files with 3481 additions and 556 deletions

View File

@@ -6,7 +6,7 @@ namespace ITNexusAgent;
public class AgentWorker
{
private const string Version = "2.0.0";
private const string Version = "2.1.2";
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
@@ -39,6 +39,10 @@ public class AgentWorker
Log($"Agent v{Version} gestartet");
// WebSocket Shell-Service im Hintergrund starten
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
_ = shellService.RunAsync(_ct);
while (!_ct.IsCancellationRequested)
{
await RunCycleAsync();
@@ -79,12 +83,16 @@ public class AgentWorker
await executor.ExecuteAsync(cmd);
}
// Ankündigungen anzeigen
// Ankündigungen anzeigen + sofort ACKen damit Server sie nicht mehr schickt
if (response.Announcements.Count > 0)
{
_notifier!.ShowAnnouncements(response.Announcements);
foreach (var ann in response.Announcements)
{
Log($"ANNOUNCEMENT: Dialog für {SystemInfoService.GetLastUser()} gestartet - ID {ann.Id}");
try { await _api!.AckAnnouncementAsync(ann.Id, payload.Hostname); }
catch (Exception ex) { Log($"ANNOUNCEMENT ACK Fehler: {ex.Message}"); }
}
}
}
catch (Exception ex)

View File

@@ -45,6 +45,7 @@ public class PatchCommand
{
[JsonProperty("id")] public int Id { get; set; }
[JsonProperty("command")] public string Command { get; set; } = "";
[JsonProperty("params")] public string? Params { get; set; }
}
public class RunningCommand

View File

@@ -39,15 +39,38 @@ internal class Program
static void RunNotification(string base64Json)
{
const string errorLog = @"C:\ProgramData\IT Nexus Agent\notify-error.log";
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64Json));
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} START json={json}\n");
var ann = JsonConvert.DeserializeObject<Announcement>(json);
if (ann == null) return;
if (ann == null)
{
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} ERROR: Deserialization returned null\n");
return;
}
var app = new System.Windows.Application();
app.Run(new NotificationWindow(ann));
app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose;
app.DispatcherUnhandledException += (s, e) =>
{
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} DISPATCHER ERROR: {e.Exception}\n");
e.Handled = true;
app.Shutdown();
};
var win = new NotificationWindow(ann);
win.Topmost = true;
win.Show();
win.Activate();
app.Run();
}
catch (Exception ex)
{
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} ERROR: {ex}\n");
}
catch { }
}
static void RunDashboard()

View File

@@ -27,6 +27,7 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
"reboot" => await Reboot(cmd.Id),
"upgrade_win11" => await UpgradeWin11(),
"update_agent" => await UpdateAgent(),
"shell_exec" => await ShellExec(cmd.Params ?? ""),
_ => $"Unbekannter Command: {cmd.Command}"
};
}
@@ -95,6 +96,33 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
return Task.FromResult("running");
}
private Task<string> ShellExec(string command)
{
if (string.IsNullOrWhiteSpace(command)) return Task.FromResult("Kein Befehl angegeben");
try
{
var psi = new ProcessStartInfo("powershell.exe",
$"-NonInteractive -NoProfile -ExecutionPolicy Bypass -Command \"{command.Replace("\"", "\\\"")}\"")
{
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
var p = Process.Start(psi)!;
var stdout = p.StandardOutput.ReadToEnd();
var stderr = p.StandardError.ReadToEnd();
p.WaitForExit(30000);
var output = stdout;
if (!string.IsNullOrEmpty(stderr)) output += "\n[STDERR] " + stderr;
return Task.FromResult(string.IsNullOrEmpty(output) ? "(kein Output)" : output.Trim());
}
catch (Exception ex)
{
return Task.FromResult($"Fehler: {ex.Message}");
}
}
private async Task<string> UpdateAgent()
{
var bytes = await _api.DownloadSetupAsync();

View File

@@ -35,6 +35,11 @@ public class NotificationService
return [];
}
public void RemoveShownId(int id)
{
if (_shownIds.Remove(id)) SaveShownIds();
}
private void SaveShownIds()
{
try { File.WriteAllText(_shownIdsPath, JsonConvert.SerializeObject(_shownIds.ToList())); }
@@ -84,8 +89,12 @@ public class NotificationService
// Task mit Trigger weit in der Zukunft erstellen (damit er nicht abläuft vor /run)
var triggerTime = DateTime.Now.AddMinutes(60).ToString("HH:mm:ss");
// AzureAD-User (Entra/Hybrid-Join) können nicht per /ru aufgelöst werden → INTERACTIVE
var ruArg = fullUser.StartsWith("AzureAD\\", StringComparison.OrdinalIgnoreCase)
? "/ru \"INTERACTIVE\""
: $"/ru \"{fullUser}\"";
var args = $"/create /tn \"{taskName}\" /tr \"\\\"{_exePath}\\\" --notify {json}\" " +
$"/sc ONCE /st {triggerTime} /ru \"{fullUser}\" /it /f";
$"/sc ONCE /st {triggerTime} {ruArg} /it /f";
var p = Process.Start(new ProcessStartInfo("schtasks.exe", args)
{ CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false });
p?.WaitForExit();

View File

@@ -0,0 +1,199 @@
using System.Diagnostics;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ITNexusAgent.Services;
public class ShellService
{
private readonly string _serverUrl;
private readonly string _agentKey;
private readonly string _hostname;
public ShellService(string serverUrl, string agentKey, string hostname)
{
_serverUrl = serverUrl;
_agentKey = agentKey;
_hostname = hostname;
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await ConnectAsync(ct); }
catch (Exception ex) { AgentWorker.Log($"SHELL: {ex.Message}"); }
if (!ct.IsCancellationRequested)
await Task.Delay(TimeSpan.FromSeconds(15), ct).ContinueWith(_ => { });
}
}
private async Task ConnectAsync(CancellationToken ct)
{
var wsUrl = _serverUrl
.Replace("https://", "wss://")
.Replace("http://", "ws://")
+ $"/ws?type=agent&key={Uri.EscapeDataString(_agentKey)}&hostname={Uri.EscapeDataString(_hostname)}";
using var ws = new ClientWebSocket();
ws.Options.SetRequestHeader("User-Agent", "IT-Nexus-Agent/2.1.2");
await ws.ConnectAsync(new Uri(wsUrl), ct);
AgentWorker.Log("SHELL: WebSocket verbunden");
Process? shell = null;
CancellationTokenSource? shellCts = null;
async Task SendText(string text)
{
if (ws.State != WebSocketState.Open) return;
var bytes = Encoding.UTF8.GetBytes(text);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
}
void StartShell()
{
try { shellCts?.Cancel(); shell?.Kill(); } catch { }
shellCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var token = shellCts.Token;
var psi = new ProcessStartInfo("powershell.exe", "-NoExit -NoLogo -NoProfile")
{
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
shell = Process.Start(psi)!;
AgentWorker.Log("SHELL: PowerShell gestartet");
// stdout streamen
_ = Task.Run(async () =>
{
var buf = new char[1024];
while (!token.IsCancellationRequested)
{
try
{
int n = await shell.StandardOutput.ReadAsync(buf, 0, buf.Length);
if (n == 0) break;
await SendText(new string(buf, 0, n));
}
catch { break; }
}
}, token);
// stderr streamen (rot markiert)
_ = Task.Run(async () =>
{
var buf = new char[1024];
while (!token.IsCancellationRequested)
{
try
{
int n = await shell.StandardError.ReadAsync(buf, 0, buf.Length);
if (n == 0) break;
await SendText("\x1b[31m" + new string(buf, 0, n) + "\x1b[0m");
}
catch { break; }
}
}, token);
}
void StopShell()
{
shellCts?.Cancel();
try { shell?.Kill(); } catch { }
shell = null;
AgentWorker.Log("SHELL: PowerShell gestoppt");
}
try
{
var buffer = new byte[8192];
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
WebSocketReceiveResult result;
try
{
result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
}
catch { break; }
if (result.MessageType == WebSocketMessageType.Close) break;
var msg = Encoding.UTF8.GetString(buffer, 0, result.Count);
JObject? obj;
try { obj = JObject.Parse(msg); }
catch { continue; }
switch (obj["type"]?.ToString())
{
case "start_shell":
StartShell();
break;
case "input":
if (shell != null && !shell.HasExited)
{
var data = obj["data"]?.ToString() ?? "";
await shell.StandardInput.WriteAsync(data);
await shell.StandardInput.FlushAsync();
}
break;
case "stop_shell":
StopShell();
break;
case "announcements":
// Sofort-Push von Ankündigungen (ohne Warten auf Check-in)
var anns = obj["announcements"]?.ToObject<List<ITNexusAgent.Models.Announcement>>();
if (anns != null && anns.Count > 0)
{
var notifier = new NotificationService(
System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName,
@"C:\ProgramData\IT Nexus Agent");
notifier.ShowAnnouncements(anns);
AgentWorker.Log($"SHELL: {anns.Count} Ankündigung(en) via WebSocket empfangen");
// ACK senden + shown_announcements.json bereinigen damit gelöschte+neu erstellte
// Ankündigungen mit gleicher ID nicht geblockt werden
foreach (var ann in anns)
{
try
{
await AckAnnouncementAsync(ann.Id);
notifier.RemoveShownId(ann.Id);
}
catch (Exception ex) { AgentWorker.Log($"SHELL: ACK Fehler für ID {ann.Id}: {ex.Message}"); }
}
}
break;
}
}
}
finally
{
StopShell();
}
AgentWorker.Log("SHELL: WebSocket getrennt");
}
private async Task AckAnnouncementAsync(int announcementId)
{
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-agent-key", _agentKey);
var body = JsonContent.Create(new { hostname = _hostname });
await http.PostAsync($"{_serverUrl}/api/announcements/{announcementId}/ack-agent", body);
}
}

View File

@@ -7,8 +7,8 @@
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
AllowsTransparency="False"
Background="#1A1D2E"
Topmost="True"
FontFamily="Segoe UI">
@@ -41,12 +41,9 @@
</Style>
</Window.Resources>
<!-- Äußerer Rahmen mit Schatten + farbigem Top-Border -->
<Border CornerRadius="12" Background="#1A1D2E"
<!-- Äußerer Rahmen mit farbigem Top-Border -->
<Border CornerRadius="0" Background="#1A1D2E"
BorderBrush="#2D3250" BorderThickness="1">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="32" ShadowDepth="8" Opacity="0.7"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>

View File

@@ -1,4 +1,4 @@
{
"server_url": "https://it-nexus.cereda-systems.de",
"agent_key": "HIER-DEN-AGENT-KEY-EINTRAGEN"
"agent_key": "itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug"
}

View File

@@ -1,5 +1,5 @@
#define MyAppName "IT Nexus Agent"
#define MyAppVersion "2.0.0"
#define MyAppVersion "2.1.2"
#define MyAppPublisher "Cereda Systems GmbH"
#define MyAppURL "https://it-nexus.cereda-systems.de"
#define MyAppExeName "IT-Nexus-Agent.exe"
@@ -40,7 +40,7 @@ CloseApplicationsFilter=IT-Nexus-Agent.exe
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Files]
Source: "bin\Publish\IT-Nexus-Agent.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "publish\IT-Nexus-Agent.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "icon.ico"; DestDir: "{app}"; Flags: ignoreversion
Source: "config.template.json"; DestDir: "{commonappdata}\IT Nexus Agent"; DestName: "config.json"; Flags: onlyifdoesntexist uninsneveruninstall