// SentinelID — CounterStrikeSharp Plugin for Counter-Strike 2 // // Hardware device verification and connect-time enforcement for CS2 community // servers. Works alongside the SentinelID sidecar which handles device // verification via the SentinelID API. // // Players must authenticate via the companion app before joining. // Unauthenticated players are kicked after a grace period. // // CS2 has no native whitelist (unlike Rust's `server.whitelist true`), so ALL // enforcement lives here in the plugin. The sidecar is only the auth cache and // API bridge — if this plugin is not loaded, nothing is enforced. // // Installation: // 1. Install Metamod:Source and CounterStrikeSharp on your server // 2. Run the SentinelID sidecar on your server (see tools/cs2-sidecar/) // 3. Drop SentinelID.dll into // game/csgo/addons/counterstrikesharp/plugins/SentinelID/ // 4. Configure via // game/csgo/addons/counterstrikesharp/configs/plugins/SentinelID/SentinelID.json // (auto-generated on first load) // See README.md in this directory for the full walkthrough. // // Commands: // !sid — Check your own SentinelID auth status // !sid check — (Admin) Check another player's status // !sid list — (Admin) List all verified players // !sid ban — (Admin) Ban a player's device // !sid unban — (Admin) Revoke a ban // !sid pardon — (Admin) Clear failed login attempts // sentinelid_status — (Server console) Status dump // // Permissions (configs/admins.json): // @sentinelid/admin — Required for admin commands // @sentinelid/bypass — Skip verification (for admins/mods) using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; // Compile-verified against CSS 1.0.305/1.0.368/1.0.371: BasePlugin exposes a // `Logger` (ILogger) property; the Log* calls below // are Microsoft.Extensions.Logging extension methods and need this using. If // your CSS build lacks BasePlugin.Logger, swap these for Server.PrintToConsole. using Microsoft.Extensions.Logging; // `Timer` is ambiguous between CounterStrikeSharp.API.Modules.Timers.Timer and // System.Threading.Timer once ImplicitUsings pulls in System. using CSTimer = CounterStrikeSharp.API.Modules.Timers.Timer; namespace SentinelID; #region Configuration public class SentinelIdConfig : BasePluginConfig { [JsonPropertyName("SidecarUrl")] public string SidecarUrl { get; set; } = "http://127.0.0.1:7997"; [JsonPropertyName("GracePeriodSeconds")] public float GracePeriodSeconds { get; set; } = 30f; [JsonPropertyName("CheckIntervalSeconds")] public float CheckIntervalSeconds { get; set; } = 5f; [JsonPropertyName("KickUnverified")] public bool KickUnverified { get; set; } = true; [JsonPropertyName("MaxFailedAttempts")] public int MaxFailedAttempts { get; set; } = 3; // Must match admin_api_key in the sidecar's config.toml. Leave empty if the // sidecar has no admin key configured. Sent as X-Admin-Key on /ban + /unban. [JsonPropertyName("AdminApiKey")] public string AdminApiKey { get; set; } = ""; // Colour tokens ({green}, {red}, {default}, ...) are substituted at runtime. // See ApplyChatColors() for the full list. [JsonPropertyName("MessagePrefix")] public string MessagePrefix { get; set; } = "{green}[SentinelID]{default}"; [JsonPropertyName("Messages")] public MessageConfig Messages { get; set; } = new MessageConfig(); } public class MessageConfig { [JsonPropertyName("AuthSuccess")] public string AuthSuccess { get; set; } = "SentinelID verified. Welcome!"; [JsonPropertyName("AuthRequired")] public string AuthRequired { get; set; } = "This server requires SentinelID verification. Run the companion app to authenticate, then rejoin."; [JsonPropertyName("KickReason")] public string KickReason { get; set; } = "SentinelID verification required. Run the companion app and rejoin."; [JsonPropertyName("ServerBanReason")] public string ServerBanReason { get; set; } = "You have been banned from this server for repeatedly joining without SentinelID verification."; [JsonPropertyName("BannedKickReason")] public string BannedKickReason { get; set; } = "SentinelID: You have been banned."; [JsonPropertyName("GracePeriodWarning")] public string GracePeriodWarning { get; set; } = "You have {seconds}s to authenticate with SentinelID or you will be kicked."; [JsonPropertyName("AttemptsRemaining")] public string AttemptsRemaining { get; set; } = "{red}Warning: {remaining} attempt(s) remaining before you are banned from this server.{default}"; [JsonPropertyName("StatusVerified")] public string StatusVerified { get; set; } = "You are SentinelID verified (device: {device_id})."; [JsonPropertyName("StatusNotVerified")] public string StatusNotVerified { get; set; } = "You are NOT SentinelID verified."; [JsonPropertyName("StatusChecking")] public string StatusChecking { get; set; } = "Checking your SentinelID status..."; [JsonPropertyName("NoPermission")] public string NoPermission { get; set; } = "You do not have permission to use admin commands."; [JsonPropertyName("BanSuccess")] public string BanSuccess { get; set; } = "Ban created (ID: {ban_id}) for {player}."; [JsonPropertyName("BanFailed")] public string BanFailed { get; set; } = "Ban failed: {error}"; [JsonPropertyName("UnbanSuccess")] public string UnbanSuccess { get; set; } = "Ban {ban_id} revoked."; [JsonPropertyName("UnbanFailed")] public string UnbanFailed { get; set; } = "Unban failed: {error}"; } #endregion // VERIFY: MinimumApiVersion(80) is the value used by the upstream TestPlugin at // time of writing. Bump this to match the CounterStrikeSharp build you deploy // against — too low and CSS may reject calls added in later API versions. [MinimumApiVersion(80)] public class SentinelIdPlugin : BasePlugin, IPluginConfig { public override string ModuleName => "SentinelID"; public override string ModuleVersion => "2.0.0"; public override string ModuleAuthor => "C-Squared Solutions"; public override string ModuleDescription => "SentinelID hardware device verification and connect-time enforcement"; public SentinelIdConfig Config { get; set; } = new SentinelIdConfig(); #region State // All of the collections below are touched ONLY on the main game thread. // Every HTTP callback marshals back via Server.NextFrame before mutating // them, so plain Dictionary/HashSet is safe here — do not mutate these from // a Task continuation without going through Server.NextFrame first. // Cached auth results (steamId -> device_id) private readonly Dictionary _verifiedPlayers = new(); // Players in grace period (steamId -> join time) private readonly Dictionary _pendingPlayers = new(); // Failed login attempts (steamId -> count) — persisted to disk private Dictionary _failedAttempts = new(); // Steam IDs we have already greeted this session. Verification starts at // OnClientAuthorized (earliest point the Steam ID is known) but chat only // works once the player is fully in the server, so the greeting is deferred // to EventPlayerConnectFull. This guards against double-greeting when the // sidecar answers before the player finishes connecting. private readonly HashSet _greeted = new(); // Steam IDs that must be kicked the moment they are fully connected // (server-banned for repeated failed attempts). private readonly Dictionary _kickOnConnect = new(); // Timer for checking pending players private CSTimer? _checkTimer; private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(10) }; private const string PermAdmin = "@sentinelid/admin"; private const string PermBypass = "@sentinelid/bypass"; private string FailedAttemptsPath => Path.Combine(ModuleDirectory, "failed_attempts.json"); #endregion #region Plugin Lifecycle public void OnConfigParsed(SentinelIdConfig config) { Config = config; if (config.GracePeriodSeconds <= 0) { Logger.LogWarning("GracePeriodSeconds must be > 0 — falling back to 30."); config.GracePeriodSeconds = 30f; } if (config.CheckIntervalSeconds <= 0) { Logger.LogWarning("CheckIntervalSeconds must be > 0 — falling back to 5."); config.CheckIntervalSeconds = 5f; } if (config.MaxFailedAttempts < 1) { Logger.LogWarning("MaxFailedAttempts must be >= 1 — falling back to 3."); config.MaxFailedAttempts = 3; } } public override void Load(bool hotReload) { LoadFailedAttempts(); // Compile-verified. Listeners.OnClientAuthorized is declared as // delegate void OnClientAuthorized(int playerSlot, SteamID steamId) // Confirmed against the CounterStrikeSharp API docs; if your CSS build // passes a ulong instead of SteamID, adjust the lambda parameter. RegisterListener(OnClientAuthorized); _checkTimer = AddTimer( Config.CheckIntervalSeconds, CheckPendingPlayers, TimerFlags.REPEAT ); Logger.LogInformation( "SentinelID loaded — sidecar {Url}, grace {Grace}s, kick {Kick}", Config.SidecarUrl, Config.GracePeriodSeconds, Config.KickUnverified ); if (hotReload) { // On hot reload nobody will fire OnClientAuthorized for players who // are already in the server, so re-seed verification for them. foreach (var player in Utilities.GetPlayers()) { if (!IsRealPlayer(player)) continue; var steamId = player.SteamID.ToString(); _greeted.Add(steamId); BeginVerification(steamId); } } } public override void Unload(bool hotReload) { // VERIFY: Timer.Kill() is the cancel method on CSS's Timer. CSS also // reaps plugin-owned timers on unload, so this is belt-and-braces — // if Kill() doesn't exist on your build, deleting this line is safe. _checkTimer?.Kill(); _checkTimer = null; SaveFailedAttempts(); _verifiedPlayers.Clear(); _pendingPlayers.Clear(); _greeted.Clear(); _kickOnConnect.Clear(); } private void LoadFailedAttempts() { try { if (!File.Exists(FailedAttemptsPath)) { _failedAttempts = new Dictionary(); return; } var json = File.ReadAllText(FailedAttemptsPath); _failedAttempts = JsonSerializer.Deserialize>(json) ?? new Dictionary(); } catch (Exception ex) { Logger.LogWarning("Failed attempts file is corrupt or unreadable ({Msg}) — starting empty.", ex.Message); _failedAttempts = new Dictionary(); } } private void SaveFailedAttempts() { try { var json = JsonSerializer.Serialize( _failedAttempts, new JsonSerializerOptions { WriteIndented = true }); File.WriteAllText(FailedAttemptsPath, json); } catch (Exception ex) { Logger.LogError("Failed to persist failed attempts: {Msg}", ex.Message); } } #endregion #region Connection Hooks // Fired as soon as Steam has authorized the client — the earliest point the // Steam ID is trustworthy. We start the sidecar round-trip here so the // answer is usually back by the time the player finishes loading in. private void OnClientAuthorized(int playerSlot, SteamID steamId) { // Compile-verified. SteamID.SteamId64 is the Steam64 accessor on CSS's SteamID // wrapper (Modules.Entities.SteamID). Rename if your build exposes it // as .SteamId64 vs .SteamID64 — casing has drifted between versions. var steamIdStr = steamId.SteamId64.ToString(); // Skip verification for players with bypass permission. // Compile-verified. AdminManager.PlayerHasPermissions(SteamID?, params string[]) // overload confirmed in the API docs. Permission flags must start with // '@' and are granted in configs/admins.json. if (AdminManager.PlayerHasPermissions(steamId, PermBypass)) { _verifiedPlayers[steamIdStr] = "bypass"; Logger.LogInformation("Bypass: {SteamId} has {Perm} permission", steamIdStr, PermBypass); return; } // Check if already banned from too many failed attempts _failedAttempts.TryGetValue(steamIdStr, out var attempts); if (attempts >= Config.MaxFailedAttempts) { Logger.LogInformation( "Blocked: {SteamId} has {Attempts} failed attempts — server banned", steamIdStr, attempts ); // The controller usually isn't usable yet at authorize time, so // defer the kick until the player is fully connected. _kickOnConnect[steamIdStr] = Config.Messages.ServerBanReason; return; } BeginVerification(steamIdStr); } [GameEventHandler] public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { var player = @event.Userid; if (!IsRealPlayer(player)) return HookResult.Continue; var steamId = player!.SteamID.ToString(); // Server-banned for repeated failures — kick now that we have a // usable controller. if (_kickOnConnect.TryGetValue(steamId, out var banReason)) { _kickOnConnect.Remove(steamId); KickPlayer(steamId, banReason); return HookResult.Continue; } // Now that the player is fully in the server, chat works. Greet them // based on whatever we know so far. _greeted.Add(steamId); if (_verifiedPlayers.ContainsKey(steamId)) { TellPlayer(steamId, Config.Messages.AuthSuccess); } else if (_pendingPlayers.ContainsKey(steamId)) { SendGraceWarning(steamId); } else { // Sidecar hasn't answered yet — BeginVerification's callback will // greet them once it does (see _greeted). TellPlayer(steamId, Config.Messages.StatusChecking); } return HookResult.Continue; } [GameEventHandler] public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) { var player = @event.Userid; if (player == null || !player.IsValid) return HookResult.Continue; var steamId = player.SteamID.ToString(); _pendingPlayers.Remove(steamId); _verifiedPlayers.Remove(steamId); _greeted.Remove(steamId); _kickOnConnect.Remove(steamId); return HookResult.Continue; } // Starts the sidecar check and records the outcome. Safe to call before the // player is fully connected — messaging is gated on _greeted. private void BeginVerification(string steamId) { CheckPlayerAuth(steamId, (authorized, deviceId) => { // Player may have disconnected mid-flight. var player = FindPlayerBySteamId(steamId); if (authorized) { _verifiedPlayers[steamId] = deviceId ?? "unknown"; _pendingPlayers.Remove(steamId); // Clear failed attempts on successful auth if (_failedAttempts.Remove(steamId)) { SaveFailedAttempts(); } if (player != null && _greeted.Contains(steamId)) { TellPlayer(steamId, Config.Messages.AuthSuccess); } Logger.LogInformation("Auth OK: {SteamId} (device: {DeviceId})", steamId, deviceId); } else { // Start grace period _pendingPlayers[steamId] = DateTime.UtcNow; _failedAttempts.TryGetValue(steamId, out var attempts); if (player != null && _greeted.Contains(steamId)) { SendGraceWarning(steamId); } Logger.LogInformation( "No auth for {SteamId} — {Grace}s grace period started (attempt {N}/{Max})", steamId, Config.GracePeriodSeconds, attempts + 1, Config.MaxFailedAttempts ); } }); } private void SendGraceWarning(string steamId) { var warning = Config.Messages.GracePeriodWarning .Replace("{seconds}", Config.GracePeriodSeconds.ToString("F0")); TellPlayer(steamId, warning); TellPlayer(steamId, Config.Messages.AuthRequired); _failedAttempts.TryGetValue(steamId, out var attempts); var remaining = Config.MaxFailedAttempts - attempts - 1; if (remaining <= 1) { TellPlayer(steamId, Config.Messages.AttemptsRemaining .Replace("{remaining}", remaining.ToString())); } } #endregion #region Grace Period Enforcement private void CheckPendingPlayers() { if (_pendingPlayers.Count == 0) return; var now = DateTime.UtcNow; var toCheck = new List(); foreach (var kvp in _pendingPlayers) { var elapsed = (now - kvp.Value).TotalSeconds; if (elapsed >= Config.GracePeriodSeconds) { toCheck.Add(kvp.Key); } } foreach (var steamId in toCheck) { _pendingPlayers.Remove(steamId); // Final check before kicking CheckPlayerAuth(steamId, (authorized, deviceId) => { if (authorized) { _verifiedPlayers[steamId] = deviceId ?? "unknown"; if (FindPlayerBySteamId(steamId) != null) { TellPlayer(steamId, Config.Messages.AuthSuccess); } Logger.LogInformation("Grace period: {SteamId} verified before deadline", steamId); return; } // Increment failed attempts _failedAttempts.TryGetValue(steamId, out var count); count++; _failedAttempts[steamId] = count; SaveFailedAttempts(); if (!Config.KickUnverified) { Logger.LogWarning( "Grace expired for {SteamId} — would kick but KickUnverified is false (attempt {N}/{Max})", steamId, count, Config.MaxFailedAttempts ); return; } if (FindPlayerBySteamId(steamId) == null) return; if (count >= Config.MaxFailedAttempts) { Logger.LogWarning( "Banning {SteamId} from server — {N} failed verification attempts", steamId, count); KickPlayer(steamId, Config.Messages.ServerBanReason); } else { Logger.LogWarning( "Kicking unverified player: {SteamId} (attempt {N}/{Max})", steamId, count, Config.MaxFailedAttempts ); KickPlayer(steamId, Config.Messages.KickReason); } }); } } #endregion #region Chat Commands // Commands prefixed `css_` are automatically exposed as chat commands // without the prefix, so this registers both `!sid` and `/sid`. [ConsoleCommand("css_sid", "SentinelID status and admin commands")] [CommandHelper(minArgs: 0, usage: "[check|list|ban|unban|pardon]", whoCanExecute: CommandUsage.CLIENT_AND_SERVER)] public void CmdSentinelId(CCSPlayerController? player, CommandInfo command) { // Several subcommands answer only after a sidecar round-trip, i.e. some // frames later — by which point `command` (a native handle) is no // longer safe to touch. Capture a replier bound to the caller's Steam // ID up front and use that everywhere instead of CommandInfo. var reply = MakeReplier(player); // GetArg(0) is the command name itself. var sub = command.ArgCount > 1 ? command.GetArg(1).ToLowerInvariant() : "status"; if (sub == "status") { if (player == null) { reply("Console has no SentinelID status — use sentinelid_status."); return; } var steamId = player.SteamID.ToString(); if (_verifiedPlayers.TryGetValue(steamId, out var deviceId)) { reply(Prefixed(Config.Messages.StatusVerified.Replace("{device_id}", deviceId))); } else { reply(Prefixed(Config.Messages.StatusNotVerified)); } return; } // Admin commands. player == null means the server console, which is // implicitly root. if (player != null && !AdminManager.PlayerHasPermissions(player, PermAdmin)) { reply(Prefixed(Config.Messages.NoPermission)); return; } switch (sub) { case "check": if (command.ArgCount < 3) { reply(Prefixed("Usage: !sid check ")); return; } CmdCheckPlayer(reply, command.GetArg(2)); break; case "list": CmdListVerified(reply); break; case "ban": if (command.ArgCount < 5) { reply(Prefixed("Usage: !sid ban ")); return; } CmdBanPlayer(reply, command.GetArg(2), command.GetArg(3), JoinArgsFrom(command, 4)); break; case "unban": if (command.ArgCount < 3) { reply(Prefixed("Usage: !sid unban ")); return; } CmdUnbanPlayer(reply, command.GetArg(2)); break; case "pardon": if (command.ArgCount < 3) { reply(Prefixed("Usage: !sid pardon — clears failed login attempts")); return; } CmdPardon(reply, command.GetArg(2)); break; default: reply(Prefixed("Commands: !sid, !sid check, !sid list, !sid ban, !sid unban, !sid pardon")); break; } } private void CmdCheckPlayer(Action reply, string target) { var targetPlayer = FindTarget(target); if (targetPlayer == null) { reply(Prefixed($"Player '{target}' not found.")); return; } var steamId = targetPlayer.SteamID.ToString(); var displayName = targetPlayer.PlayerName; if (_verifiedPlayers.TryGetValue(steamId, out var deviceId)) { reply(Prefixed($"{displayName} ({steamId}) is SentinelID verified (device: {deviceId})")); } else if (_pendingPlayers.TryGetValue(steamId, out var joinedAt)) { var elapsed = (DateTime.UtcNow - joinedAt).TotalSeconds; var remaining = Config.GracePeriodSeconds - elapsed; reply(Prefixed($"{displayName} ({steamId}) is pending verification ({remaining:F0}s remaining)")); } else { CheckPlayerAuth(steamId, (authorized, devId) => { if (authorized) { _verifiedPlayers[steamId] = devId ?? "unknown"; reply(Prefixed($"{displayName} ({steamId}) is SentinelID verified (device: {devId})")); } else { reply(Prefixed($"{displayName} ({steamId}) is NOT SentinelID verified")); } }); } } private void CmdListVerified(Action reply) { if (_verifiedPlayers.Count == 0) { reply(Prefixed("No verified players online.")); return; } reply(Prefixed($"Verified players ({_verifiedPlayers.Count}):")); foreach (var kvp in _verifiedPlayers) { var p = FindPlayerBySteamId(kvp.Key); var name = p != null ? p.PlayerName : kvp.Key; reply($" {name} — device: {kvp.Value}"); } if (_pendingPlayers.Count > 0) { reply($"Pending verification: {_pendingPlayers.Count} player(s)"); } } private void CmdPardon(Action reply, string steamId) { if (_failedAttempts.Remove(steamId)) { SaveFailedAttempts(); reply(Prefixed($"Cleared failed attempts for {steamId}. They can rejoin.")); } else { reply(Prefixed($"{steamId} has no failed attempts on record.")); } } private void CmdBanPlayer(Action reply, string target, string banType, string reason) { banType = banType.ToLowerInvariant(); if (banType != "cheat" && banType != "social") { reply(Prefixed("Ban type must be 'cheat' or 'social'.")); return; } var targetPlayer = FindTarget(target); if (targetPlayer == null) { reply(Prefixed($"Player '{target}' not found.")); return; } var steamId = targetPlayer.SteamID.ToString(); var displayName = targetPlayer.PlayerName; _verifiedPlayers.TryGetValue(steamId, out var deviceId); if (deviceId != null && deviceId != "bypass") { PostBan(reply, steamId, displayName, deviceId, banType, reason); } else { CheckPlayerAuth(steamId, (authorized, devId) => { if (string.IsNullOrEmpty(devId)) { reply(Prefixed($"Cannot ban {displayName}: no verified device on record.")); return; } PostBan(reply, steamId, displayName, devId, banType, reason); }); } } private void PostBan(Action reply, string steamId, string displayName, string deviceId, string banType, string reason) { var url = $"{Config.SidecarUrl.TrimEnd('/')}/ban"; var body = JsonSerializer.Serialize(new Dictionary { ["steam_id"] = steamId, ["device_id"] = deviceId, ["ban_type"] = banType, ["scope"] = "game", ["reason_code"] = reason, }); PostJson(url, body, (code, response) => { if (code == 200 && !string.IsNullOrEmpty(response)) { try { using var doc = JsonDocument.Parse(response!); var banId = doc.RootElement.TryGetProperty("ban_id", out var el) ? (el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString()) : "?"; var msg = Config.Messages.BanSuccess .Replace("{ban_id}", banId ?? "?") .Replace("{player}", displayName); reply(Prefixed(msg)); _verifiedPlayers.Remove(steamId); // Kick the banned player KickPlayer(steamId, Config.Messages.BannedKickReason); } catch (Exception ex) { reply(Prefixed(Config.Messages.BanFailed.Replace("{error}", ex.Message))); } } else { reply(Prefixed(Config.Messages.BanFailed .Replace("{error}", ExtractError(code, response)))); } }); } private void CmdUnbanPlayer(Action reply, string banIdStr) { var url = $"{Config.SidecarUrl.TrimEnd('/')}/unban"; var body = JsonSerializer.Serialize(new Dictionary { ["ban_id"] = banIdStr, }); PostJson(url, body, (code, response) => { if (code == 200) { reply(Prefixed(Config.Messages.UnbanSuccess.Replace("{ban_id}", banIdStr))); } else { reply(Prefixed(Config.Messages.UnbanFailed .Replace("{error}", ExtractError(code, response)))); } }); } #endregion #region Console Commands [ConsoleCommand("sentinelid_status", "Dump SentinelID plugin status")] [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] public void CcmdStatus(CCSPlayerController? player, CommandInfo command) { var msg = new StringBuilder(); msg.AppendLine("SentinelID Status:"); msg.AppendLine($" Verified players: {_verifiedPlayers.Count}"); msg.AppendLine($" Pending players: {_pendingPlayers.Count}"); msg.AppendLine($" Sidecar URL: {Config.SidecarUrl}"); msg.AppendLine($" Grace period: {Config.GracePeriodSeconds}s"); msg.AppendLine($" Kick unverified: {Config.KickUnverified}"); msg.AppendLine($" Max failed attempts: {Config.MaxFailedAttempts}"); if (_verifiedPlayers.Count > 0) { msg.AppendLine(); msg.AppendLine(" Verified:"); foreach (var kvp in _verifiedPlayers) { var p = FindPlayerBySteamId(kvp.Key); var name = p != null ? p.PlayerName : kvp.Key; msg.AppendLine($" {name} ({kvp.Key}) — device: {kvp.Value}"); } } command.ReplyToCommand(msg.ToString()); } #endregion #region Sidecar HTTP Communication // Queries the sidecar's GET /check_player/{steam_id}. // Contract: {"steam_id","authorized","device_id","expires_at"} // // The callback is ALWAYS invoked on the main game thread via // Server.NextFrame — callers may safely touch game state and plugin // collections from inside it. private void CheckPlayerAuth(string steamId, Action callback) { var url = $"{Config.SidecarUrl.TrimEnd('/')}/check_player/{steamId}"; Task.Run(async () => { var authorized = false; string? deviceId = null; string? warn = null; try { using var resp = await Http.GetAsync(url).ConfigureAwait(false); var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); if (!resp.IsSuccessStatusCode) { warn = $"Sidecar returned HTTP {(int)resp.StatusCode} for {steamId}"; } else { var result = JsonSerializer.Deserialize(text); if (result == null) { warn = $"Sidecar returned an empty response for {steamId}"; } else { authorized = result.Authorized; deviceId = result.DeviceId; } } } catch (HttpRequestException ex) { warn = $"Cannot reach sidecar at {Config.SidecarUrl} — is it running? ({ex.Message})"; } catch (TaskCanceledException) { warn = $"Sidecar request timed out for {steamId}"; } catch (Exception ex) { warn = $"Failed to parse sidecar response: {ex.Message}"; } // CRITICAL: we are on a thread-pool thread here. Nothing above this // line may touch game state, and nothing below it may run outside // Server.NextFrame. Logging is deferred for the same reason. Server.NextFrame(() => { if (warn != null) Logger.LogWarning("{Warning}", warn); callback(authorized, deviceId); }); }); } // POSTs JSON to the sidecar. Callback receives (statusCode, body) and is // marshalled back onto the main thread. statusCode is 0 if unreachable. private void PostJson(string url, string body, Action callback) { Task.Run(async () => { var code = 0; string? text = null; string? warn = null; try { using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; if (!string.IsNullOrEmpty(Config.AdminApiKey)) { req.Headers.Add("X-Admin-Key", Config.AdminApiKey); } using var resp = await Http.SendAsync(req).ConfigureAwait(false); code = (int)resp.StatusCode; text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); } catch (Exception ex) { warn = $"Sidecar POST {url} failed: {ex.Message}"; } Server.NextFrame(() => { if (warn != null) Logger.LogWarning("{Warning}", warn); callback(code, text); }); }); } private static string ExtractError(int code, string? response) { var error = code == 0 ? "sidecar unreachable" : $"HTTP {code}"; if (string.IsNullOrEmpty(response)) return error; try { using var doc = JsonDocument.Parse(response!); if (doc.RootElement.TryGetProperty("error", out var el)) { return el.ValueKind == JsonValueKind.String ? (el.GetString() ?? error) : el.ToString(); } } catch { /* not JSON — fall through to the status-code message */ } return error; } private class CheckPlayerResponse { [JsonPropertyName("steam_id")] public string? SteamId { get; set; } [JsonPropertyName("authorized")] public bool Authorized { get; set; } [JsonPropertyName("device_id")] public string? DeviceId { get; set; } [JsonPropertyName("expires_at")] public double? ExpiresAt { get; set; } } #endregion #region Helpers private static bool IsRealPlayer(CCSPlayerController? player) { // Compile-verified. IsBot / IsHLTV are the standard CCSPlayerController guards. // SteamID is 0 for bots, which is the belt-and-braces check here. return player != null && player.IsValid && !player.IsBot && !player.IsHLTV && player.SteamID != 0; } private static CCSPlayerController? FindPlayerBySteamId(string steamId) { if (!ulong.TryParse(steamId, out var id)) return null; var player = Utilities.GetPlayerFromSteamId(id); return player != null && player.IsValid ? player : null; } // Resolves a target by Steam64 ID first, then by case-insensitive // substring match on the player name (mirrors Rust's BasePlayer.Find). private static CCSPlayerController? FindTarget(string target) { var byId = FindPlayerBySteamId(target); if (byId != null) return byId; foreach (var player in Utilities.GetPlayers()) { if (!IsRealPlayer(player)) continue; if (player.PlayerName.Contains(target, StringComparison.OrdinalIgnoreCase)) return player; } return null; } private void TellPlayer(string steamId, string message) { var player = FindPlayerBySteamId(steamId); if (player == null) return; player.PrintToChat(ApplyChatColors(Prefixed(message))); } private string Prefixed(string message) => $"{Config.MessagePrefix} {message}"; // Builds a reply channel that stays valid across async sidecar round-trips. // // CommandInfo is a native handle and must not be used once the command // handler has returned, so we never capture it. For players we capture only // the Steam ID (a string) and re-resolve the controller on every reply — // which also means a reply to someone who disconnected mid-request is // silently dropped rather than crashing. Console replies go to stdout with // the colour tokens stripped. private Action MakeReplier(CCSPlayerController? player) { if (player == null) { return msg => Server.PrintToConsole(StripChatColors(msg) + "\n"); } var steamId = player.SteamID.ToString(); return msg => { var p = FindPlayerBySteamId(steamId); if (p != null) p.PrintToChat(ApplyChatColors(msg)); }; } // Rust used Unity rich-text (). CS2 chat colours are single // control characters instead, so configurable strings use {token} // placeholders that we substitute here — keeping every player-facing string // editable in JSON without embedding raw control bytes in the config file. // // Compile-verified against 1.0.305/1.0.368/1.0.371. The ChatColors field set can vary between CounterStrikeSharp // versions. Green/Default/Red/White/Blue/Yellow/Grey are long-standing; // drop any entry below that fails to compile against your CSS build. private static readonly Dictionary ColorTokens = new() { ["{default}"] = ChatColors.Default, ["{white}"] = ChatColors.White, ["{green}"] = ChatColors.Green, ["{red}"] = ChatColors.Red, ["{blue}"] = ChatColors.Blue, ["{yellow}"] = ChatColors.Yellow, ["{grey}"] = ChatColors.Grey, }; private static string ApplyChatColors(string message) { foreach (var kvp in ColorTokens) { message = message.Replace(kvp.Key, kvp.Value.ToString()); } return message; } private static string StripChatColors(string message) { foreach (var key in ColorTokens.Keys) { message = message.Replace(key, string.Empty); } return message; } private static string JoinArgsFrom(CommandInfo command, int startIndex) { var parts = new List(); for (var i = startIndex; i < command.ArgCount; i++) { parts.Add(command.GetArg(i)); } return string.Join(" ", parts); } private void KickPlayer(string steamId, string reason) { var player = FindPlayerBySteamId(steamId); if (player == null || player.UserId == null) { Logger.LogWarning("Cannot kick {SteamId} — no usable controller/userid", steamId); return; } // VERIFY: `kickid ""` is the standard Source kick. // CS2 also accepts a Steam64 ID in place of the userid, but userid is // the better-supported form, so we resolve the controller first. Server.ExecuteCommand($"kickid {player.UserId.Value} \"{SanitizeForConsole(reason)}\""); } // Kick reasons come from config (and, for bans, from admin-typed text) and // are interpolated straight into a server console command. Strip the // characters that could terminate the argument or chain another command. private static string SanitizeForConsole(string value) { var cleaned = new StringBuilder(value.Length); foreach (var c in value) { if (c is '"' or ';' or '\n' or '\r') continue; cleaned.Append(c); } return cleaned.ToString(); } #endregion }