Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
3 changed files with 18 additions and 283 deletions
277
AdminStealth.cs
277
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<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)
|
||||
|
|
@ -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<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);
|
||||
}
|
||||
return player is { IsValid: true, IsBot: false } && _simpleAdmin?.IsAdminSilent(player) == true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
|
||||
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.376">
|
||||
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.375">
|
||||
<PrivateAssets>none</PrivateAssets>
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
|
|
|||
22
README.md
22
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-<tag>.tar.gz -C /srv/cs2/game/csgo
|
|||
To publish one, commit, bump `ModuleVersion`, then run
|
||||
`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
|
||||
release. The plugin has no config. The release never includes `hidden.json`.
|
||||
release. The plugin has no config.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue