AdminStealth/AdminStealth.cs

371 lines
13 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.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<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();
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<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.");
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<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);
}
}
}