using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Capabilities; using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities.Constants; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.Modules.Utils; using CS2_SimpleAdminApi; using GroupVerifyApi; using Microsoft.Extensions.Logging; using Timer = CounterStrikeSharp.API.Modules.Timers.Timer; namespace GroupVerify; // Marks players as verified when they're in a Steam group, or have a manual override in the // gv_verified table (written by the web panel), and tells other plugins through GroupVerifyApi. // Everything is kept in CS2-SimpleAdmin's database, over its connection string. // // Threads: every field below is only touched on the game thread. HTTP and database work runs in // Task.Run and hands its result back with Server.NextFrame(Async). public class GroupVerify : BasePlugin, IGroupVerifyBackend { public override string ModuleName => "GroupVerify"; public override string ModuleVersion => "0.2.0"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Verifies players by Steam group membership or a manual override"; private const string VerifyCommand = "css_verify"; // Changing settings is for the owner, unless admin_overrides.json says otherwise. private const string SettingsPermission = "@css/root"; // css_verify answers per player up to this many targets, with a summary above it. private const int PerPlayerReplyLimit = 5; private static readonly PluginCapability SimpleAdminCapability = new("simpleadmin:api"); private ICS2_SimpleAdminApi? _simpleAdmin; private Database? _db; private readonly CancellationTokenSource _unloading = new(); private Settings _settings = Settings.Default; // Set once the first database load has applied; nothing is evaluated before that. private bool _ready; // Bumped when a command changes a setting, so a reload that read the table before the change // doesn't put the old value back. private int _settingsVersion; // The group's members. Seeded from gv_verified's 'group' rows at load, so a Steam outage at // startup doesn't unverify everyone, then replaced by each successful fetch. private HashSet _groupMembers = []; private DateTime? _groupFetchedAt; // UTC, the last successful fetch this session private DateTime? _groupAttemptAt; // UTC, the last fetch started private Task? _groupRefresh; // in flight, shared by everyone who asks // SteamID64 -> override end (null: never), from gv_verified. private Dictionary _overrides = []; private DateTime? _dbLoadedAt; // UTC private DateTime? _dbAttemptAt; // UTC private Task? _dbReload; // Slot -> the SteamID64 evaluated there and whether they were verified, to tell other plugins // only about changes. private readonly Dictionary _evaluated = []; private Timer? _groupTimer; private Timer? _dbTimer; // A css_verify target: an online player (Slot set) or a SteamID looked up offline. private sealed record Target(ulong SteamId, string Name, int? Slot); private sealed record Caller(int Slot, ulong SteamId, string Name); public override void Load(bool hotReload) { RegisterEventHandler(OnPlayerConnectFull); RegisterListener(OnClientAuthorized); RegisterListener(OnClientDisconnect); // The bridge lives in the shared GroupVerifyApi assembly, so it's the same object across // reloads of this plugin. CSS keeps every supplier ever registered and always uses the // first, so register it once per server run, whichever load gets here first. GroupVerifyBridge.Instance.Attach(this); if (!ApiRegistered()) Capabilities.RegisterPluginCapability(IGroupVerifyApi.Capability, () => GroupVerifyBridge.Instance); } private static bool ApiRegistered() { try { return IGroupVerifyApi.Capability.Get() != null; } catch (KeyNotFoundException) { return false; } } public override void OnAllPluginsLoaded(bool hotReload) { try { _simpleAdmin = SimpleAdminCapability.Get(); } catch (KeyNotFoundException) { } if (_simpleAdmin == null) { Logger.LogError("CS2-SimpleAdmin's API isn't available, so there's no database: verification is off"); return; } var connectionString = _simpleAdmin.GetConnectionString(); if (string.IsNullOrEmpty(connectionString)) { Logger.LogError("CS2-SimpleAdmin has no MySQL connection string (is it on SQLite?): verification is off"); return; } _db = new Database(connectionString); var db = _db; Task.Run(async () => { try { foreach (var version in await db.MigrateAsync()) Logger.LogInformation("Migration \"{Version}\" applied", version); } catch (Exception ex) { Logger.LogError(ex, "Couldn't create GroupVerify's tables: verification is off"); return; } await Server.NextFrameAsync(() => { ReloadDatabase(); }); }); } public override void Unload(bool hotReload) { GroupVerifyBridge.Instance.Detach(this); _unloading.Cancel(); _groupTimer?.Kill(); _dbTimer?.Kill(); _evaluated.Clear(); } // --- API (game thread) ------------------------------------------------------------------- public bool IsActive => _ready && _settings.Active; public VerifyStatus GetStatus(ulong steamId64) { if (!IsActive) return default; var inGroup = _groupMembers.Contains(steamId64); if (!_overrides.TryGetValue(steamId64, out var ends)) return new VerifyStatus(inGroup, false, null); // Overrides can run out between reloads. var active = ends == null || ends > _settings.Now(); return new VerifyStatus(inGroup, active, active ? ends : null); } // --- Refreshing (game thread) ------------------------------------------------------------ // Rereads gv_settings and gv_verified, then re-evaluates everyone. Concurrent calls share one. private Task ReloadDatabase() { if (_db == null) return Task.CompletedTask; if (_dbReload != null) return _dbReload; var db = _db; var version = _settingsVersion; _dbAttemptAt = DateTime.UtcNow; return _dbReload = Task.Run(async () => { Database.Snapshot? snapshot = null; try { snapshot = await db.LoadAsync(message => Logger.LogWarning("{Message}", message)); } catch (Exception ex) { Logger.LogError("Couldn't load gv_settings/gv_verified, keeping what's loaded: {Error}", ex.Message); } if (_unloading.IsCancellationRequested) return; await Server.NextFrameAsync(() => { _dbReload = null; if (snapshot == null) return; _overrides = snapshot.Overrides; _dbLoadedAt = DateTime.UtcNow; if (snapshot.Expired > 0) Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired); ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings, snapshot.GroupRows); }); }); } // Switches to new settings (or just re-evaluates, if they're the same). savedGroupRows is the // group list saved in the database, used only at the first load. private void ApplySettings(Settings next, HashSet? savedGroupRows = null) { var old = _settings; var first = !_ready; _settings = next; _ready = true; var groupChanged = old.GroupId64 != next.GroupId64; if (first || groupChanged) { // At load, the saved rows stand in for the list until the first fetch below. They're // whichever group was last fetched, so after a change of group they're not used. _groupMembers = first && savedGroupRows != null ? savedGroupRows : []; _groupFetchedAt = null; _groupAttemptAt = null; } if (first || old.Active != next.Active) { if (next.Active) Logger.LogInformation("Verification is on, Steam group {Group}", next.GroupId64); else Logger.LogWarning("Verification is off ({Why}): nobody is verified", next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable"); } if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds) RestartTimers(); EvaluateAll(); if (next.Active && (first || groupChanged || !old.Active)) RefreshGroup(); } private void RestartTimers() { _groupTimer?.Kill(); _dbTimer?.Kill(); _groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT); _dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT); } // Fetches the group's member list and saves it to gv_verified. A failed or empty fetch keeps the // old list. Does nothing while verification is off. Concurrent calls share one. private Task RefreshGroup() { if (_db == null || !IsActive) return Task.CompletedTask; if (_groupRefresh != null) return _groupRefresh; var db = _db; var settings = _settings; var groupId = settings.GroupId64; var cancel = _unloading.Token; _groupAttemptAt = DateTime.UtcNow; return _groupRefresh = Task.Run(async () => { HashSet? members = null; try { members = await SteamGroup.FetchMembersAsync(groupId, cancel); if (members.Count == 0) { Logger.LogWarning("Steam group {Group}'s member list came back empty, keeping the old one", groupId); members = null; } } catch (Exception ex) when (!cancel.IsCancellationRequested) { Logger.LogWarning("Couldn't fetch Steam group {Group}'s member list, keeping the old one: {Error}", groupId, ex.Message); } catch (OperationCanceledException) { return; } if (members != null) { try { await db.SaveGroupAsync(members, settings.Now()); } catch (Exception ex) { // The fetched list is still used; it just won't survive a restart. Logger.LogError("Couldn't save the Steam group list to gv_verified: {Error}", ex.Message); } } if (cancel.IsCancellationRequested) return; await Server.NextFrameAsync(() => { _groupRefresh = null; // Dropped if the group changed or verification was turned off while fetching. if (members == null || groupId != _settings.GroupId64 || !IsActive) return; _groupMembers = members; _groupFetchedAt = DateTime.UtcNow; EvaluateAll(); }); }); } // --- Verification (game thread) ---------------------------------------------------------- private void EvaluateAll() { foreach (var player in Utilities.GetPlayers()) Evaluate(player); } // Tells other plugins (OnVerifiedChanged) when the player's verified status has changed. private void Evaluate(CCSPlayerController? player) { if (!_ready || player is not { IsValid: true, IsBot: false, IsHLTV: false }) return; // Only Steam-authorized IDs count; OnClientAuthorized evaluates again once it's known. var steamId = player.AuthorizedSteamID?.SteamId64; if (steamId == null) return; var status = GetStatus(steamId.Value); var had = _evaluated.TryGetValue(player.Slot, out var last) && last.SteamId == steamId && last.Verified; _evaluated[player.Slot] = (steamId.Value, status.Verified); if (status.Verified == had) return; Logger.LogInformation("{Name} ({SteamId}) is {Status}", player.PlayerName, steamId, status.Verified ? "verified" : "no longer verified"); foreach (var ex in GroupVerifyBridge.Instance.RaiseVerifiedChanged(player, status)) Logger.LogError(ex, "An OnVerifiedChanged handler threw for {Name}", player.PlayerName); } // The player is leaving: other plugins get no event, just forget them. private void ForgetPlayer(int slot) { _evaluated.Remove(slot); } private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { Evaluate(@event.Userid); return HookResult.Continue; } private void OnClientAuthorized(int playerSlot, SteamID steamId) { Evaluate(Utilities.GetPlayerFromSlot(playerSlot)); } private void OnClientDisconnect(int playerSlot) { ForgetPlayer(playerSlot); } // --- css_verify -------------------------------------------------------------------------- [ConsoleCommand(VerifyCommand, "Shows whether players are verified (Steam group or manual override)")] [CommandHelper(usage: "<@all|@t|@ct|@spec|@alive|@dead|@me|#userid|name|steamid>")] public void OnVerify(CCSPlayerController? player, CommandInfo command) { if (player != null && !player.IsValid) return; var arg = command.ArgCount > 1 ? command.ArgString.Trim().Trim('"').Trim() : ""; if (arg.Length == 0) { if (player == null) { command.ReplyToCommand($"Usage: {VerifyCommand} <@all|@t|@ct|@spec|@alive|@dead|#userid|name|steamid>"); return; } arg = "@me"; } if (_db == null || !_ready) { command.ReplyToCommand("[Verify] Not ready: the database hasn't loaded (see the server log)."); return; } var commandString = command.GetCommandString; var targets = ResolveTargets(player, arg, out var error); if (targets == null) { command.ReplyToCommand($"[Verify] {error}"); return; } if (!TryGetCaller(player, command, out var caller)) return; // The server console always may. An admin_overrides.json entry for css_verify is enforced // by CSS before this runs, and then replaces the rank check. if (caller == null || AdminManager.CommandIsOverriden(VerifyCommand)) { Run(caller, commandString, targets); return; } var db = _db; var settings = _settings; var serverId = _simpleAdmin?.GetServerId(); Task.Run(async () => { bool allowed; string? problem; try { (allowed, problem) = await db.CanVerifyAsync(caller!.SteamId, settings.VerifyMinRank, settings.VerifyExtraRanks, serverId, settings.Now()); } catch (Exception ex) { (allowed, problem) = (false, $"rank check failed: {ex.Message}"); } Server.NextFrame(() => { if (problem != null) Logger.LogWarning("css_verify: {Problem}", problem); if (allowed) { Run(caller, commandString, targets); return; } Logger.LogInformation("{Name} ({SteamId}) was refused {Command}", caller!.Name, caller.SteamId, commandString); Reply(caller, $"{ChatColors.Red}You don't have access to this command."); }); }); } // Brings the group list and database up to date if they're older than // min_forced_refresh_seconds, then answers. private void Run(Caller? caller, string commandString, List targets) { LogCommand(caller, commandString); var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds); var now = DateTime.UtcNow; var pending = new List(); if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge) pending.Add(RefreshGroup()); if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge) pending.Add(ReloadDatabase()); if (pending.All(t => t.IsCompleted)) { Answer(caller, targets); return; } Task.WhenAll(pending).ContinueWith(_ => Server.NextFrame(() => Answer(caller, targets))); } private void Answer(Caller? caller, List targets) { if (caller != null && CurrentPlayer(caller) == null) return; int verified = 0, byGroup = 0, byOverrideOnly = 0; var lines = new List(); foreach (var target in targets) { // An online target who left since is looked up by SteamID instead. var online = target.Slot is { } slot && Utilities.GetPlayerFromSlot(slot) is { IsValid: true } p && p.AuthorizedSteamID?.SteamId64 == target.SteamId ? p : null; if (online != null) Evaluate(online); var status = GetStatus(target.SteamId); if (status.Verified) verified++; if (status.InGroup) byGroup++; else if (status.HasOverride) byOverrideOnly++; if (targets.Count <= PerPlayerReplyLimit) { var name = online?.PlayerName ?? target.Name; var where = online == null ? " (offline)" : ""; lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}"); } } if (targets.Count > PerPlayerReplyLimit) { lines.Add($"{targets.Count} players: {ChatColors.Green}{verified} verified{ChatColors.Default} " + $"({byGroup} Steam group, {byOverrideOnly} override only), " + $"{ChatColors.Red}{targets.Count - verified} not verified{ChatColors.Default}."); } lines.Add($"{ChatColors.Grey}{DescribeSources()}"); foreach (var line in lines) Reply(caller, line); } private string Describe(VerifyStatus status) { if (!status.Verified) return $"{ChatColors.Red}not verified{ChatColors.Default}"; var reasons = new List(); if (status.InGroup) reasons.Add("Steam group"); if (status.HasOverride) { reasons.Add(status.OverrideExpires is { } ends ? $"override until {ends:yyyy-MM-dd HH:mm} {_settings.TimeZone.Id} (in {FormatSpan(ends - _settings.Now())})" : "override, no expiry"); } return $"{ChatColors.Green}verified{ChatColors.Default} ({string.Join(" + ", reasons)})"; } private string DescribeSources() { if (!_settings.Active) return DescribeState(); string group; if (_groupFetchedAt is { } fetched) group = $"group list {_groupMembers.Count} members, {FormatSpan(DateTime.UtcNow - fetched)} old"; else group = $"group list {_groupMembers.Count} members from the database (Steam not reached yet)"; var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded"; return $"{group}; {db}."; } private static string FormatSpan(TimeSpan span) { if (span < TimeSpan.Zero) span = TimeSpan.Zero; if (span.TotalMinutes < 1) return $"{(int)span.TotalSeconds}s"; if (span.TotalHours < 1) return $"{(int)span.TotalMinutes}m"; if (span.TotalDays < 1) return $"{(int)span.TotalHours}h {span.Minutes}m"; return $"{(int)span.TotalDays}d {span.Hours}h"; } // On or off, and why. private string DescribeState() { if (_settings.Active) return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}."; if (!_settings.Enabled) { var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}"; return $"{ChatColors.Red}Off{ChatColors.Default}: disabled (css_gv_enable), {group}."; } return $"{ChatColors.Red}Off{ChatColors.Default}: no Steam group set (css_gv_group )."; } // The player who ran a command, or null for the server console. False, after telling them, if // Steam hasn't authorized them yet. private static bool TryGetCaller(CCSPlayerController? player, CommandInfo command, out Caller? caller) { caller = null; if (player == null) return true; var steamId = player.AuthorizedSteamID?.SteamId64; if (steamId == null) { command.ReplyToCommand("[Verify] You aren't authorized with Steam yet, try again in a moment."); return false; } caller = new Caller(player.Slot, steamId.Value, player.PlayerName); return true; } // Logged here and through SimpleAdmin (server log and its Discord log), like its own commands. private void LogCommand(Caller? caller, string commandString) { Logger.LogInformation("{Name} ({SteamId}) ran {Command}", caller?.Name ?? "Console", caller?.SteamId ?? 0, commandString); var player = caller == null ? null : CurrentPlayer(caller); if (caller == null || player != null) _simpleAdmin?.LogCommand(player, commandString); } // The caller if they're still on the server in the same slot, else null. private static CCSPlayerController? CurrentPlayer(Caller caller) { var player = Utilities.GetPlayerFromSlot(caller.Slot); return player is { IsValid: true } && player.AuthorizedSteamID?.SteamId64 == caller.SteamId ? player : null; } // Answers in chat, or on the server console. Not through CommandInfo: by now it's gone. private void Reply(Caller? caller, string message) { if (caller == null) { Server.PrintToConsole($"[Verify] {StripColors(message)}"); return; } CurrentPlayer(caller)?.PrintToChat($" {ChatColors.Green}[Verify]{ChatColors.Default} {message}"); } private static string StripColors(string message) => new(message.Where(c => c > '\u0010' || c == '\n').ToArray()); // --- Settings commands ------------------------------------------------------------------- // Saved to gv_settings and applied at once. @css/root, or what admin_overrides.json says. [ConsoleCommand("css_gv_enable", "Turns GroupVerify on (it also needs a Steam group, css_gv_group)")] [RequiresPermissions(SettingsPermission)] public void OnEnable(CCSPlayerController? player, CommandInfo command) => ChangeSetting(player, command, "enabled", "1", s => s with { Enabled = true }); [ConsoleCommand("css_gv_disable", "Turns GroupVerify off: nobody is verified")] [RequiresPermissions(SettingsPermission)] public void OnDisable(CCSPlayerController? player, CommandInfo command) => ChangeSetting(player, command, "enabled", "0", s => s with { Enabled = false }); [ConsoleCommand("css_gv_group", "Shows or sets the Steam group (its 64-bit ID); 0 turns GroupVerify off")] [CommandHelper(usage: "[groupId64|0]")] [RequiresPermissions(SettingsPermission)] public void OnGroup(CCSPlayerController? player, CommandInfo command) { if (command.ArgCount < 2) { command.ReplyToCommand($"[Verify] {StripColors(DescribeState())} {StripColors(DescribeSources())}"); return; } var text = command.GetArg(1).Trim(); if (!ulong.TryParse(text, out var groupId) || (groupId != 0 && new SteamID(groupId) is var id && (id.AccountType != SteamAccountType.Clan || !id.IsValid()))) { command.ReplyToCommand($"[Verify] \"{text}\" isn't a Steam group ID64 (103582791...). It's on " + "https://steamcommunity.com/groups//memberslistxml/?xml=1. 0 turns verification off."); return; } ChangeSetting(player, command, "group_id64", groupId.ToString(), s => s with { GroupId64 = groupId }); } private void ChangeSetting(CCSPlayerController? player, CommandInfo command, string name, string value, Func change) { if (_db == null || !_ready) { command.ReplyToCommand("[Verify] Not ready: the database hasn't loaded (see the server log)."); return; } if (!TryGetCaller(player, command, out var caller)) return; var commandString = command.GetCommandString; var db = _db; Task.Run(async () => { string? error = null; try { await db.SetSettingAsync(name, value); } catch (Exception ex) { error = ex.Message; } Server.NextFrame(() => { if (error != null) { Logger.LogError("Couldn't save gv_settings {Name}: {Error}", name, error); Reply(caller, $"{ChatColors.Red}Couldn't save the setting: {error}"); return; } LogCommand(caller, commandString); _settingsVersion++; ApplySettings(change(_settings)); Reply(caller, DescribeState()); }); }); } // --- Targets ----------------------------------------------------------------------------- // @all, @t, @ct, @spec, @alive, @dead, @me, #userid, a SteamID (64, STEAM_X:Y:Z or [U:1:N], // online or not), or a name: exact first, then partial, refusing ambiguous matches. private static List? ResolveTargets(CCSPlayerController? caller, string arg, out string? error) { error = null; var players = Utilities.GetPlayers() .Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false, Connected: PlayerConnectedState.Connected }) .Where(p => p.AuthorizedSteamID != null) .ToList(); static List Online(IEnumerable matches) => matches.Select(p => new Target(p.AuthorizedSteamID!.SteamId64, p.PlayerName, p.Slot)).ToList(); List? group = arg.ToLowerInvariant() switch { "@all" => Online(players), "@t" => Online(players.Where(p => p.Team == CsTeam.Terrorist)), "@ct" => Online(players.Where(p => p.Team == CsTeam.CounterTerrorist)), "@spec" => Online(players.Where(p => p.Team == CsTeam.Spectator)), "@alive" => Online(players.Where(p => p.PawnIsAlive)), "@dead" => Online(players.Where(p => !p.PawnIsAlive)), _ => null }; if (group != null) { if (group.Count == 0) error = $"No players match {arg}."; return group.Count == 0 ? null : group; } if (arg.Equals("@me", StringComparison.OrdinalIgnoreCase)) { if (caller?.AuthorizedSteamID == null) { error = "@me only works for players."; return null; } return Online([caller]); } if (arg.StartsWith('#') && int.TryParse(arg[1..], out var userId)) { var byUserId = players.FirstOrDefault(p => p.UserId == userId); if (byUserId == null) error = $"No player with userid {userId}."; return byUserId == null ? null : Online([byUserId]); } if (SteamID.TryParse(arg, out var parsed) && parsed != null && parsed.IsValid()) { var bySteamId = players.FirstOrDefault(p => p.AuthorizedSteamID!.SteamId64 == parsed.SteamId64); return bySteamId != null ? Online([bySteamId]) : [new Target(parsed.SteamId64, "offline player", null)]; } var exact = players.Where(p => p.PlayerName.Equals(arg, StringComparison.OrdinalIgnoreCase)).ToList(); var matches = exact.Count > 0 ? exact : players.Where(p => p.PlayerName.Contains(arg, StringComparison.OrdinalIgnoreCase)).ToList(); switch (matches.Count) { case 0: error = $"No player matches \"{arg}\"."; return null; case 1: return Online(matches); default: var names = string.Join(", ", matches.Take(5).Select(p => $"{p.PlayerName} (#{p.UserId})")); error = $"\"{arg}\" matches {matches.Count} players: {names}. Use #userid."; return null; } } }