using System; using System.Collections.Concurrent; using System.IO; using System.Net; using System.Net.Sockets; using System.Net.Security; 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.Events; using Microsoft.Extensions.Logging; namespace WsTelemetry; [MinimumApiVersion(175)] public class WebSocketTelemetryPlugin : BasePlugin { public override string ModuleName => "WS Telemetry"; public override string ModuleVersion => "1.2.0"; public override string ModuleAuthor => "you + ChatGPT"; public override string ModuleDescription => "WS(S)-Server: broadcastet Spieler-Position/ViewAngles + Nade-Events"; // --- Konfiguration --- private volatile bool _enabled = false; private volatile int _sendHz = 10; private volatile string _mapName = ""; // Bind-Info: ws://host:port/path oder wss://host:port/path private volatile string _bindHost = "0.0.0.0"; private volatile int _bindPort = 8081; private volatile string _bindPath = "/telemetry"; private volatile bool _useTls = false; // TLS Zertifikat (PFX) private volatile string _certPath = ""; private volatile string _certPassword = ""; private X509Certificate2? _serverCert; // --- Server / Clients --- private TcpListener? _listener; private CancellationTokenSource? _serverCts; private Task? _acceptTask; private volatile bool _serverRunning = false; private class ClientState { public required TcpClient Tcp; public required Stream Stream; // NetworkStream oder SslStream public readonly object SendLock = new(); public readonly CancellationTokenSource Cts = new(); } private readonly ConcurrentDictionary _clients = new(); private int _clientSeq = 0; // --- Outgoing Queue (für Events) --- private readonly ConcurrentQueue _outbox = new(); private readonly AutoResetEvent _sendSignal = new(false); // --- Tick / Sampling --- private double _accumulator = 0.0; private const double MaxFrameDt = 0.25; private DateTime _lastTick = DateTime.UtcNow; public override void Load(bool hotReload) { Logger.LogInformation("[WS] Plugin geladen. Kommandos: css_ws_enable, css_ws_url, css_ws_rate, css_ws_cert, css_ws_certpwd"); RegisterListener(OnTick); // Mapname initial erfassen und auf Mapwechsel reagieren _mapName = Server.MapName ?? ""; RegisterListener(OnMapStart); } public override void Unload(bool hotReload) { StopWebSocket(); } private void OnMapStart(string newMap) { _mapName = newMap ?? Server.MapName ?? ""; var payload = JsonSerializer.Serialize(new { type = "map", name = _mapName, t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); Broadcast(payload); Logger.LogInformation($"[WS] Map gewechselt: '{_mapName}' – an Clients gesendet."); } // ========================= // Konsolen-Kommandos // ========================= [ConsoleCommand("css_ws_enable", "Aktiviert/Deaktiviert den integrierten WS(S)-Server: css_ws_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_ws_sendmap", "Sendet die aktuelle Karte an alle verbundenen Clients")] public void CmdSendMap(CCSPlayerController? caller, CommandInfo cmd) { var payload = JsonSerializer.Serialize(new { type = "map", name = _mapName, t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); Broadcast(payload); cmd.ReplyToCommand($"[WS] Map '{_mapName}' an Clients gesendet."); } [ConsoleCommand("css_ws_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}"); if (_enabled) { StopWebSocket(); StartWebSocket(); } } [ConsoleCommand("css_ws_rate", "Sendefrequenz in Hz (Standard 10)")] [CommandHelper(minArgs: 1, usage: "")] public void CmdRate(CCSPlayerController? caller, CommandInfo cmd) { if (int.TryParse(cmd.GetArg(1), out var hz) && hz > 0 && hz <= 128) { _sendHz = hz; cmd.ReplyToCommand($"[WS] Sendefrequenz = {_sendHz} Hz"); } else cmd.ReplyToCommand("[WS] Ungültig. Bereich: 1..128"); } [ConsoleCommand("css_ws_cert", "Setzt das TLS-Zertifikat (PFX-Datei)")] [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; // neu laden beim Start cmd.ReplyToCommand($"[WS] Zertifikatspfad gesetzt: '{_certPath}'"); if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); } } [ConsoleCommand("css_ws_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; // neu laden beim Start cmd.ReplyToCommand($"[WS] Zertifikatspasswort {(string.IsNullOrEmpty(_certPassword) ? "gelöscht" : "gesetzt")}."); if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); } } // ========================= // Tick / Spieler-Snapshot // ========================= private void OnTick() { if (!_enabled || !_serverRunning) return; var now = DateTime.UtcNow; var dt = (now - _lastTick).TotalSeconds; _lastTick = now; if (dt > MaxFrameDt) dt = MaxFrameDt; _accumulator += dt; var targetDt = 1.0 / Math.Max(1, _sendHz); if (_accumulator < targetDt) return; _accumulator = 0; var list = new System.Collections.Generic.List(); foreach (var p in Utilities.GetPlayers()) { try { if (p == null || !p.IsValid || p.IsBot || p.IsHLTV) continue; var pawnHandle = p.PlayerPawn; if (pawnHandle == null || !pawnHandle.IsValid) continue; var pawn = pawnHandle.Value; if (pawn == null) continue; var pos = pawn.AbsOrigin; var ang = pawn.EyeAngles; list.Add(new { steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL, name = p.PlayerName, team = p.TeamNum, pos = new { x = pos.X, y = pos.Y, z = pos.Z }, view = new { pitch = ang.X, yaw = ang.Y, roll = ang.Z } }); } catch { } } if (list.Count == 0) return; var payload = new { type = "tick", t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), players = list }; Broadcast(JsonSerializer.Serialize(payload)); } // ========================= // Game-Events: Granaten // ========================= [GameEventHandler] public HookResult OnGrenadeThrown(EventGrenadeThrown ev, GameEventInfo info) { try { var p = ev.Userid; Enqueue(new { type = "nade", sub = "thrown", t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL, name = p?.PlayerName ?? "", weapon = ev.Weapon }); } catch { } return HookResult.Continue; } [GameEventHandler] public HookResult OnHeDetonate (EventHegrenadeDetonate ev, GameEventInfo info) => EnqueueNadeDet(ev, "he"); [GameEventHandler] public HookResult OnFlashDetonate (EventFlashbangDetonate ev, GameEventInfo info) => EnqueueNadeDet(ev, "flash"); [GameEventHandler] public HookResult OnSmokeDetonate (EventSmokegrenadeDetonate ev, GameEventInfo info) => EnqueueNadeDet(ev, "smoke"); [GameEventHandler] public HookResult OnDecoyDetonate (EventDecoyDetonate ev, GameEventInfo info) => EnqueueNadeDet(ev, "decoy"); [GameEventHandler] public HookResult OnMolotovDetonate (EventMolotovDetonate ev, GameEventInfo info) => EnqueueNadeDet(ev, "molotov"); private HookResult EnqueueNadeDet(GameEvent ev, string kind) { try { dynamic d = ev; var p = d.Userid as CCSPlayerController; Enqueue(new { type = "nade", sub = "detonate", nade = kind, t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL, name = p?.PlayerName ?? "", pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z } }); } catch { } return HookResult.Continue; } private HookResult Enqueue(object obj) { if (!_enabled || !_serverRunning) return HookResult.Continue; _outbox.Enqueue(JsonSerializer.Serialize(obj)); _sendSignal.Set(); return HookResult.Continue; } private bool TryLoadCertificate(out string usedPath) { usedPath = _certPath; try { string pluginDir = ModuleDirectory; // CSS stellt das bereit // Wenn kein Pfad konfiguriert: im Plugin-Ordner suchen 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 { // Relativen Pfad relativ zum Plugin-Ordner interpretieren if (!Path.IsPathRooted(usedPath)) usedPath = Path.Combine(pluginDir, usedPath); } if (string.IsNullOrWhiteSpace(usedPath) || !File.Exists(usedPath)) { Logger.LogWarning($"[WS] Kein PFX gefunden im Plugin-Ordner ({pluginDir}). " + "Lege z.B. 'cert.pfx' dort ab oder setze mit css_ws_cert ."); return false; } _serverCert = new X509Certificate2( usedPath, string.IsNullOrEmpty(_certPassword) ? null : _certPassword, X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable ); // Erfolgs-Hinweis 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; } } // ========================= // WS(S)-Server / Broadcast // ========================= private void StartWebSocket() { StopWebSocket(); try { if (_useTls) { if (!TryLoadCertificate(out var usedPath)) 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}"); // beim Start aktuelle Map an bereits verbundene (falls Hot-Reload) senden _mapName = string.IsNullOrEmpty(Server.MapName) ? _mapName : Server.MapName!; var startPayload = JsonSerializer.Serialize(new { type = "map", name = _mapName, t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); Broadcast(startPayload); _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); } } }); // Sender, der die Outbox entleert _ = Task.Run(async () => { var ct = _serverCts!.Token; while (!ct.IsCancellationRequested) { if (_outbox.IsEmpty) _sendSignal.WaitOne(200); while (_outbox.TryDequeue(out var msg)) Broadcast(msg); await Task.Delay(1, 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 void Broadcast(string text) { foreach (var kv in _clients) { var id = kv.Key; var c = kv.Value; try { SendTextFrame(c, text); } catch { _clients.TryRemove(id, out _); try { c.Cts.Cancel(); } catch { } try { c.Stream.Close(); } catch { } try { c.Tcp.Close(); } catch { } } } } private async Task HandleClientAsync(TcpClient tcp, CancellationToken serverCt) { var id = Interlocked.Increment(ref _clientSeq); tcp.NoDelay = true; // Basis-Stream var baseStream = tcp.GetStream(); baseStream.ReadTimeout = 10000; baseStream.WriteTimeout = 10000; // Optional TLS Stream stream = baseStream; SslStream? ssl = null; try { if (_useTls) { ssl = new SslStream(baseStream, leaveInnerStreamOpen: false); await ssl.AuthenticateAsServerAsync( _serverCert!, clientCertificateRequired: false, enabledSslProtocols: SslProtocols.Tls13 | SslProtocols.Tls12, checkCertificateRevocation: false ); 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}"); // Dem neuen Client die aktuelle Map sofort schicken try { var mapPayload = JsonSerializer.Serialize(new { type = "map", name = _mapName, t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }); SendTextFrame(state, mapPayload); } catch { /* wenn der Client beim Connect gleich schließt, ignorieren */ } 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}"); } } // --- Minimaler WebSocket-Server: 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.GetBuffer(); c.Stream.Write(buf, 0, (int)ms.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; } }