AdminStealth 0.2.0: keep admins hidden across reconnects and map changes, block joining teams while hidden

This commit is contained in:
Astra 2026-09-27 19:08:59 +01:00
parent 791c198fa0
commit b60f508a7b
2 changed files with 282 additions and 17 deletions

View file

@ -1,9 +1,13 @@
using System.Text.Json;
using CounterStrikeSharp.API; using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Core.Capabilities; using CounterStrikeSharp.API.Core.Capabilities;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Commands; using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Entities;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using CounterStrikeSharp.API.ValveConstants.Protobuf; using CounterStrikeSharp.API.ValveConstants.Protobuf;
using CS2_SimpleAdminApi; using CS2_SimpleAdminApi;
using Microsoft.Extensions.Logging; 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 // 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 // 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. // otherwise still list a hidden admin.
public class AdminStealth : BasePlugin public class AdminStealth : BasePlugin
{ {
public override string ModuleName => "AdminStealth"; 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 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<ICS2_SimpleAdminApi> SimpleAdminCapability = new("simpleadmin:api"); private static readonly PluginCapability<ICS2_SimpleAdminApi> SimpleAdminCapability = new("simpleadmin:api");
private ICS2_SimpleAdminApi? _simpleAdmin; private ICS2_SimpleAdminApi? _simpleAdmin;
// SteamID64s of admins who hid and haven't unhidden, kept in hidden.json.
private readonly HashSet<ulong> _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<int, int> _hideTyped = [];
// Slots with a restore timer running, and slots whose hide was run by us rather than typed.
private readonly HashSet<int> _restoreScheduled = [];
private readonly HashSet<int> _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) 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); AddCommandListener("status", OnStatus);
RegisterListener<Listeners.OnMapStart>(_ =>
{
_hideTyped.Clear();
_restoreScheduled.Clear();
_restoring.Clear();
});
} }
public override void OnAllPluginsLoaded(bool hotReload) public override void OnAllPluginsLoaded(bool hotReload)
@ -41,10 +81,19 @@ public class AdminStealth : BasePlugin
if (_simpleAdmin == null) 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; return;
} }
_simpleAdmin.OnAdminToggleSilent += OnAdminToggleSilent; _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) public override void Unload(bool hotReload)
@ -59,17 +108,67 @@ public class AdminStealth : BasePlugin
return player == null ? HookResult.Continue : HookResult.Stop; 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) private void OnAdminToggleSilent(int slot, bool hidden)
{ {
if (!hidden) // The typed command's listener may run before or after SimpleAdmin's handler, which fires
return; // 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); var admin = Utilities.GetPlayerFromSlot(slot);
if (admin is not { IsValid: true, IsBot: false }) if (admin is not { IsValid: true, IsBot: false })
return; return;
// A fake player_disconnect, sent to each client rather than fired on the server, so no if (hidden)
// plugin (SimpleAdmin included) treats the admin as really gone. {
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) var leave = new EventPlayerDisconnect(true)
{ {
Userid = admin, Userid = admin,
@ -82,17 +181,107 @@ public class AdminStealth : BasePlugin
}; };
foreach (var player in Utilities.GetPlayers()) 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.FireEventToClient(player);
} }
leave.Free(); 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)] [GameEventHandler(HookMode.Pre)]
public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info)
{ {
if (IsHidden(@event.Userid)) var player = @event.Userid;
info.DontBroadcast = true; 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; return HookResult.Continue;
} }
@ -108,13 +297,75 @@ public class AdminStealth : BasePlugin
[GameEventHandler(HookMode.Pre)] [GameEventHandler(HookMode.Pre)]
public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info)
{ {
if (IsHidden(@event.Userid)) var player = @event.Userid;
if (IsHidden(player))
info.DontBroadcast = true; info.DontBroadcast = true;
if (player is { IsValid: true })
{
_hideTyped.Remove(player.Slot);
_restoreScheduled.Remove(player.Slot);
_restoring.Remove(player.Slot);
}
return HookResult.Continue; return HookResult.Continue;
} }
private bool IsHidden(CCSPlayerController? player) 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<string>().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<List<ulong>>(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);
}
} }
} }

View file

@ -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. 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 - While an admin is hidden, their team changes, their death and their real disconnect are not
broadcast to clients. 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 - `status` from a player's console is blocked and prints nothing. It still works from the server
console and RCON. 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 WebPanelBridge leaves hidden admins out of `css_webpanel_status`, so simpleadmin-web doesn't show
them either. 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 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 compile-time copy of the server's `shared/` one and isn't shipped. Without SimpleAdmin loaded, only
the `status` block works. the `status` block works.
## Limits ## Limits
- Hiding still lasts only as long as SimpleAdmin remembers it: a map change or reconnect makes the - After a reconnect or map change, the admin is visible to SimpleAdmin (and so on the web panel) for
admin visible again, and joining a team unhides them. the few seconds it takes to hide them again.
- Unhiding doesn't announce the admin as joining 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 - 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. 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 ## Install
@ -41,4 +55,4 @@ tar -xzf AdminStealth-<tag>.tar.gz -C /srv/cs2/game/csgo
To publish one, commit, bump `ModuleVersion`, then run To publish one, commit, bump `ModuleVersion`, then run
`FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>`. It rebuilds the plugin from the committed `FORGEJO_TOKEN=... ./release.sh v<ModuleVersion>`. It rebuilds the plugin from the committed
source in the SDK container, tags HEAD, and uploads `AdminStealth-<tag>.tar.gz` to the git.zio.sh source in the SDK container, tags HEAD, and uploads `AdminStealth-<tag>.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`.