2025-08-18 23:48:49 +02:00

751 lines
25 KiB
C#

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;
// 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<int, ClientState> _clients = new();
private int _clientSeq = 0;
// --- Outgoing Queue (für Events) ---
private readonly ConcurrentQueue<string> _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<Listeners.OnTick>(OnTick);
}
public override void Unload(bool hotReload)
{
StopWebSocket();
}
// =========================
// 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_url", "Setzt Bind-Host/Port/Pfad als ws[s]://host:port/path")]
[CommandHelper(minArgs: 1, usage: "<ws[s]://host:port/path>")]
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: "<hz>")]
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: "<pfad-zum.pfx>")]
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: "<passwort|->")]
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<object>();
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 <pfad>.");
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}");
_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}");
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<string> 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<bool> 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<byte>();
if (masked)
{
mask = new byte[4];
r = await ReadExactAsync(s, mask, 0, 4, serverCt);
if (r == 0) break;
}
byte[] payload = Array.Empty<byte>();
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<int> 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;
}
}