GroupVerify 0.2.0: Steam group / override verification with an API for other plugins

Players are verified when they're in the configured Steam group (public memberslistxml,
refetched every 10 minutes, kept on failure) or have an unexpired override row in
gv_verified, written by the web panel. Settings and data live in CS2-SimpleAdmin's MySQL
database (gv_verified, gv_settings, gv_migrations), reached through its API.

- css_gv_group <groupId64|0>, css_gv_enable, css_gv_disable (@css/root): verification runs
  only when enabled with a group set.
- css_verify <target>: why a player is or isn't verified. Admin and above (by immunity from
  SimpleAdmin's groups) plus Guardian, unless admin_overrides.json says otherwise.
- groupverify:api (shared/GroupVerifyApi): IsVerified, GetStatus, IsActive and
  OnVerifiedChanged. The API object lives in the shared assembly, so it survives hot
  reloads of GroupVerify.
This commit is contained in:
Astra 2026-10-02 15:37:47 +01:00
commit a0439dbd41
13 changed files with 1711 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
bin/
obj/
release-out/

215
Database.cs Normal file
View file

@ -0,0 +1,215 @@
using MySqlConnector;
namespace GroupVerify;
// Everything in CS2-SimpleAdmin's MySQL database: our gv_* tables, and its admin and group tables
// (read only). Every method opens its own connection and runs off the game thread.
internal sealed class Database(string connectionString)
{
private const string MigrationPrefix = "GroupVerify.Migrations.";
private const int UpsertBatch = 500;
// What a reload hands back to the game thread.
public sealed record Snapshot(
Settings Settings,
Dictionary<ulong, DateTime?> Overrides,
HashSet<ulong> GroupRows,
int Expired);
private async Task<MySqlConnection> OpenAsync()
{
var connection = new MySqlConnection(connectionString);
await connection.OpenAsync();
return connection;
}
private static MySqlCommand Command(MySqlConnection connection, string sql, params (string Name, object? Value)[] parameters)
{
var command = new MySqlCommand(sql, connection);
foreach (var (name, value) in parameters)
command.Parameters.AddWithValue(name, value ?? DBNull.Value);
return command;
}
// Applies the embedded Migrations/*.sql in name order, each once, the way SimpleAdmin's
// Migration class does with sa_migrations. Returns the versions applied.
public async Task<List<string>> MigrateAsync()
{
await using var connection = await OpenAsync();
await using (var create = Command(connection, """
CREATE TABLE IF NOT EXISTS `gv_migrations` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`version` varchar(128) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
"""))
await create.ExecuteNonQueryAsync();
string last;
await using (var query = Command(connection, "SELECT version FROM gv_migrations ORDER BY id DESC LIMIT 1"))
last = (await query.ExecuteScalarAsync())?.ToString() ?? "";
var assembly = typeof(Database).Assembly;
var applied = new List<string>();
foreach (var resource in assembly.GetManifestResourceNames()
.Where(n => n.StartsWith(MigrationPrefix) && n.EndsWith(".sql"))
.Order(StringComparer.Ordinal))
{
var version = resource[MigrationPrefix.Length..^".sql".Length];
if (string.CompareOrdinal(version, last) <= 0)
continue;
using var reader = new StreamReader(assembly.GetManifestResourceStream(resource)!);
await using (var migrate = Command(connection, await reader.ReadToEndAsync()))
await migrate.ExecuteNonQueryAsync();
await using (var record = Command(connection, "INSERT INTO gv_migrations (version) VALUES (@version)", ("@version", version)))
await record.ExecuteNonQueryAsync();
applied.Add(version);
}
return applied;
}
// Rereads settings and overrides, deletes expired rows, and reads the last saved group list.
public async Task<Snapshot> LoadAsync(Action<string> warn)
{
await using var connection = await OpenAsync();
// Missing settings get their defaults, so new ones show up in the table to be edited.
var insert = "INSERT IGNORE INTO gv_settings (name, value) VALUES " +
string.Join(", ", Settings.Defaults.Select((_, i) => $"(@n{i}, @v{i})"));
var defaults = Settings.Defaults
.SelectMany((d, i) => new (string, object?)[] { ($"@n{i}", d.Name), ($"@v{i}", d.Value) })
.ToArray();
await using (var command = Command(connection, insert, defaults))
await command.ExecuteNonQueryAsync();
var values = new Dictionary<string, string>();
await using (var command = Command(connection, "SELECT name, value FROM gv_settings"))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
values[reader.GetString(0)] = reader.GetString(1);
}
var settings = Settings.Parse(values, warn);
var now = settings.Now();
int expired;
await using (var command = Command(connection,
"DELETE FROM gv_verified WHERE expires_at IS NOT NULL AND expires_at <= @now", ("@now", now)))
expired = await command.ExecuteNonQueryAsync();
// A player with several override rows keeps the latest end; NULL (never) beats any date.
var overrides = new Dictionary<ulong, DateTime?>();
await using (var command = Command(connection, """
SELECT steamid64, expires_at FROM gv_verified
WHERE `override` = 1 AND (expires_at IS NULL OR expires_at > @now)
""", ("@now", now)))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
var steamId = (ulong)reader.GetInt64(0);
DateTime? expires = reader.IsDBNull(1) ? null : reader.GetDateTime(1);
if (!overrides.TryGetValue(steamId, out var current))
overrides[steamId] = expires;
else if (current != null && (expires == null || expires > current))
overrides[steamId] = expires;
}
}
var groupRows = new HashSet<ulong>();
await using (var command = Command(connection, "SELECT steamid64 FROM gv_verified WHERE source = 'group'"))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
groupRows.Add((ulong)reader.GetInt64(0));
}
return new Snapshot(settings, overrides, groupRows, expired);
}
public async Task SetSettingAsync(string name, string value)
{
await using var connection = await OpenAsync();
await using var command = Command(connection, """
INSERT INTO gv_settings (name, value) VALUES (@name, @value)
ON DUPLICATE KEY UPDATE value = @value
""", ("@name", name), ("@value", value));
await command.ExecuteNonQueryAsync();
}
// Saves a fetched group list: upserts a 'group' row for every member (keeping created_at), then
// deletes the 'group' rows of anyone no longer in it. One transaction, so a failure keeps the
// previous list whole.
public async Task SaveGroupAsync(IReadOnlyCollection<ulong> members, DateTime now)
{
await using var connection = await OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
foreach (var batch in members.Chunk(UpsertBatch))
{
var rows = string.Join(", ", batch.Select((_, i) => $"(@s{i}, 'group', 0, NULL, 'steam-group', @now, @now)"));
var parameters = batch.Select((id, i) => ($"@s{i}", (object?)(long)id)).Append(("@now", now)).ToArray();
await using var command = Command(connection, $"""
INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at)
VALUES {rows}
ON DUPLICATE KEY UPDATE updated_at = @now
""", parameters);
command.Transaction = transaction;
await command.ExecuteNonQueryAsync();
}
await using (var command = Command(connection,
"DELETE FROM gv_verified WHERE source = 'group' AND updated_at < @now", ("@now", now)))
{
command.Transaction = transaction;
await command.ExecuteNonQueryAsync();
}
await transaction.CommitAsync();
}
// Whether a player may use css_verify: one of their SimpleAdmin groups has at least minRank's
// immunity, or is one of extraRanks. Groups are read the way our SimpleAdmin fork's
// PermissionManager.GetRank reads ranks: an unexpired sa_admins row for this server whose
// sa_admins_flags entry names an sa_groups row that applies to this server. Problem is set when
// minRank isn't in sa_groups (then only extraRanks pass).
public async Task<(bool Allowed, string? Problem)> CanVerifyAsync(ulong steamId, string minRank, string[] extraRanks,
int? serverId, DateTime now)
{
await using var connection = await OpenAsync();
int? minImmunity = null;
await using (var command = Command(connection, "SELECT immunity FROM sa_groups WHERE name = @name ORDER BY immunity LIMIT 1",
("@name", minRank)))
{
var result = await command.ExecuteScalarAsync();
if (result != null && result != DBNull.Value)
minImmunity = Convert.ToInt32(result);
}
var allowed = false;
await using (var command = Command(connection, """
SELECT DISTINCT g.name, g.immunity
FROM sa_admins a
JOIN sa_admins_flags f ON f.admin_id = a.id
JOIN sa_groups g ON g.name = f.flag
JOIN sa_groups_servers gs ON gs.group_id = g.id AND (gs.server_id IS NULL OR gs.server_id = @serverid)
WHERE a.player_steamid = @steamid AND (a.ends IS NULL OR a.ends > @now)
AND (a.server_id IS NULL OR a.server_id = @serverid)
""", ("@steamid", (long)steamId), ("@serverid", serverId), ("@now", now)))
await using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
var name = reader.GetString(0);
var immunity = reader.GetInt32(1);
if ((minImmunity != null && immunity >= minImmunity) ||
extraRanks.Contains(name, StringComparer.OrdinalIgnoreCase))
allowed = true;
}
}
return (allowed, minImmunity == null ? $"verify_min_rank \"{minRank}\" isn't a group in sa_groups" : null);
}
}

