936 lines
32 KiB
C#
936 lines
32 KiB
C#
// 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 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 TcpListener? _listener;
|
||
private CancellationTokenSource? _serverCts;
|
||
private Task? _acceptTask;
|
||
private volatile bool _serverRunning = false;
|
||
|
||
private readonly ConcurrentDictionary<int, ClientState> _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 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 void TrySafeInitialPush()
|
||
{
|
||
try
|
||
{
|
||
var mn = Server.MapName; // jetzt sicher, weil NextFrame/MapStart
|
||
if (!string.IsNullOrEmpty(mn))
|
||
{
|
||
_mapName = mn!;
|
||
Broadcast(JsonSerializer.Serialize(new { type = "map", name = _mapName, t = NowMs() }));
|
||
}
|
||
SendFullPlayerList(); // nur hier oder aus Events heraus aufrufen
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogDebug($"[WS] Initial Push übersprungen: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
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<Cfg>(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<Listeners.OnMapStart>(OnMapStart);
|
||
|
||
// Spieler-Events
|
||
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
|
||
RegisterEventHandler<EventPlayerDisconnect>(OnPlayerDisconnect);
|
||
|
||
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 ?? "";
|
||
Broadcast(JsonSerializer.Serialize(new { type = "map", name = _mapName, t = NowMs() }));
|
||
// Nach Mapstart zusätzlich die Spielerliste pushen
|
||
SendFullPlayerList();
|
||
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;
|
||
}
|
||
|
||
// ------------- 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: "<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}");
|
||
SaveConfig();
|
||
if (_enabled) { StopWebSocket(); StartWebSocket(); }
|
||
}
|
||
|
||
[ConsoleCommand("css_meta_cert", "Setzt das TLS-Zertifikat (PFX-Datei) – nur für wss")]
|
||
[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;
|
||
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: "<passwort|->")]
|
||
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(JsonSerializer.Serialize(new { type = "map", name = _mapName, t = NowMs() }));
|
||
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()
|
||
{
|
||
var list = new List<object>();
|
||
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
|
||
try
|
||
{
|
||
var nowMs = NowMs();
|
||
SendTextFrame(state, JsonSerializer.Serialize(new { type = "map", name = _mapName, t = nowMs }));
|
||
// volle Liste an diesen Client:
|
||
var buf = BuildPlayersPayload();
|
||
SendTextFrame(state, buf);
|
||
}
|
||
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<object>();
|
||
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 });
|
||
}
|
||
|
||
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<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.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 <pfad>.");
|
||
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;
|
||
}
|
||
}
|
||
}
|