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; 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.2.0"; 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. 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) { 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 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) { // 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; } 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) { 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; } [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); } } }