766
GroupVerify.cs Normal file
View file

@ -0,0 +1,766 @@
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<ICS2_SimpleAdminApi> 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<ulong> _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<ulong, DateTime?> _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<int, (ulong SteamId, bool Verified)> _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<EventPlayerConnectFull>(OnPlayerConnectFull);
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
RegisterListener<Listeners.OnClientDisconnect>(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<ulong>? 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<ulong>? 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<Target> targets)
{
LogCommand(caller, commandString);
var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds);
var now = DateTime.UtcNow;
var pending = new List<Task>();
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<Target> targets)
{
if (caller != null && CurrentPlayer(caller) == null)
return;
int verified = 0, byGroup = 0, byOverrideOnly = 0;
var lines = new List<string>();
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<string>();
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 <groupId64>).";
}
// 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 <groupID64> on " +
"https://steamcommunity.com/groups/<name>/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<Settings, Settings> 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<Target>? 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<Target> Online(IEnumerable<CCSPlayerController> matches) =>
matches.Select(p => new Target(p.AuthorizedSteamID!.SteamId64, p.PlayerName, p.Slot)).ToList();
List<Target>? 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;
}
}
}

36
GroupVerify.csproj Normal file
View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GroupVerify</AssemblyName>
<RootNamespace>GroupVerify</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- The same MySQL driver and version as CS2-SimpleAdmin; ships with the plugin -->
<PackageReference Include="MySqlConnector" Version="2.6.0" />
<!-- Compile-time only: CS2-SimpleAdmin installs it in shared/, copied from the server's copy -->
<Reference Include="CS2-SimpleAdminApi">
<HintPath>lib/CS2-SimpleAdminApi.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<!-- GroupVerifyApi is its own project, shipped in shared/ (see release.sh), not in this folder -->
<Compile Remove="GroupVerifyApi/**" />
<None Remove="GroupVerifyApi/**" />
<ProjectReference Include="GroupVerifyApi/GroupVerifyApi.csproj">
<Private>false</Private>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<!-- Embedded, so the tables are created without any extra files on the server -->
<EmbeddedResource Include="Migrations/*.sql" LogicalName="GroupVerify.Migrations.%(Filename)%(Extension)" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- GroupVerify's API for other plugins. Installed in addons/counterstrikesharp/shared/GroupVerifyApi/,
never in a plugin's folder: CSS loads it once from there, so every plugin shares its types. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>GroupVerifyApi</AssemblyName>
<RootNamespace>GroupVerifyApi</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
<PrivateAssets>none</PrivateAssets>
<ExcludeAssets>runtime</ExcludeAssets>
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>

