diff --git a/AdminStealth.cs b/AdminStealth.cs index 3d4679e..eb119e3 100644 --- a/AdminStealth.cs +++ b/AdminStealth.cs @@ -1,13 +1,9 @@ -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; @@ -16,57 +12,21 @@ 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 +// disconnect later) is announced. 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 ModuleVersion => "0.1.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; + public override string ModuleDescription => "Makes CS2-SimpleAdmin's css_hide look like leaving the server"; 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) @@ -81,19 +41,10 @@ public class AdminStealth : BasePlugin if (_simpleAdmin == null) { - Logger.LogError("CS2-SimpleAdmin's API isn't available, so hiding won't look like leaving or last"); + Logger.LogError("CS2-SimpleAdmin's API isn't available, so hiding won't look like leaving"); 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) @@ -108,67 +59,17 @@ 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) { - // 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; + if (!hidden) + return; 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) - { + // 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. var leave = new EventPlayerDisconnect(true) { Userid = admin, @@ -181,107 +82,17 @@ public class AdminStealth : BasePlugin }; foreach (var player in Utilities.GetPlayers()) { - if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.Slot != admin.Slot) + if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.Slot != 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); + if (IsHidden(@event.Userid)) + info.DontBroadcast = true; return HookResult.Continue; } @@ -297,75 +108,13 @@ public class AdminStealth : BasePlugin [GameEventHandler(HookMode.Pre)] public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) { - var player = @event.Userid; - if (IsHidden(player)) + if (IsHidden(@event.Userid)) 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); - } + return player is { IsValid: true, IsBot: false } && _simpleAdmin?.IsAdminSilent(player) == true; } } diff --git a/AdminStealth.csproj b/AdminStealth.csproj index f13eac9..59daa26 100644 --- a/AdminStealth.csproj +++ b/AdminStealth.csproj @@ -8,7 +8,7 @@ - + none runtime compile; build; native; contentfiles; analyzers; buildtransitive diff --git a/README.md b/README.md index 8c921f7..9de121e 100644 --- a/README.md +++ b/README.md @@ -7,38 +7,24 @@ 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.376** (`net10.0`) on Metamod:Source 2.0.0.1469, and needs +Built for CounterStrikeSharp **1.0.375** (`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 -- 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. +- 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. - 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 @@ -55,4 +41,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. The release never includes `hidden.json`. +release. The plugin has no config.