From b60f508a7b7e8106474b2042c55ebf5ae80c6e9b Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 27 Sep 2026 19:08:59 +0100 Subject: [PATCH] AdminStealth 0.2.0: keep admins hidden across reconnects and map changes, block joining teams while hidden --- AdminStealth.cs | 277 +++++++++++++++++++++++++++++++++++++++++++++--- README.md | 22 +++- 2 files changed, 282 insertions(+), 17 deletions(-) diff --git a/AdminStealth.cs b/AdminStealth.cs index eb119e3..3d4679e 100644 --- a/AdminStealth.cs +++ b/AdminStealth.cs @@ -1,9 +1,13 @@ +using System.Text.Json; 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.Timers; +using CounterStrikeSharp.API.Modules.Utils; using CounterStrikeSharp.API.ValveConstants.Protobuf; using CS2_SimpleAdminApi; using Microsoft.Extensions.Logging; @@ -12,21 +16,57 @@ namespace AdminStealth; // Companion to CS2-SimpleAdmin's css_hide. When an admin hides, everyone else sees them leave the // server, and nothing the hide does afterwards (the suicide, the team changes, their real -// disconnect later) is announced. Also blocks the status command for players, which would +// disconnect later) is announced. Hidden admins stay hidden across reconnects and map changes, and +// can't join a team until they unhide. Also blocks the status command for players, which would // otherwise still list a hidden admin. public class AdminStealth : BasePlugin { public override string ModuleName => "AdminStealth"; - public override string ModuleVersion => "0.1.0"; + public override string ModuleVersion => "0.2.0"; public override string ModuleAuthor => "astra"; - public override string ModuleDescription => "Makes CS2-SimpleAdmin's css_hide look like leaving the server"; + public override string ModuleDescription => "Makes CS2-SimpleAdmin's css_hide look like leaving, and keeps it"; + + // css_hide's own permission. Restoring waits for it, since SimpleAdmin loads admins after connect. + private const string HidePermission = "@css/kick"; + private const float RestoreDelay = 2f; + private const int RestoreAttempts = 5; private static readonly PluginCapability SimpleAdminCapability = new("simpleadmin:api"); private ICS2_SimpleAdminApi? _simpleAdmin; + // SteamID64s of admins who hid and haven't unhidden, kept in hidden.json. + private readonly HashSet _hidden = []; + private string[] _hideAliases = ["css_hide", "css_stealth"]; + + // Slot -> tick a hide alias was typed, to tell an admin unhiding themselves from SimpleAdmin + // unhiding them because they joined a team. + private readonly Dictionary _hideTyped = []; + // Slots with a restore timer running, and slots whose hide was run by us rather than typed. + private readonly HashSet _restoreScheduled = []; + private readonly HashSet _restoring = []; + + private static string ConfigDirectory => Path.Combine(Server.GameDirectory, "csgo", "addons", "counterstrikesharp", + "configs", "plugins"); + + private static string HiddenPath => Path.Combine(ConfigDirectory, "AdminStealth", "hidden.json"); + public override void Load(bool hotReload) { + LoadHidden(); + _hideAliases = ReadHideAliases(); + foreach (var alias in _hideAliases) + AddCommandListener(alias, OnHideTyped); + // Joining any team, spectators included, would make SimpleAdmin unhide them. + AddCommandListener("jointeam", OnJoinTeam); + AddCommandListener("spectate", OnJoinTeam); AddCommandListener("status", OnStatus); + + RegisterListener(_ => + { + _hideTyped.Clear(); + _restoreScheduled.Clear(); + _restoring.Clear(); + }); } public override void OnAllPluginsLoaded(bool hotReload) @@ -41,10 +81,19 @@ public class AdminStealth : BasePlugin if (_simpleAdmin == null) { - Logger.LogError("CS2-SimpleAdmin's API isn't available, so hiding won't look like leaving"); + Logger.LogError("CS2-SimpleAdmin's API isn't available, so hiding won't look like leaving or last"); return; } _simpleAdmin.OnAdminToggleSilent += OnAdminToggleSilent; + + if (hotReload) + { + foreach (var player in Utilities.GetPlayers()) + { + if (player is { IsValid: true, IsBot: false } && _hidden.Contains(player.SteamID)) + ScheduleRestore(player, RestoreDelay); + } + } } public override void Unload(bool hotReload) @@ -59,17 +108,67 @@ public class AdminStealth : BasePlugin return player == null ? HookResult.Continue : HookResult.Stop; } + private HookResult OnHideTyped(CCSPlayerController? player, CommandInfo command) + { + if (player is { IsValid: true } && !_restoring.Contains(player.Slot)) + _hideTyped[player.Slot] = Server.TickCount; + return HookResult.Continue; + } + + private HookResult OnJoinTeam(CCSPlayerController? player, CommandInfo command) + { + if (!IsHidden(player)) + return HookResult.Continue; + + player!.PrintToChat($" You're hidden, so you can't join a team. Type {ChatAlias()} to show yourself."); + return HookResult.Stop; + } + private void OnAdminToggleSilent(int slot, bool hidden) { - if (!hidden) - return; + // The typed command's listener may run before or after SimpleAdmin's handler, which fires + // this, so decide on the next frame, once both have run. + var tick = Server.TickCount; + Server.NextFrame(() => ResolveToggle(slot, hidden, tick)); + } + + private void ResolveToggle(int slot, bool hidden, int tick) + { + var typed = _hideTyped.Remove(slot, out var typedTick) && typedTick == tick; var admin = Utilities.GetPlayerFromSlot(slot); if (admin is not { IsValid: true, IsBot: false }) return; - // A fake player_disconnect, sent to each client rather than fired on the server, so no - // plugin (SimpleAdmin included) treats the admin as really gone. + if (hidden) + { + if (_restoring.Remove(slot)) + { + admin.PrintToChat($" You're still hidden. Type {ChatAlias()} to show yourself."); + return; + } + if (_hidden.Add(admin.SteamID)) + SaveHidden(); + SendFakeLeave(admin); + return; + } + + if (typed) + { + if (_hidden.Remove(admin.SteamID)) + SaveHidden(); + } + else if (_hidden.Contains(admin.SteamID)) + { + // SimpleAdmin unhides anyone who joins a team, e.g. when the game auto-assigns them. + ScheduleRestore(admin, 0.1f); + } + } + + // A fake player_disconnect, sent to each client rather than fired on the server, so no plugin + // (SimpleAdmin included) treats the admin as really gone. + private static void SendFakeLeave(CCSPlayerController admin) + { var leave = new EventPlayerDisconnect(true) { Userid = admin, @@ -82,17 +181,107 @@ public class AdminStealth : BasePlugin }; foreach (var player in Utilities.GetPlayers()) { - if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.Slot != slot) + if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.Slot != admin.Slot) leave.FireEventToClient(player); } leave.Free(); } + private void ScheduleRestore(CCSPlayerController player, float delay) + { + var slot = player.Slot; + var steamId = player.SteamID; + if (!_restoreScheduled.Add(slot)) + return; + AddTimer(delay, () => TryRestore(slot, steamId, 1), TimerFlags.STOP_ON_MAPCHANGE); + } + + // Hides the admin again with SimpleAdmin's own command, retrying until their permissions load. + private void TryRestore(int slot, ulong steamId, int attempt) + { + var player = Utilities.GetPlayerFromSlot(slot); + if (player is not { IsValid: true, IsBot: false } || player.SteamID != steamId || + !_hidden.Contains(steamId) || _simpleAdmin == null) + { + _restoreScheduled.Remove(slot); + _restoring.Remove(slot); + return; + } + + if (_simpleAdmin.IsAdminSilent(player)) + { + _restoreScheduled.Remove(slot); + _restoring.Remove(slot); + MoveOffTeams(player); + return; + } + + if (attempt > RestoreAttempts) + { + _restoreScheduled.Remove(slot); + _restoring.Remove(slot); + _hidden.Remove(steamId); + SaveHidden(); + player.PrintToChat(" You couldn't be hidden again, so you're visible now."); + Logger.LogWarning("Couldn't hide {Name} ({SteamId}) again; forgot they were hidden", player.PlayerName, steamId); + return; + } + + if (AdminManager.PlayerHasPermissions(player, HidePermission)) + { + _restoring.Add(slot); + player.ExecuteClientCommandFromServer(_hideAliases[0]); + } + AddTimer(RestoreDelay, () => TryRestore(slot, steamId, attempt + 1), TimerFlags.STOP_ON_MAPCHANGE); + } + + // For an admin SimpleAdmin still counts as hidden who ended up on a team anyway. + private static void MoveOffTeams(CCSPlayerController player) + { + if (player.TeamNum <= (byte)CsTeam.Spectator) + return; + + if (player.PlayerPawn.Value is { IsValid: true, LifeState: (byte)LifeState_t.LIFE_ALIVE } pawn) + pawn.CommitSuicide(true, false); + player.ChangeTeam(CsTeam.Spectator); + Server.NextFrame(() => + { + if (player.IsValid) + player.ChangeTeam(CsTeam.None); + }); + } + + [GameEventHandler(HookMode.Pre)] + public HookResult OnPlayerConnect(EventPlayerConnect @event, GameEventInfo info) + { + if (!@event.Bot && _hidden.Contains(@event.Xuid)) + info.DontBroadcast = true; + return HookResult.Continue; + } + + // Also fires for everyone on a map change, which is when SimpleAdmin forgets who was hidden. + [GameEventHandler(HookMode.Pre)] + public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) + { + var player = @event.Userid; + if (player is not { IsValid: true, IsBot: false } || !_hidden.Contains(player.SteamID)) + return HookResult.Continue; + + info.DontBroadcast = true; + ScheduleRestore(player, RestoreDelay); + return HookResult.Continue; + } + [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) { - if (IsHidden(@event.Userid)) - info.DontBroadcast = true; + var player = @event.Userid; + if (!IsHidden(player)) + return HookResult.Continue; + + info.DontBroadcast = true; + if (@event.Team > (int)CsTeam.Spectator && _hidden.Contains(player!.SteamID)) + ScheduleRestore(player, 0.1f); return HookResult.Continue; } @@ -108,13 +297,75 @@ public class AdminStealth : BasePlugin [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) { - if (IsHidden(@event.Userid)) + var player = @event.Userid; + if (IsHidden(player)) info.DontBroadcast = true; + if (player is { IsValid: true }) + { + _hideTyped.Remove(player.Slot); + _restoreScheduled.Remove(player.Slot); + _restoring.Remove(player.Slot); + } return HookResult.Continue; } private bool IsHidden(CCSPlayerController? player) { - return player is { IsValid: true, IsBot: false } && _simpleAdmin?.IsAdminSilent(player) == true; + return player is { IsValid: true, IsBot: false } && + (_hidden.Contains(player.SteamID) || _simpleAdmin?.IsAdminSilent(player) == true); + } + + private string ChatAlias() + { + var alias = _hideAliases[0]; + return alias.StartsWith("css_") ? "!" + alias[4..] : alias; + } + + // SimpleAdmin lets Commands.json rename css_hide, so listen for whatever it's called there. + private string[] ReadHideAliases() + { + var path = Path.Combine(ConfigDirectory, "CS2-SimpleAdmin", "Commands.json"); + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + var aliases = doc.RootElement.GetProperty("commands").GetProperty("css_hide").GetProperty("aliases") + .EnumerateArray().Select(a => a.GetString()).OfType().ToArray(); + if (aliases.Length > 0) + return aliases; + } + catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException or InvalidOperationException) + { + Logger.LogWarning("Couldn't read css_hide's aliases from {Path} ({Error}); using css_hide and css_stealth", + path, ex.Message); + } + return ["css_hide", "css_stealth"]; + } + + private void LoadHidden() + { + _hidden.Clear(); + if (!File.Exists(HiddenPath)) + return; + try + { + _hidden.UnionWith(JsonSerializer.Deserialize>(File.ReadAllText(HiddenPath)) ?? []); + } + catch (Exception ex) when (ex is IOException or JsonException) + { + Logger.LogError("Couldn't read {Path}: {Error}", HiddenPath, ex.Message); + } + } + + private void SaveHidden() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(HiddenPath)!); + File.WriteAllText(HiddenPath, JsonSerializer.Serialize(_hidden.Order().ToList())); + } + catch (IOException ex) + { + Logger.LogError("Couldn't write {Path}: {Error}", HiddenPath, ex.Message); + } } } diff --git a/README.md b/README.md index 9de121e..8c921f7 100644 --- a/README.md +++ b/README.md @@ -7,24 +7,38 @@ admin off the scoreboard. This plugin makes it look like they left the server: client only. The server and other plugins never see it, so the admin stays connected. - While an admin is hidden, their team changes, their death and their real disconnect are not broadcast to clients. +- Hiding lasts until the admin unhides. Hidden admins are saved by SteamID in + `configs/plugins/AdminStealth/hidden.json`. When one reconnects, or on a map change (SimpleAdmin + forgets hidden admins on both), their join isn't announced, the plugin runs `css_hide` for them + again once SimpleAdmin has loaded their permissions, and tells them they're still hidden. After + five tries (about 12 seconds) it gives up, forgets them and tells them they're visible. +- A hidden admin can't join a team or go to spectators (`jointeam` and `spectate` are blocked + with a chat message). If the game or another plugin puts them on a team anyway, which makes + SimpleAdmin unhide them, they're hidden again straight away. - `status` from a player's console is blocked and prints nothing. It still works from the server console and RCON. +The plugin listens for `css_hide` under whatever aliases SimpleAdmin's +`configs/plugins/CS2-SimpleAdmin/Commands.json` gives it (by default `css_hide` and `css_stealth`), +so adding aliases there (e.g. `css_invis`) works here too after a restart. + WebPanelBridge leaves hidden admins out of `css_webpanel_status`, so simpleadmin-web doesn't show them either. -Built for CounterStrikeSharp **1.0.375** (`net10.0`) on Metamod:Source 2.0.0.1469, and needs +Built for CounterStrikeSharp **1.0.376** (`net10.0`) on Metamod:Source 2.0.0.1469, and needs CS2-SimpleAdmin 1.8.2b's shared API (`CS2-SimpleAdminApi`). `lib/CS2-SimpleAdminApi.dll` is a compile-time copy of the server's `shared/` one and isn't shipped. Without SimpleAdmin loaded, only the `status` block works. ## Limits -- Hiding still lasts only as long as SimpleAdmin remembers it: a map change or reconnect makes the - admin visible again, and joining a team unhides them. +- After a reconnect or map change, the admin is visible to SimpleAdmin (and so on the web panel) for + the few seconds it takes to hide them again. - Unhiding doesn't announce the admin as joining again. - The server browser and trackers get the player list from a separate server query, which this doesn't touch, so they still list a hidden admin. +- Reloading CS2-SimpleAdmin on its own leaves this plugin listening to the old instance. Reload + AdminStealth after it. ## Install @@ -41,4 +55,4 @@ tar -xzf AdminStealth-.tar.gz -C /srv/cs2/game/csgo To publish one, commit, bump `ModuleVersion`, then run `FORGEJO_TOKEN=... ./release.sh v`. It rebuilds the plugin from the committed source in the SDK container, tags HEAD, and uploads `AdminStealth-.tar.gz` to the git.zio.sh -release. The plugin has no config. +release. The plugin has no config. The release never includes `hidden.json`.