View file

@ -0,0 +1,86 @@
using System.ComponentModel;
using CounterStrikeSharp.API.Core;
namespace GroupVerifyApi;
/// <summary>What GroupVerify itself implements behind the API. Not for other plugins.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public interface IGroupVerifyBackend
{
/// <summary>See <see cref="IGroupVerifyApi.IsActive"/>.</summary>
bool IsActive { get; }
/// <summary>See <see cref="IGroupVerifyApi.GetStatus"/>.</summary>
VerifyStatus GetStatus(ulong steamId64);
}
/// <summary>
/// The one API object, living in this shared assembly so it outlives GroupVerify reloads: CSS keeps
/// the first capability supplier registered forever, and subscribers keep their handlers here.
/// GroupVerify attaches its backend on load and detaches it on unload. Not for other plugins.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class GroupVerifyBridge : IGroupVerifyApi
{
/// <summary>The instance GroupVerify registers as the capability.</summary>
public static GroupVerifyBridge Instance { get; } = new();
private IGroupVerifyBackend? _backend;
private GroupVerifyBridge()
{
}
/// <inheritdoc />
public event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged;
/// <inheritdoc />
public bool IsActive => _backend?.IsActive ?? false;
/// <inheritdoc />
public bool IsVerified(CCSPlayerController player)
{
if (player is not { IsValid: true, IsBot: false, IsHLTV: false })
return false;
var steamId = player.AuthorizedSteamID?.SteamId64;
return steamId != null && IsVerified(steamId.Value);
}
/// <inheritdoc />
public bool IsVerified(ulong steamId64) => GetStatus(steamId64).Verified;
/// <inheritdoc />
public VerifyStatus GetStatus(ulong steamId64) =>
_backend is { IsActive: true } backend ? backend.GetStatus(steamId64) : default;
/// <summary>Called by GroupVerify on load.</summary>
public void Attach(IGroupVerifyBackend backend) => _backend = backend;
/// <summary>Called by GroupVerify on unload; ignored if a newer backend has attached since.</summary>
public void Detach(IGroupVerifyBackend backend)
{
if (ReferenceEquals(_backend, backend))
_backend = null;
}
/// <summary>
/// Called by GroupVerify on the game thread. Each handler runs on its own, so one that throws
/// doesn't stop the others; the exceptions are returned for GroupVerify to log.
/// </summary>
public List<Exception> RaiseVerifiedChanged(CCSPlayerController player, VerifyStatus status)
{
var errors = new List<Exception>();
foreach (var handler in OnVerifiedChanged?.GetInvocationList() ?? [])
{
try
{
((Action<CCSPlayerController, VerifyStatus>)handler)(player, status);
}
catch (Exception ex)
{
errors.Add(ex);
}
}
return errors;
}
}

View file

