2025-09-08 22:53:37 +02:00

937 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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()
{
Server.NextFrame(() =>
{
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 aber auf dem Main-Thread,
// weil BuildPlayersPayload() CSS-Natives nutzt.
Server.NextFrame(() =>
{
try
{
if (!_clients.ContainsKey(id) || state.Cts.IsCancellationRequested) return;
var nowMs = NowMs();
SendTextFrame(state, JsonSerializer.Serialize(new { type = "map", name = _mapName, t = nowMs }));
var buf = BuildPlayersPayload(); // jetzt safe
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;
}
}
}