Telling only their client they joined spectators left the screen open, so the admin had to unhide to get out. Move them to spectators and back to no team, as css_hide does, with sv_disable_teamselect_menu on around it.
528 lines
20 KiB
C#
528 lines
20 KiB
C#
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.14";
|
|
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<ICS2_SimpleAdminApi> SimpleAdminCapability = new("simpleadmin:api");
|
|
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)
|
|
{
|
|
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));
|
|
// 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<Listeners.OnMapStart>(_ =>
|
|
{
|
|
_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 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<int> _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<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);
|
|
}
|
|
}
|
|
}
|