@ -0,0 +1,56 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Capabilities;
namespace GroupVerifyApi;
/// <summary>
/// Whether players are verified by GroupVerify: in the configured Steam group, or given a manual
/// override on the web panel. Get it in <c>OnAllPluginsLoaded</c> with
/// <c>IGroupVerifyApi.Capability.Get()</c>. Use it on the game thread only.
/// </summary>
/// <remarks>
/// The object stays valid when GroupVerify is hot-reloaded, and event subscriptions stay attached.
/// While GroupVerify is unloaded, disabled or has no group set, <see cref="IsActive"/> is false and
/// nobody is verified.
/// </remarks>
public interface IGroupVerifyApi
{
/// <summary>The capability GroupVerify registers.</summary>
public static readonly PluginCapability<IGroupVerifyApi> Capability = new("groupverify:api");
/// <summary>
/// True when GroupVerify is loaded, enabled, has a Steam group set and has read its database.
/// When false, every check returns not verified.
/// </summary>
bool IsActive { get; }
/// <summary>
/// Whether the player is verified. False for bots and players Steam hasn't authorized yet.
/// </summary>
bool IsVerified(CCSPlayerController player);
/// <summary>Whether a SteamID64 is verified, online or not.</summary>
bool IsVerified(ulong steamId64);
/// <summary>Why a SteamID64 is or isn't verified, online or not.</summary>
VerifyStatus GetStatus(ulong steamId64);
/// <summary>
/// Fired on the game thread when an online player's verified status changes: true when they
/// become verified (including right after connecting), false when they stop being verified
/// (they left the group, their override ran out, or GroupVerify was disabled). Not fired on
/// disconnect. After GroupVerify is hot-reloaded it fires true again for everyone verified,
/// so handlers should be idempotent.
/// </summary>
event Action<CCSPlayerController, VerifyStatus>? OnVerifiedChanged;
}
/// <summary>A player's verification, and why.</summary>
/// <param name="InGroup">In the Steam group's member list.</param>
/// <param name="HasOverride">Has an unexpired manual override.</param>
/// <param name="OverrideExpires">When the override ends, in GroupVerify's database time zone; null if it never does (or there's none).</param>
public readonly record struct VerifyStatus(bool InGroup, bool HasOverride, DateTime? OverrideExpires)
{
/// <summary>In the group, or has an override.</summary>
public bool Verified => InGroup || HasOverride;
}

View file

