// CS2MetaWebSocketPlugin.cs // CounterStrikeSharp plugin that exposes ONLY metadata: current map + connected players. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Reflection; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Cvars; using Microsoft.Extensions.Logging; namespace WsTelemetry; [MinimumApiVersion(175)] public class MetaWebSocketPlugin : BasePlugin { public override string ModuleName => "WS Metadata"; public override string ModuleVersion => "2.0.0"; public override string ModuleAuthor => "you + ChatGPT"; public override string ModuleDescription => "WS(S)-Server: Map + verbundene Spieler (reine Meta-Daten)"; // ------------- Config / State ------------- private volatile bool _enabled = true; private volatile string _bindHost = "0.0.0.0"; private volatile int _bindPort = 8081; private volatile string _bindPath = "/telemetry"; private volatile bool _useTls = false; private volatile string _certPath = ""; private volatile string _certPassword = ""; private X509Certificate2? _serverCert; private volatile string _mapName = ""; private volatile string _serverName = ""; private int _scoreCT = 0; private int _scoreT = 0; private TcpListener? _listener; private CancellationTokenSource? _serverCts; private Task? _acceptTask; private volatile bool _serverRunning = false; private volatile string _phase = "unknown"; private readonly ConcurrentDictionary _clients = new(); private int _clientSeq = 0; private const string ConfigFileName = "config.json"; private sealed class Cfg { public string? Url { get; set; } // ws[s]://host:port/path public string? CertPath { get; set; } // PFX public string? CertPassword { get; set; } // optional } private void SetPhase(string p) { var s = (p ?? "unknown").ToLowerInvariant(); if (s == _phase) return; _phase = s; Broadcast(JsonSerializer.Serialize(new { type = "phase", phase = _phase, t = NowMs() })); } private sealed class ClientState { public required TcpClient Tcp; public required Stream Stream; // NetworkStream oder SslStream public readonly object SendLock = new(); public readonly CancellationTokenSource Cts = new(); } // ------------- Helpers ------------- private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); private string BuildMapPayload() { return JsonSerializer.Serialize(new { type = "map", name = _mapName, serverName = _serverName, t = NowMs() }); } private void TrySafeInitialPush() { try { RefreshServerName(); // Map-Name aus dem Server holen (erst im NextFrame sicher verfügbar) var mn = Server.MapName; if (!string.IsNullOrWhiteSpace(mn)) { _mapName = mn!; // Map + (aktuelle) Phase in EINEM Payload Broadcast(BuildMapPayload()); } // Vollständige Spielerliste SendFullPlayerList(); // Phase separat noch einmal senden (falls ein Client erst danach connected) Broadcast(JsonSerializer.Serialize(new { type = "phase", phase = _phase, t = NowMs() })); // Optional: aktuellen Score mitschicken (falls du BroadcastScore() implementiert hast) BroadcastScore(); } catch (Exception ex) { Logger.LogDebug($"[WS] Initial Push übersprungen: {ex.Message}"); } } private void RefreshServerName() { try { // Standard in Source/CS2 var s = ConVar.Find("hostname")?.StringValue; if (!string.IsNullOrWhiteSpace(s)) { _serverName = s!; return; } // Fallback (vereinzelt genutzt) s = ConVar.Find("host_name")?.StringValue; if (!string.IsNullOrWhiteSpace(s)) _serverName = s!; } catch { /* ignore */ } } private void LoadAndApplyConfig(bool generateIfMissing = true) { try { var path = Path.Combine(ModuleDirectory, ConfigFileName); if (!File.Exists(path) && generateIfMissing) { var example = new Cfg { Url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}", CertPath = string.IsNullOrWhiteSpace(_certPath) ? "cert.pfx" : _certPath, CertPassword = _certPassword }; var jsonEx = JsonSerializer.Serialize(example, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, jsonEx, Encoding.UTF8); Logger.LogInformation($"[WS] Beispiel-Konfiguration erzeugt: {path}"); } if (!File.Exists(path)) { Logger.LogWarning($"[WS] Keine {ConfigFileName} gefunden. Verwende Defaults."); return; } var json = File.ReadAllText(path, Encoding.UTF8); var cfg = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new Cfg(); if (!string.IsNullOrWhiteSpace(cfg.Url)) { if (Uri.TryCreate(cfg.Url, UriKind.Absolute, out var uri) && (uri.Scheme == "ws" || uri.Scheme == "wss")) { _useTls = uri.Scheme == "wss"; _bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host; _bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port; _bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath; if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0"; } else { Logger.LogWarning($"[WS] Ungültige URL in {ConfigFileName}: '{cfg.Url}'"); } } if (!string.IsNullOrWhiteSpace(cfg.CertPath)) { _certPath = Path.IsPathRooted(cfg.CertPath) ? cfg.CertPath : Path.Combine(ModuleDirectory, cfg.CertPath); _serverCert = null; } if (cfg.CertPassword != null) { _certPassword = cfg.CertPassword; _serverCert = null; } Logger.LogInformation($"[WS] Konfiguration geladen ({_bindHost}:{_bindPort}{_bindPath}, tls={_useTls})"); } catch (Exception ex) { Logger.LogError($"[WS] Konnte {ConfigFileName} nicht laden/anwenden: {ex.Message}"); } } private void SaveConfig() { try { var path = Path.Combine(ModuleDirectory, ConfigFileName); var url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}"; var cp = _certPath; try { if (!string.IsNullOrWhiteSpace(cp)) { if (Path.IsPathRooted(cp) && cp.StartsWith(ModuleDirectory, StringComparison.OrdinalIgnoreCase)) cp = Path.GetRelativePath(ModuleDirectory, cp); } } catch { } var cfg = new Cfg { Url = url, CertPath = cp, CertPassword = _certPassword }; var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(path, json, Encoding.UTF8); Logger.LogInformation($"[WS] Konfiguration gespeichert: {path}"); } catch (Exception ex) { Logger.LogError($"[WS] Konnte Konfiguration nicht speichern: {ex.Message}"); } } // ------------- Lifecycle ------------- public override void Load(bool hotReload) { Logger.LogInformation("[WS] Meta-Plugin geladen. Kommandos: css_meta_enable, css_meta_restart, css_meta_reloadcfg, css_meta_url, css_meta_cert, css_meta_certpwd, css_meta_sendmap, css_meta_sendplayers"); // Mapstart → map broadcasten RegisterListener(OnMapStart); // Spieler-Events RegisterEventHandler(OnPlayerConnectFull); RegisterEventHandler(OnPlayerDisconnect); // Runden-/Bomben-Phase RegisterEventHandler(OnRoundStart); RegisterEventHandler(OnRoundFreezeEnd); RegisterEventHandler(OnRoundEnd); RegisterEventHandler(OnBombPlanted); RegisterEventHandler(OnBombDefused); RegisterEventHandler(OnBombExploded); // (optional, falls verfügbar) // RegisterEventHandler(OnWarmupStart); // RegisterEventHandler(OnWarmupEnd); LoadAndApplyConfig(); if (_enabled) StartWebSocket(); Server.NextFrame(() => { if (!_enabled) return; TrySafeInitialPush(); // siehe Methode unten }); } public override void Unload(bool hotReload) { StopWebSocket(); } // ------------- Events ------------- private void OnMapStart(string newMap) { _mapName = newMap ?? Server.MapName ?? ""; // Score neu starten _scoreCT = 0; _scoreT = 0; RefreshServerName(); SetPhase("warmup"); Broadcast(BuildMapPayload()); SendFullPlayerList(); BroadcastScore(); // gleich initial 0:0 senden Logger.LogInformation($"[WS] Map gewechselt: '{(_mapName ?? "")}' – Meta gesendet."); } private HookResult OnPlayerConnectFull(EventPlayerConnectFull ev, GameEventInfo info) { try { CCSPlayerController? p = ev.Userid; // direkt, kein .Value und kein ev.Name string name = (p != null && p.IsValid) ? p.PlayerName : "unknown"; ulong steamId = (p != null && p.IsValid) ? (p.AuthorizedSteamID?.SteamId64 ?? 0UL) : 0UL; int team = (p != null && p.IsValid) ? p.TeamNum : 0; bool? isBot = (p != null && p.IsValid) ? p.IsBot : (bool?)null; Broadcast(JsonSerializer.Serialize(new { type = "player_join", t = NowMs(), player = new { steamId, name, team, isBot } })); // danach die komplette Liste senden (Client-Sync) SendFullPlayerList(); } catch { /* ignore */ } return HookResult.Continue; } private HookResult OnPlayerDisconnect(EventPlayerDisconnect ev, GameEventInfo info) { try { CCSPlayerController? p = ev.Userid; ulong steamId = 0UL; if (p != null && p.IsValid) { steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL; } Broadcast(JsonSerializer.Serialize(new { type = "player_leave", t = NowMs(), steamId })); SendFullPlayerList(); } catch { /* ignore */ } return HookResult.Continue; } private HookResult OnRoundStart(EventRoundStart ev, GameEventInfo info) { // Start der Runde == Freezezeit SetPhase("freezetime"); BroadcastScore(); return HookResult.Continue; } private HookResult OnRoundFreezeEnd(EventRoundFreezeEnd ev, GameEventInfo info) { // Ende Freeze -> Live SetPhase("live"); BroadcastScore(); return HookResult.Continue; } private HookResult OnRoundEnd(EventRoundEnd ev, GameEventInfo info) { try { // Viele Builds haben ev.Winner (2 = T, 3 = CT). // Falls in deinem Build anders, kannst du unten noch "WinnerTeam" per Reflection abfragen. int? winner = null; // direkt zugreifen, wenn Property existiert try { winner = ev.Winner; } catch { /* Property evtl. nicht vorhanden */ } // Fallback per Reflection (WinnerTeam / Team / …) if (!winner.HasValue) { winner = TryGetIntProp(ev, "Winner") ?? TryGetIntProp(ev, "WinnerTeam") ?? TryGetIntProp(ev, "Team"); } if (winner == 3) _scoreCT++; else if (winner == 2) _scoreT++; } catch { /* ignore */ } SetPhase("over"); BroadcastScore(); return HookResult.Continue; } private HookResult OnBombPlanted(EventBombPlanted ev, GameEventInfo info) { SetPhase("bomb"); BroadcastScore(); return HookResult.Continue; } private HookResult OnBombDefused(EventBombDefused ev, GameEventInfo info) { // Bombe ist weg, Runde läuft weiter bis RoundEnd SetPhase("live"); return HookResult.Continue; } private HookResult OnBombExploded(EventBombExploded ev, GameEventInfo info) { SetPhase("over"); return HookResult.Continue; } // (optional) // private HookResult OnWarmupStart(EventWarmupStart ev, GameEventInfo info) { SetPhase("warmup"); return HookResult.Continue; } // private HookResult OnWarmupEnd(EventWarmupEnd ev, GameEventInfo info) { SetPhase("freezetime"); return HookResult.Continue; } // ------------- Console Commands ------------- [ConsoleCommand("css_meta_enable", "WS(S)-Server aktivieren/deaktivieren: css_meta_enable 1|0")] [CommandHelper(minArgs: 1, usage: "<1|0>")] public void CmdEnable(CCSPlayerController? caller, CommandInfo cmd) { var val = cmd.GetArg(1); bool enable = val == "1" || val.Equals("true", StringComparison.OrdinalIgnoreCase); if (enable == _enabled) { cmd.ReplyToCommand($"[WS] Bereits {_enabled}."); return; } _enabled = enable; cmd.ReplyToCommand($"[WS] Enabled = {_enabled}"); if (_enabled) StartWebSocket(); else StopWebSocket(); } [ConsoleCommand("css_meta_reloadcfg", "Lädt die config.json neu und startet den WS(S)-Server ggf. neu")] public void CmdReloadCfg(CCSPlayerController? caller, CommandInfo cmd) { var wasEnabled = _enabled; LoadAndApplyConfig(generateIfMissing: false); if (wasEnabled) { StopWebSocket(); StartWebSocket(); cmd.ReplyToCommand("[WS] Konfiguration neu geladen und Server neu gestartet."); } else { cmd.ReplyToCommand("[WS] Konfiguration neu geladen. Server ist deaktiviert (css_meta_enable 1 zum Starten)."); } } [ConsoleCommand("css_meta_restart", "Restartet den WS(S)-Server und lädt config.json neu")] public void CmdRestart(CCSPlayerController? caller, CommandInfo cmd) { try { LoadAndApplyConfig(generateIfMissing: false); var wasEnabled = _enabled; StopWebSocket(); if (wasEnabled) StartWebSocket(); cmd.ReplyToCommand("[WS] Neu gestartet."); if (!wasEnabled) cmd.ReplyToCommand("[WS] Hinweis: Server ist deaktiviert (css_meta_enable 1)."); } catch (Exception ex) { cmd.ReplyToCommand($"[WS] Restart-Fehler: {ex.Message}"); } } [ConsoleCommand("css_meta_url", "Setzt Bind-Host/Port/Pfad als ws[s]://host:port/path")] [CommandHelper(minArgs: 1, usage: "")] public void CmdUrl(CCSPlayerController? caller, CommandInfo cmd) { var url = cmd.GetArg(1); if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "ws" && uri.Scheme != "wss")) { cmd.ReplyToCommand($"[WS] Ungültige URL: {url}"); return; } _useTls = uri.Scheme == "wss"; _bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host; _bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port; _bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath; if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0"; var scheme = _useTls ? "wss" : "ws"; cmd.ReplyToCommand($"[WS] Bind = {scheme}://{_bindHost}:{_bindPort}{_bindPath}"); SaveConfig(); if (_enabled) { StopWebSocket(); StartWebSocket(); } } [ConsoleCommand("css_meta_cert", "Setzt das TLS-Zertifikat (PFX-Datei) – nur für wss")] [CommandHelper(minArgs: 1, usage: "")] public void CmdCert(CCSPlayerController? caller, CommandInfo cmd) { var input = cmd.GetArg(1); _certPath = Path.IsPathRooted(input) ? input : Path.Combine(ModuleDirectory, input); _serverCert = null; cmd.ReplyToCommand($"[WS] Zertifikatspfad gesetzt: '{_certPath}'"); SaveConfig(); if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); } } [ConsoleCommand("css_meta_certpwd", "Setzt das Passwort für das PFX-Zertifikat (oder '-' zum Leeren)")] [CommandHelper(minArgs: 1, usage: "")] public void CmdCertPwd(CCSPlayerController? caller, CommandInfo cmd) { var pwd = cmd.GetArg(1); _certPassword = pwd == "-" ? "" : pwd; _serverCert = null; cmd.ReplyToCommand($"[WS] Zertifikatspasswort {(string.IsNullOrEmpty(_certPassword) ? "gelöscht" : "gesetzt")}."); SaveConfig(); if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); } } [ConsoleCommand("css_meta_sendmap", "Sendet die aktuelle Karte an alle verbundenen Clients")] public void CmdSendMap(CCSPlayerController? caller, CommandInfo cmd) { Broadcast(BuildMapPayload()); cmd.ReplyToCommand($"[WS] Map '{_mapName}' an Clients gesendet."); } [ConsoleCommand("css_meta_sendplayers", "Sendet die komplette Spielerliste an alle verbundenen Clients")] public void CmdSendPlayers(CCSPlayerController? caller, CommandInfo cmd) { SendFullPlayerList(); cmd.ReplyToCommand("[WS] Spielerliste gesendet."); } // ------------- Player snapshot/broadcast ------------- private void SendFullPlayerList() { Server.NextFrame(() => { var list = new List(); foreach (var p in Utilities.GetPlayers()) { try { if (p == null || !p.IsValid) continue; list.Add(new { steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL, name = p.PlayerName, team = p.TeamNum, isBot = p.IsBot }); } catch { /* ignore one */ } } var payload = new { type = "players", t = NowMs(), players = list }; Broadcast(JsonSerializer.Serialize(payload)); }); } // ------------- WebSocket Server ------------- private void StartWebSocket() { StopWebSocket(); try { if (_useTls && !TryLoadCertificate(out _)) throw new Exception("TLS aktiv, aber kein funktionsfähiges PFX gefunden."); IPAddress ip; if (!IPAddress.TryParse(_bindHost, out ip)) ip = IPAddress.Any; _listener = new TcpListener(ip, _bindPort); _listener.Start(); _serverCts = new CancellationTokenSource(); _serverRunning = true; var scheme = _useTls ? "wss" : "ws"; Logger.LogInformation($"[WS] Server lauscht auf {scheme}://{_bindHost}:{_bindPort}{_bindPath}"); // Initiale Meta-Infos erst im nächsten Frame pushen (Globals sind dann da) Server.NextFrame(() => { if (!_enabled) return; TrySafeInitialPush(); }); _acceptTask = Task.Run(async () => { var ct = _serverCts!.Token; while (!ct.IsCancellationRequested) { TcpClient? tcp = null; try { tcp = await _listener!.AcceptTcpClientAsync(ct); _ = HandleClientAsync(tcp, ct); } catch (OperationCanceledException) { break; } catch (Exception ex) { Logger.LogWarning($"[WS] Accept-Fehler: {ex.Message}"); tcp?.Close(); await Task.Delay(250, ct); } } }); } catch (Exception ex) { Logger.LogError($"[WS] Start fehlgeschlagen: {ex.Message}"); StopWebSocket(); } } private void StopWebSocket() { _serverRunning = false; try { _serverCts?.Cancel(); } catch { } try { _listener?.Stop(); } catch { } _listener = null; foreach (var kv in _clients) { try { kv.Value.Cts.Cancel(); } catch { } try { kv.Value.Stream.Close(); } catch { } try { kv.Value.Tcp.Close(); } catch { } } _clients.Clear(); _serverCts = null; _acceptTask = null; } private async Task HandleClientAsync(TcpClient tcp, CancellationToken serverCt) { var id = Interlocked.Increment(ref _clientSeq); tcp.NoDelay = true; var baseStream = tcp.GetStream(); baseStream.ReadTimeout = 10000; baseStream.WriteTimeout = 10000; Stream stream = baseStream; SslStream? ssl = null; try { if (_useTls) { ssl = new SslStream(baseStream, leaveInnerStreamOpen: false); try { await ssl.AuthenticateAsServerAsync( _serverCert!, clientCertificateRequired: false, enabledSslProtocols: SslProtocols.Tls13 | SslProtocols.Tls12, checkCertificateRevocation: false ); } catch (AuthenticationException aex) { Logger.LogError($"[WS] TLS-Handshake fehlgeschlagen: {aex.Message}. Tipp: PFX & Passwort in {ConfigFileName} prüfen."); throw; } stream = ssl; } if (!await DoHandshakeAsync(stream, serverCt)) { tcp.Close(); return; } var state = new ClientState { Tcp = tcp, Stream = stream }; _clients[id] = state; Logger.LogInformation($"[WS] Client #{id} verbunden. Aktive: {_clients.Count}"); // Initial: Map + kompletter Roster – aber auf dem Main-Thread, // weil BuildPlayersPayload() CSS-Natives nutzt. Server.NextFrame(() => { try { if (!_clients.ContainsKey(id) || state.Cts.IsCancellationRequested) return; SendTextFrame(state, BuildMapPayload()); // ✅ Map ohne Phase var buf = BuildPlayersPayload(); SendTextFrame(state, buf); // ✅ Phase EINMAL separat für den neu verbundenen Client SendTextFrame(state, JsonSerializer.Serialize(new { type = "phase", phase = _phase, t = NowMs() })); } catch { /* ignore */ } }); await ReceiveLoop(state, serverCt); } catch (OperationCanceledException) { } catch (Exception ex) { Logger.LogWarning($"[WS] Client #{id} Fehler: {ex.Message}"); } finally { _clients.TryRemove(id, out _); try { stream.Close(); } catch { } try { ssl?.Dispose(); } catch { } try { baseStream.Close(); } catch { } try { tcp.Close(); } catch { } Logger.LogInformation($"[WS] Client #{id} getrennt. Aktive: {_clients.Count}"); } } private string BuildPlayersPayload() { var list = new List(); foreach (var p in Utilities.GetPlayers()) { try { if (p == null || !p.IsValid) continue; list.Add(new { steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL, name = p.PlayerName, team = p.TeamNum, isBot = p.IsBot }); } catch { } } return JsonSerializer.Serialize(new { type = "players", t = NowMs(), players = list }); } // kleiner Helper für obige Reflection-Zugriffe private static int? TryGetIntProp(object obj, string prop) { try { var pi = obj.GetType().GetProperty(prop, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (pi != null) { var v = pi.GetValue(obj); if (v is int i) return i; } } catch { } return null; } private void BroadcastScore() { Broadcast(JsonSerializer.Serialize(new { type = "score", ct = _scoreCT, t = _scoreT, tms = NowMs() })); } private void Broadcast(string text) { foreach (var kv in _clients) { var c = kv.Value; try { SendTextFrame(c, text); } catch { _clients.TryRemove(kv.Key, out _); try { c.Cts.Cancel(); } catch { } try { c.Stream.Close(); } catch { } try { c.Tcp.Close(); } catch { } } } } // --- Minimal WebSocket (Handshake + Frames) --- private static async Task ReadHeadersAsync(Stream s, CancellationToken ct) { var buf = new byte[8192]; using var ms = new MemoryStream(); while (true) { int n = await s.ReadAsync(buf.AsMemory(0, buf.Length), ct); if (n <= 0) break; ms.Write(buf, 0, n); if (ms.Length >= 4) { var b = ms.GetBuffer(); for (int i = 3; i < ms.Length; i++) { if (b[i - 3] == '\r' && b[i - 2] == '\n' && b[i - 1] == '\r' && b[i] == '\n') return Encoding.ASCII.GetString(b, 0, i + 1); } } if (ms.Length > 65536) throw new Exception("Header zu groß"); } return Encoding.ASCII.GetString(ms.ToArray()); } private async Task DoHandshakeAsync(Stream stream, CancellationToken ct) { var header = await ReadHeadersAsync(stream, ct); if (!header.StartsWith("GET ", StringComparison.OrdinalIgnoreCase)) return false; var firstLineEnd = header.IndexOf("\r\n", StringComparison.Ordinal); var firstLine = firstLineEnd > 0 ? header[..firstLineEnd] : header; var parts = firstLine.Split(' '); if (parts.Length < 2) return false; var path = parts[1]; if (!path.StartsWith(_bindPath, StringComparison.Ordinal)) { var notFound = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; var bytes = Encoding.ASCII.GetBytes(notFound); await stream.WriteAsync(bytes, ct); await stream.FlushAsync(ct); return false; } string? wsKey = null; foreach (var line in header.Split("\r\n")) { var idx = line.IndexOf(':'); if (idx <= 0) continue; var name = line[..idx].Trim(); var value = line[(idx + 1)..].Trim(); if (name.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase)) wsKey = value; } if (string.IsNullOrEmpty(wsKey)) return false; var accept = ComputeWebSocketAccept(wsKey); var resp = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + $"Sec-WebSocket-Accept: {accept}\r\n" + "\r\n"; var respBytes = Encoding.ASCII.GetBytes(resp); await stream.WriteAsync(respBytes, ct); await stream.FlushAsync(ct); return true; } private static string ComputeWebSocketAccept(string key) { const string guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; var bytes = Encoding.ASCII.GetBytes(key + guid); var hash = SHA1.HashData(bytes); return Convert.ToBase64String(hash); } private async Task ReceiveLoop(ClientState state, CancellationToken serverCt) { var s = state.Stream; var buf2 = new byte[2]; while (!serverCt.IsCancellationRequested && !state.Cts.IsCancellationRequested) { int r = await ReadExactAsync(s, buf2, 0, 2, serverCt); if (r == 0) break; byte b0 = buf2[0]; // FIN + opcode byte b1 = buf2[1]; // MASK + payload len byte opcode = (byte)(b0 & 0x0F); bool masked = (b1 & 0x80) != 0; ulong len = (ulong)(b1 & 0x7F); if (len == 126) { r = await ReadExactAsync(s, buf2, 0, 2, serverCt); if (r == 0) break; len = (ulong)IPAddress.NetworkToHostOrder(BitConverter.ToInt16(buf2, 0)); } else if (len == 127) { var b8 = new byte[8]; r = await ReadExactAsync(s, b8, 0, 8, serverCt); if (r == 0) break; if (BitConverter.IsLittleEndian) Array.Reverse(b8); len = BitConverter.ToUInt64(b8, 0); } byte[] mask = Array.Empty(); if (masked) { mask = new byte[4]; r = await ReadExactAsync(s, mask, 0, 4, serverCt); if (r == 0) break; } byte[] payload = Array.Empty(); if (len > 0) { payload = new byte[len]; r = await ReadExactAsync(s, payload, 0, (int)len, serverCt); if (r == 0) break; if (masked) for (int i = 0; i < payload.Length; i++) payload[i] = (byte)(payload[i] ^ mask[i % 4]); } if (opcode == 0x8) // Close { await SendCloseFrame(state); break; } else if (opcode == 0x9) // Ping -> Pong { await SendPongFrame(state, payload); } // Textframes (0x1) werden ignoriert } } private static async Task ReadExactAsync(Stream s, byte[] buf, int off, int len, CancellationToken ct) { int got = 0; while (got < len) { int n = await s.ReadAsync(buf.AsMemory(off + got, len - got), ct); if (n <= 0) return got; got += n; } return got; } private void SendTextFrame(ClientState c, string text) { var payload = Encoding.UTF8.GetBytes(text); using var ms = new MemoryStream(capacity: 2 + payload.Length + 10); ms.WriteByte(0x81); // FIN + Text if (payload.Length <= 125) { ms.WriteByte((byte)payload.Length); } else if (payload.Length <= ushort.MaxValue) { ms.WriteByte(126); var lenBytes = BitConverter.GetBytes((ushort)payload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); ms.Write(lenBytes, 0, 2); } else { ms.WriteByte(127); var lenBytes = BitConverter.GetBytes((ulong)payload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); ms.Write(lenBytes, 0, 8); } ms.Write(payload, 0, payload.Length); lock (c.SendLock) { var buf = ms.ToArray(); c.Stream.Write(buf, 0, buf.Length); c.Stream.Flush(); } } private static Task SendPongFrame(ClientState c, byte[] pingPayload) { var header = new MemoryStream(2 + pingPayload.Length); header.WriteByte(0x8A); // FIN + Pong if (pingPayload.Length <= 125) { header.WriteByte((byte)pingPayload.Length); } else if (pingPayload.Length <= ushort.MaxValue) { header.WriteByte(126); var lenBytes = BitConverter.GetBytes((ushort)pingPayload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); header.Write(lenBytes, 0, 2); } else { header.WriteByte(127); var lenBytes = BitConverter.GetBytes((ulong)pingPayload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); header.Write(lenBytes, 0, 8); } var buf = header.ToArray(); lock (c.SendLock) { c.Stream.Write(buf, 0, buf.Length); if (pingPayload.Length > 0) c.Stream.Write(pingPayload, 0, pingPayload.Length); c.Stream.Flush(); } return Task.CompletedTask; } private static Task SendCloseFrame(ClientState c) { var frame = new byte[] { 0x88, 0x00 }; // Close, no payload lock (c.SendLock) { c.Stream.Write(frame, 0, frame.Length); c.Stream.Flush(); } return Task.CompletedTask; } // --- TLS --- private bool TryLoadCertificate(out string usedPath) { usedPath = _certPath; try { string pluginDir = ModuleDirectory; if (string.IsNullOrWhiteSpace(usedPath)) { var def = Path.Combine(pluginDir, "cert.pfx"); if (File.Exists(def)) { usedPath = def; } else { var files = Directory.GetFiles(pluginDir, "*.pfx", SearchOption.TopDirectoryOnly); if (files.Length > 0) usedPath = files[0]; } } else if (!Path.IsPathRooted(usedPath)) { usedPath = Path.Combine(pluginDir, usedPath); } if (string.IsNullOrWhiteSpace(usedPath) || !File.Exists(usedPath)) { Logger.LogWarning($"[WS] Kein PFX im Plugin-Ordner ({pluginDir}) gefunden. Lege z.B. 'cert.pfx' dort ab oder setze mit css_meta_cert ."); return false; } try { _serverCert = new X509Certificate2( usedPath, string.IsNullOrEmpty(_certPassword) ? null : _certPassword, X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable ); } catch (CryptographicException cex) when ((cex.Message ?? "").IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 || (cex.Message ?? "").IndexOf("kennwort", StringComparison.OrdinalIgnoreCase) >= 0) { var pwdState = string.IsNullOrEmpty(_certPassword) ? "leer" : "gesetzt"; Logger.LogError($"[WS] TLS-PFX konnte nicht geöffnet werden: vermutlich falsches Passwort. Pfad: {usedPath}. CertPassword ist {pwdState}."); _serverCert = null; return false; } catch (CryptographicException cex) { Logger.LogError($"[WS] TLS-PFX Fehler ({usedPath}): {cex.Message}"); _serverCert = null; return false; } if (_serverCert == null) return false; if (!_serverCert.HasPrivateKey) { Logger.LogError($"[WS] Zertifikat geladen, aber ohne Private Key: {Path.GetFileName(usedPath)}. Bitte PFX mit privatem Schlüssel verwenden."); _serverCert = null; return false; } try { Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)} | Subject: {_serverCert.Subject} | Gültig bis: {_serverCert.NotAfter:u}"); } catch { Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)}"); } return true; } catch (Exception ex) { Logger.LogError($"[WS] Zertifikat konnte nicht geladen werden: {ex.Message}"); _serverCert = null; return false; } } }