505 lines
19 KiB
C#
505 lines
19 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.13";
|
|
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();
|
|
});
|
|
}
|
|
|
|
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.");
|
|
|
|
// 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. Answer the pick by telling
|
|
// only their client they joined spectators, with the team screen disabled around it, so
|
|
// nobody else ever sees them on a team.
|
|
var slot = player.Slot;
|
|
Server.ExecuteCommand("sv_disable_teamselect_menu 1");
|
|
AddTimer(0.3f, () =>
|
|
{
|
|
var p = Utilities.GetPlayerFromSlot(slot);
|
|
if (p is { IsValid: true } && IsHidden(p))
|
|
SendTeamEvent(p, (int)CsTeam.Spectator, p.TeamNum);
|
|
}, TimerFlags.STOP_ON_MAPCHANGE);
|
|
AddTimer(0.6f, () => Server.ExecuteCommand("sv_disable_teamselect_menu 0"), TimerFlags.STOP_ON_MAPCHANGE);
|
|
return HookResult.Stop;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|