@ -0,0 +1,29 @@
-- GroupVerify's tables, in CS2-SimpleAdmin's database. Applied once at load, tracked in
-- gv_migrations (like SimpleAdmin's sa_migrations, kept apart so neither confuses the other).
-- Times are wall-clock values in gv_settings' timezone, which should match SimpleAdmin's Timezone.
-- One row per player and source. A player counts as verified if they're in the Steam group's
-- member list, or have a row with `override` = 1 whose expires_at is NULL (never) or still ahead.
-- source 'group': written by the plugin for everyone in the group list, `override` 0. Removed
-- when they leave the group. The live list decides, not these rows.
-- source 'manual': written by the web panel, `override` 1, expires_at NULL or a date.
-- updated_at is when the plugin last saw a group row in the list.
CREATE TABLE IF NOT EXISTS `gv_verified` (
`steamid64` bigint(20) NOT NULL,
`source` enum('group','manual') NOT NULL,
`override` tinyint(1) NOT NULL DEFAULT 0,
`expires_at` datetime NULL DEFAULT NULL,
`added_by` varchar(128) NOT NULL DEFAULT '',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`steamid64`, `source`),
KEY `idx_gv_verified_override` (`override`, `expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- Plugin settings, name -> value. The plugin adds any missing name with its default at load, and
-- rereads them on every database reload, so edits apply without a restart.
CREATE TABLE IF NOT EXISTS `gv_settings` (
`name` varchar(64) NOT NULL,
`value` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

227
README.md Normal file
View file

@ -0,0 +1,227 @@
# GroupVerify
Marks players as **verified** when they're in our Steam group, or when staff have given them a
manual override on the web panel. It gives out nothing itself: other plugins ask it through its
API (`GroupVerifyApi`, see [For other plugins](#for-other-plugins)) and hand out their own perks.
Everything lives in CS2-SimpleAdmin's MySQL database, reached through SimpleAdmin's API
(`GetConnectionString()`), so there's no config file. Needs CS2-SimpleAdmin on MySQL; on SQLite,
or without SimpleAdmin, it logs an error and verifies nobody.
## Turning it on
Verification runs only when it's **enabled and has a Steam group set**. Otherwise nobody is
verified, including players with an override, and other plugins see `IsActive == false`. A new
install is enabled with no group, so it's off until you set one:
```
css_gv_group 103582791429521408 set the group (its 64-bit ID) and fetch its members now
css_gv_group 0 clear the group: verification is off
css_gv_group show whether it's on, the group, and how old the list is
css_gv_disable turn it off, keeping the group
css_gv_enable turn it back on
```
These need `@css/root` (an `admin_overrides.json` entry for a command changes that) and work from
the server console and RCON. Changes are saved to `gv_settings` and take effect at once: turning
it off tells other plugins every verified player stopped being verified, and turning it on (or
changing the group) fetches the member list straight away.
The group ID is the number in `<groupID64>` at
`https://steamcommunity.com/groups/<name>/memberslistxml/?xml=1`. `css_gv_group` refuses anything
that isn't a Steam group ID.
## How it works
- **Steam group.** The group's public member list
(`https://steamcommunity.com/gid/<group_id64>/memberslistxml/?xml=1`, every `nextPageLink` page)
is fetched off the game thread every `refresh_minutes`. A failed or empty fetch keeps the old
list. Each good fetch is saved to `gv_verified` as `source = 'group'` rows, and the rows of
anyone who left are deleted; at startup those rows stand in for the list until Steam answers.
- **Overrides.** `gv_verified` is reread every `db_reload_seconds`, so web panel changes show up
without a restart. Expired rows are ignored, and deleted on each reload.
- **Verified** = in the group list, **or** an unexpired row with `override` = 1.
- Online players are checked when they connect (once Steam has authorized them), after each group
fetch, after each database reload and after each settings command. Other plugins get
`OnVerifiedChanged` only when a player's status changes. Their state is dropped when they
disconnect.
## `css_verify` (`!verify`)
`css_verify <target>`: `@all`, `@t`, `@ct`, `@spec`, `@alive`, `@dead`, `@me`, `#userid`, a name
(an exact match first, then a partial one; ambiguous names are refused), or a SteamID
(SteamID64, `STEAM_X:Y:Z` or `[U:1:N]`), which works for offline players too. No target means
`@me`. Works from the server console.
If the group list or the database was last loaded more than `min_forced_refresh_seconds` ago,
both are brought up to date first. Then the targets are re-evaluated, and the answer goes to chat:
one line per player for up to 5 targets (verified by Steam group, override with its expiry, or
not verified), a summary for more, and how old the group list and overrides are. Every use is
logged through SimpleAdmin's `LogCommand` (server log and its Discord log) and the plugin's log.
**Who can use it:** Admin and above, plus Guardian. A player qualifies when one of their
SimpleAdmin groups has at least the immunity of the `verify_min_rank` group (`#rank/admin`), or is
listed in `verify_extra_ranks` (`#rank/guardian`). Groups are read from the database the same way
our SimpleAdmin fork works out a rank (`PermissionManager.GetRank`): an unexpired `sa_admins` row for
this server, whose `sa_admins_flags` entry names an `sa_groups` group that applies to this server.
So promoting someone on the web panel takes effect at once, and a new rank above Admin passes
by its immunity without changes here.
An `admin_overrides.json` entry for `css_verify` replaces that check: CSS enforces the override's
flags before the command runs, and the rank check is skipped.
## Database
Created at load from the embedded `Migrations/*.sql`, tracked in `gv_migrations` (kept apart from
SimpleAdmin's `sa_migrations`). Times are wall-clock values in the `timezone` setting, which must
match SimpleAdmin's `Timezone` (as the web panel already assumes for SimpleAdmin's own tables).
### `gv_verified`
| column | type | notes |
|--------------|----------------------------|---------------------------------------------------------------|
| `steamid64` | `bigint` | primary key, with `source` |
| `source` | `enum('group','manual')` | `group`: written by the plugin. `manual`: the web panel's. |
| `override` | `tinyint(1)` | 1 verifies the player whether or not they're in the group |
| `expires_at` | `datetime NULL` | NULL = never. Expired rows are ignored, then deleted. |
| `added_by` | `varchar(128)` | free text: who added it (`steam-group` for group rows) |
| `created_at` | `datetime` | first added |
| `updated_at` | `datetime` | group rows: last seen in the list |
One row per player and source, so a manual override and a group row never overwrite each other.
`override` is backticked in the plugin's SQL; do the same, it's close to a reserved word.
The web panel only writes `manual` rows. To add or change an override:
```sql
INSERT INTO gv_verified (steamid64, source, `override`, expires_at, added_by, created_at, updated_at)
VALUES (76561198000000000, 'manual', 1, '2026-12-31 23:59:00', 'astra (76561198...)', NOW(), NOW())
ON DUPLICATE KEY UPDATE `override` = 1, expires_at = VALUES(expires_at),
added_by = VALUES(added_by), updated_at = VALUES(updated_at);
```
`NOW()` is only right if MySQL's time zone matches `timezone`; otherwise pass the time from the
panel. To remove one: `DELETE FROM gv_verified WHERE steamid64 = ? AND source = 'manual'`. Leave
`group` rows alone: the next fetch rewrites them, and they never verify anyone on their own.
### `gv_settings`
`name` → `value` (both strings). Missing names are added with their defaults at each reload.
`enabled` and `group_id64` are normally set with the commands above. Anything edited by hand
applies at the next reload (`db_reload_seconds`), timers included.
| name | default | meaning |
|------------------------------|------------------|-----------------------------------------------------------------|
| `enabled` | `1` | `0` turns verification off (`css_gv_enable`/`css_gv_disable`) |
| `group_id64` | `0` | the Steam group's 64-bit ID; `0` turns it off (`css_gv_group`) |
| `refresh_minutes` | `10` | how often the member list is fetched |
| `min_forced_refresh_seconds` | `60` | `css_verify` refreshes only data older than this |
| `db_reload_seconds` | `60` | how often `gv_settings` and `gv_verified` are reread |
| `timezone` | `UTC` | time zone of the `datetime` columns; match SimpleAdmin's |
| `verify_min_rank` | `#rank/admin` | `css_verify` needs this group's immunity or higher |
| `verify_extra_ranks` | `#rank/guardian` | comma-separated groups that may also use `css_verify` |
## For other plugins
GroupVerify registers the capability `groupverify:api` (`IGroupVerifyApi`), from
`GroupVerifyApi.dll`, which is installed in `addons/counterstrikesharp/shared/GroupVerifyApi/`.
Use it on the game thread:
- `IsVerified(player)` / `IsVerified(steamId64)`: verified right now. False for bots, players
Steam hasn't authorized yet, and everyone while `IsActive` is false.
- `GetStatus(steamId64)`: a `VerifyStatus` with `Verified`, `InGroup`, `HasOverride` and
`OverrideExpires` (null when it never ends).
- `IsActive`: GroupVerify is loaded, enabled, has a group and has read its database.
- `OnVerifiedChanged(player, status)`: fired when an online player becomes verified (including
just after connecting) or stops being verified (left the group, override ran out, or
verification was turned off). Not fired on disconnect.
In the other plugin, copy `GroupVerifyApi.dll` from the server's `shared/GroupVerifyApi/` (or
`GroupVerifyApi/bin/Release/net10.0/` here) to its `lib/`, and reference it compile-only. It must
not end up in that plugin's own folder, or that plugin gets its own copy of the types and the
capability won't match:
```xml
<Reference Include="GroupVerifyApi">
<HintPath>lib/GroupVerifyApi.dll</HintPath>
<Private>false</Private>
</Reference>
```
```csharp
using GroupVerifyApi;
private IGroupVerifyApi? _verify;
public override void OnAllPluginsLoaded(bool hotReload)
{
try
{
_verify = IGroupVerifyApi.Capability.Get();
}
catch (KeyNotFoundException)
{
}
if (_verify == null)
{
Logger.LogError("GroupVerify isn't installed, so nobody gets verified perks");
return;
}
_verify.OnVerifiedChanged += OnVerifiedChanged;
}
public override void Unload(bool hotReload)
{
if (_verify != null)
_verify.OnVerifiedChanged -= OnVerifiedChanged;
}
// React to changes...
private void OnVerifiedChanged(CCSPlayerController player, VerifyStatus status)
{
if (status.Verified)
GiveTag(player);
else
RemoveTag(player);
}
// ...or just ask whenever it matters.
private HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info)
{
if (@event.Userid is { IsValid: true } player && _verify?.IsVerified(player) == true)
GiveVerifiedLoadout(player);
return HookResult.Continue;
}
```
Event handlers run one at a time, and one that throws is logged by GroupVerify without stopping
the others. Make them idempotent: after GroupVerify is hot-reloaded, it fires `true` again for
everyone verified.
**Hot reload.** Unlike SimpleAdmin's, this API survives reloads of GroupVerify. The object handed
out is `GroupVerifyBridge.Instance`, which lives in the shared assembly, not in the plugin. CSS
keeps the first capability supplier registered for the whole server run, and GroupVerify registers
only once, so `Capability.Get()` keeps returning that same object. GroupVerify attaches to it on
load and detaches on unload, and event subscriptions stay on it. While GroupVerify is unloaded,
`IsActive` is false and nobody is verified. Changing `GroupVerifyApi` itself needs a server
restart, and its plugins rebuilt if the interface changed.
`IGroupVerifyBackend`, and `GroupVerifyBridge`'s `Attach`, `Detach` and `RaiseVerifiedChanged`,
are for GroupVerify only.
## Building and releasing
`cd plugins/GroupVerify && ../../build.sh` builds into `compiled/GroupVerify/`.
`./release.sh v<ModuleVersion>` (needs `FORGEJO_TOKEN`) publishes `GroupVerify-<tag>.tar.gz`, laid
out like `game/csgo/`, to the `cs2/GroupVerify` repo on git.zio.sh. It bundles `MySqlConnector.dll`
(the same version as SimpleAdmin's) and its `Microsoft.Extensions.*` abstractions in the plugin
folder, and `GroupVerifyApi` in `shared/GroupVerifyApi/`.
`build.sh` doesn't put `GroupVerifyApi.dll` in `compiled/GroupVerify/` (it doesn't belong in the
plugin folder). Its build is in `GroupVerifyApi/bin/Release/net10.0/`; copy it to
`shared/GroupVerifyApi/` by hand when deploying without a release.
`lib/CS2-SimpleAdminApi.dll` is a compile-only copy of the server's `shared/CS2-SimpleAdminApi`.
Never hot-reload CS2-SimpleAdmin while this runs: CSS keeps handing out the old instance's API
until the server restarts. Hot-reloading GroupVerify is fine (see Hot reload above).

81
Settings.cs Normal file
View file

@ -0,0 +1,81 @@
namespace GroupVerify;
// The plugin's settings, from gv_settings. Immutable: a reload swaps in a new one.
internal sealed record Settings(
bool Enabled,
ulong GroupId64,
int RefreshMinutes,
int MinForcedRefreshSeconds,
int DbReloadSeconds,
TimeZoneInfo TimeZone,
string VerifyMinRank,
string[] VerifyExtraRanks)
{
// Written to gv_settings when missing. The rank defaults are our real SimpleAdmin groups (see
// the fork's Helper.StaffRanks and the web panel's site config). Only the names are set here:
// "Admin or higher" is decided by immunity read from sa_groups.
public static readonly (string Name, string Value)[] Defaults =
[
("enabled", "1"), // 0 turns verification off (css_gv_enable/css_gv_disable)
("group_id64", "0"), // the Steam group's 64-bit ID (103582791...); 0 turns verification off (css_gv_group)
("refresh_minutes", "10"), // how often the group's member list is fetched
("min_forced_refresh_seconds", "60"), // css_verify refetches/reloads only when older than this
("db_reload_seconds", "60"), // how often gv_verified and gv_settings are reread
("timezone", "UTC"), // must match CS2-SimpleAdmin's Timezone
("verify_min_rank", "#rank/admin"), // css_verify: this group's immunity or higher
("verify_extra_ranks", "#rank/guardian") // css_verify: these groups too, comma-separated
];
public static readonly Settings Default = Parse(new Dictionary<string, string>(), _ => { });
public static Settings Parse(IReadOnlyDictionary<string, string> values, Action<string> warn)
{
string Get(string name) =>
values.TryGetValue(name, out var value) ? value.Trim() : Defaults.First(d => d.Name == name).Value;
int GetInt(string name, int min)
{
if (int.TryParse(Get(name), out var value) && value >= min)
return value;
var fallback = int.Parse(Defaults.First(d => d.Name == name).Value);
warn($"gv_settings {name} must be a number of at least {min}; using {fallback}");
return fallback;
}
ulong groupId = 0;
var groupText = Get("group_id64");
if (groupText.Length > 0 && !ulong.TryParse(groupText, out groupId))
warn($"gv_settings group_id64 \"{groupText}\" isn't a 64-bit group ID; verification is off");
var timeZone = TimeZoneInfo.Utc;
var zoneName = Get("timezone");
try
{
timeZone = TimeZoneInfo.FindSystemTimeZoneById(zoneName);
}
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
{
warn($"gv_settings timezone \"{zoneName}\" isn't a known time zone; using UTC");
}
return new Settings(
Get("enabled") != "0",
groupId,
GetInt("refresh_minutes", 1),
GetInt("min_forced_refresh_seconds", 0),
GetInt("db_reload_seconds", 10),
timeZone,
Get("verify_min_rank"),
Get("verify_extra_ranks").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
}
// Verification only runs when enabled with a group set; otherwise nobody is verified.
public bool Active => Enabled && GroupId64 != 0;
// Now as a wall-clock time in the database's time zone, to the second (MySQL rounds fractions).
public DateTime Now()
{
var now = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, TimeZone);
return DateTime.SpecifyKind(now.AddTicks(-(now.Ticks % TimeSpan.TicksPerSecond)), DateTimeKind.Unspecified);
}
}

60
SteamGroup.cs Normal file
View file

@ -0,0 +1,60 @@
using System.Xml.Linq;
namespace GroupVerify;
// Reads a Steam group's public member list (memberslistxml), runs off the game thread.
internal static class SteamGroup
{
// 1000 members a page, so this allows groups of up to 100,000.
private const int MaxPages = 100;
// A pause between pages, to stay clear of Steam's rate limit.
private static readonly TimeSpan PageDelay = TimeSpan.FromMilliseconds(500);
private static readonly HttpClient Http = CreateClient();
private static HttpClient CreateClient()
{
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("GroupVerify/1.0 (CounterStrikeSharp)");
return client;
}
// Every member's SteamID64, following nextPageLink. Throws if any page fails, so a partial list
// never replaces a whole one.
public static async Task<HashSet<ulong>> FetchMembersAsync(ulong groupId64, CancellationToken cancel)
{
var members = new HashSet<ulong>();
Uri? url = new($"https://steamcommunity.com/gid/{groupId64}/memberslistxml/?xml=1");
for (var page = 1; url != null; page++)
{
if (page > MaxPages)
throw new InvalidDataException($"the member list has more than {MaxPages} pages");
if (page > 1)
await Task.Delay(PageDelay, cancel);
var root = XDocument.Parse(await Http.GetStringAsync(url, cancel)).Root;
if (root?.Name.LocalName != "memberList")
throw new InvalidDataException($"page {page} isn't a member list (<{root?.Name.LocalName}>)");
foreach (var id in root.Element("members")?.Elements("steamID64") ?? [])
{
if (ulong.TryParse(id.Value.Trim(), out var steamId))
members.Add(steamId);
}
url = NextPage(root.Element("nextPageLink")?.Value);
}
return members;
}
// Only ever follows links back to steamcommunity.com, over https.
private static Uri? NextPage(string? link)
{
if (string.IsNullOrWhiteSpace(link) || !Uri.TryCreate(link.Trim(), UriKind.Absolute, out var uri))
return null;
if (uri.Host != "steamcommunity.com" || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
throw new InvalidDataException($"unexpected nextPageLink {uri}");
return new UriBuilder(uri) { Scheme = Uri.UriSchemeHttps, Port = -1 }.Uri;
}
}

BIN
lib/CS2-SimpleAdminApi.dll Normal file

Binary file not shown.

132
release.sh Executable file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Build GroupVerify, package it as one tarball and publish it as a Forgejo release.
#
# ./release.sh <tag> e.g. ./release.sh v0.1.0
#
# Commit first: the plugin is rebuilt here from the committed source, so the release always matches
# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with
# the same tag replaces that release's attachment.
#
# Asset: GroupVerify-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
# tar -xzf GroupVerify-<tag>.tar.gz -C /srv/cs2/game/csgo
#
# addons/counterstrikesharp/plugins/GroupVerify/ GroupVerify.dll, .pdb, .deps.json, MySqlConnector.dll
# and its Microsoft.Extensions.* abstractions
# addons/counterstrikesharp/shared/GroupVerifyApi/ GroupVerifyApi.dll, .pdb, .xml: the API other
# plugins compile against (never in a plugin folder)
#
# No configs/: GroupVerify has no config file. Its settings and data are in CS2-SimpleAdmin's
# database (gv_settings, gv_verified), which the plugin creates itself, so an upgrade never touches them.
set -euo pipefail
FORGEJO_URL="https://git.zio.sh"
OWNER="cs2"
REPO="GroupVerify"
# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository.
# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token.
FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}"
# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj
# (ExcludeAssets=runtime); the server already provides it.
PLUGIN_FILES=(
GroupVerify.dll
GroupVerify.pdb
GroupVerify.deps.json
MySqlConnector.dll
Microsoft.Extensions.DependencyInjection.Abstractions.dll
Microsoft.Extensions.Logging.Abstractions.dll
)
TAG="${1:-}"
if [[ -z "$TAG" ]]; then
echo "usage: $0 <tag> (e.g. v0.1.0)" >&2
exit 1
fi
if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then
echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2
exit 1
fi
for tool in podman jq curl git; do
command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; }
done
cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")"
if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then
echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2
exit 1
fi
# The tag should name the version the plugin reports in `css_plugins list`.
version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' GroupVerify.cs)"
if [[ "${TAG#v}" != "$version" ]]; then
echo "Tag $TAG doesn't match ModuleVersion $version in GroupVerify.cs." >&2
exit 1
fi
# --- Build -----------------------------------------------------------------------
OUT="release-out"
PUBLISH="$OUT/publish"
rm -rf bin obj GroupVerifyApi/bin GroupVerifyApi/obj "$OUT"
podman run --rm -v "$(pwd)":/src:Z -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH"
# The project reference already built GroupVerifyApi.
API_BUILD="GroupVerifyApi/bin/Release/net10.0"
# --- Stage (layout of game/csgo/) and package ----------------------------------------
STAGE="$OUT/stage"
PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/GroupVerify"
API_DIR="$STAGE/addons/counterstrikesharp/shared/GroupVerifyApi"
mkdir -p "$PLUGIN_DIR" "$API_DIR"
for file in "${PLUGIN_FILES[@]}"; do
cp "$PUBLISH/$file" "$PLUGIN_DIR/"
done
for file in GroupVerifyApi.dll GroupVerifyApi.pdb GroupVerifyApi.xml; do
cp "$API_BUILD/$file" "$API_DIR/"
done
if [[ -e "$PLUGIN_DIR/GroupVerifyApi.dll" ]]; then
echo "GroupVerifyApi.dll ended up in the plugin folder; it must only be in shared/." >&2
exit 1
fi
TARBALL="GroupVerify-${TAG}.tar.gz"
tar -czf "$OUT/$TARBALL" -C "$STAGE" addons
rm -rf "$STAGE" "$PUBLISH"
echo "Packaged:"
tar -tzf "$OUT/$TARBALL"
# --- Tag ---------------------------------------------------------------------------
if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
git tag -a "$TAG" -m "$TAG"
fi
git push origin "refs/tags/$TAG"
# --- Release -----------------------------------------------------------------------
API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO"
AUTH=(-H "Authorization: token $FORGEJO_TOKEN")
release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)"
if [[ -z "$release_json" ]]; then
body="$(git log -1 --format=%B "$TAG")"
release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' |
curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")"
echo "Created release $TAG"
else
echo "Release $TAG already exists, replacing its attachment"
fi
release_id="$(jq -r '.id' <<<"$release_json")"
existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")"
if [[ -n "$existing_id" ]]; then
curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null
fi
curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \
"$API/releases/$release_id/assets?name=$TARBALL" >/dev/null
echo "Uploaded $TARBALL"
echo
echo "Download URL:"
echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"