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.UserMessages; using CounterStrikeSharp.API.Modules.Utils; using CounterStrikeSharp.API.ValveConstants.Protobuf; using CS2_SimpleAdminApi; using Microsoft.Extensions.Logging; 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. 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.3.15"; public override string ModuleAuthor => "astra"; public override string ModuleDescription => "Makes CS2-SimpleAdmin's css_hide look like leaving, and keeps it"; // css_hide's own permission, unless admin_overrides.json overrides css_hide (see CanHide). // 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; // How soon after connecting a hidden admin is moved to spectators, past the team select screen. private const float SkipTeamSelectDelay = 0.1f; // CS_UM_VGUIMenu: how the server opens the team select screen, as name "team". CS2 opens it // whatever the message's "show" says (the server doesn't even set it), so it can't close one. private const int VguiMenuMessage = 301; 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(); // Each listener needs its own delegate: CSS keys a plugin's listeners by handler, so adding // the same method twice orphans the first registration, which then blocks its command // without ever calling us. foreach (var alias in _hideAliases) AddCommandListener(alias, (player, command) => OnHideTyped(player, command)); // Players who may not hide get no reply at all, not even CSS's missing-permission one. Only // a catch-all listener runs before every plugin's command handler, for console and chat. AddCommandListener(null, OnAnyCommand); // Joining any team, spectators included, would make SimpleAdmin unhide them. AddCommandListener("jointeam", (player, command) => OnJoinTeam(player, command)); AddCommandListener("spectate", (player, command) => OnJoinTeam(player, command)); // M. Hidden admins have no team, and the game won't let a player with no team close the // team screen without picking one, so don't let it open. AddCommandListener("teammenu", OnTeamMenu); AddCommandListener("status", OnStatus); HookUserMessage(VguiMenuMessage, OnVguiMenu); RegisterListener(_ => { _hideTyped.Clear(); _restoreScheduled.Clear(); _restoring.Clear(); _closingTeamScreen.Clear(); }); } 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 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) { if (_simpleAdmin != null) _simpleAdmin.OnAdminToggleSilent -= OnAdminToggleSilent; } // Players get nothing back. The server console and RCON still work. private HookResult OnStatus(CCSPlayerController? player, CommandInfo command) { return player == null ? HookResult.Continue : HookResult.Stop; } private HookResult OnAnyCommand(CCSPlayerController? player, CommandInfo command) { if (player is not { IsValid: true, IsBot: false } || !_hideAliases.Contains(command.GetArg(0), StringComparer.OrdinalIgnoreCase) || CanHide(player)) return HookResult.Continue; return 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."); CloseTeamScreen(player); return HookResult.Stop; } // M opens the team screen on the client, where the server can't block it, and a player with no // team (as hidden admins are) can't close it without picking. Telling only their client they // joined spectators doesn't close it (tried live), so do what css_hide does: really move them // to spectators and back to no team, with the team screen disabled around it. Their team // events aren't broadcast while hidden (OnPlayerTeam), and our SimpleAdmin fork doesn't unhide // for spectators. private readonly HashSet _closingTeamScreen = []; private void CloseTeamScreen(CCSPlayerController player) { var slot = player.Slot; if (!_closingTeamScreen.Add(slot)) return; Server.ExecuteCommand("sv_disable_teamselect_menu 1"); Server.NextFrame(() => { var p = Utilities.GetPlayerFromSlot(slot); if (p is { IsValid: true } && IsHidden(p) && p.TeamNum == (byte)CsTeam.None) p.ChangeTeam(CsTeam.Spectator); }); AddTimer(0.15f, () => { var p = Utilities.GetPlayerFromSlot(slot); if (p is { IsValid: true } && IsHidden(p) && p.TeamNum == (byte)CsTeam.Spectator) p.ChangeTeam(CsTeam.None); }, TimerFlags.STOP_ON_MAPCHANGE); AddTimer(0.5f, () => { _closingTeamScreen.Remove(slot); Server.ExecuteCommand("sv_disable_teamselect_menu 0"); }, TimerFlags.STOP_ON_MAPCHANGE); } private HookResult OnTeamMenu(CCSPlayerController? player, CommandInfo command) { if (!IsHidden(player)) return HookResult.Continue; Logger.LogInformation("Blocked teammenu from hidden admin {Name}", player!.PlayerName); player.PrintToChat($" You're hidden, so you can't join a team. Type {ChatAlias()} to show yourself."); return HookResult.Stop; } // Keeps the team select screen from ever opening for a hidden admin. private HookResult OnVguiMenu(UserMessage message) { try { if (message.ReadString("name") != "team") return HookResult.Continue; } catch (Exception ex) { WarnMenuOnce($"Couldn't read the team menu message ({message.DebugString}): {ex.Message}"); return HookResult.Continue; } foreach (var player in message.Recipients.ToList()) { if (!IsHidden(player)) continue; message.Recipients.Remove(player); Logger.LogInformation("Kept the team menu from hidden admin {Name}", player.PlayerName); } return HookResult.Continue; } private bool _menuWarned; private void WarnMenuOnce(string warning) { if (_menuWarned) return; _menuWarned = true; Logger.LogWarning("{Warning}", warning); } private void OnAdminToggleSilent(int slot, bool hidden) { // 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; 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, Playerid = admin.Slot, Name = admin.PlayerName, Networkid = new SteamID(admin.SteamID).SteamId3, Xuid = admin.SteamID, Reason = (int)NetworkDisconnectionReason.NETWORK_DISCONNECT_DISCONNECT_BY_USER, EverFullyConnected = true, }; foreach (var player in Utilities.GetPlayers()) { 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; } // css_hide toggles, so running it again after it didn't seem to take could unhide them. That // happens when SimpleAdmin was hot-reloaded: CSS keeps handing out the old instance's API. if (_restoring.Contains(slot)) { _restoreScheduled.Remove(slot); _restoring.Remove(slot); Logger.LogWarning("Ran css_hide for {Name} ({SteamId}) but SimpleAdmin doesn't report them hidden. " + "If CS2-SimpleAdmin was reloaded without a server restart, restart the server.", player.PlayerName, steamId); 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 (CanHide(player)) { _restoring.Add(slot); player.ExecuteClientCommandFromServer(_hideAliases[0]); } AddTimer(RestoreDelay, () => TryRestore(slot, steamId, attempt + 1), TimerFlags.STOP_ON_MAPCHANGE); } // Whether the player may use css_hide: an admin_overrides.json override of it wins, checked the way // CSS checks the typed command ("all" needs every flag, anything else one of them). private bool CanHide(CCSPlayerController player) { var command = _hideAliases[0]; var data = AdminManager.GetCommandOverrideData(command); if (data == null || !data.Enabled) return AdminManager.PlayerHasPermissions(player, HidePermission); var flags = data.Flags.ToArray(); BaseRequiresPermissions check = data.CheckType == "all" ? new RequiresPermissions(flags) : new RequiresPermissionsOr(flags); check.Command = command; return check.CanExecuteCommand(player); } // 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); // Put them in spectators straight away so they skip the team select screen, rather than // waiting for the restore. SimpleAdmin doesn't count them as hidden yet, so this doesn't // unhide them; the restore then moves them from spectators to hidden. var slot = player.Slot; var steamId = player.SteamID; AddTimer(SkipTeamSelectDelay, () => { var p = Utilities.GetPlayerFromSlot(slot); if (p is { IsValid: true } && p.SteamID == steamId && _hidden.Contains(steamId) && p.TeamNum == (byte)CsTeam.None && _simpleAdmin?.IsAdminSilent(p) != true) { p.ChangeTeam(CsTeam.Spectator); } }, TimerFlags.STOP_ON_MAPCHANGE); return HookResult.Continue; } [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) { var player = @event.Userid; if (!IsHidden(player)) return HookResult.Continue; info.DontBroadcast = true; // Still tell the admin themselves: their client closes the team screen on it. SendTeamEvent(player!, @event.Team, @event.Oldteam); if (@event.Team > (int)CsTeam.Spectator && _hidden.Contains(player!.SteamID)) ScheduleRestore(player, 0.1f); return HookResult.Continue; } private static void SendTeamEvent(CCSPlayerController player, int team, int oldTeam) { var copy = new EventPlayerTeam(true) { Userid = player, Team = team, Oldteam = oldTeam, Disconnect = false, Silent = true, Isbot = false, Name = player.PlayerName, }; copy.FireEventToClient(player); copy.Free(); } [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) { if (IsHidden(@event.Userid)) info.DontBroadcast = true; return HookResult.Continue; } // Pre runs before SimpleAdmin's own handler forgets the player was hidden. [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) { 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 } && (_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); } } }