diff --git a/AdminStealth.cs b/AdminStealth.cs index 37d0adf..1351fd6 100644 --- a/AdminStealth.cs +++ b/AdminStealth.cs @@ -23,11 +23,12 @@ namespace AdminStealth; public class AdminStealth : BasePlugin { public override string ModuleName => "AdminStealth"; - public override string ModuleVersion => "0.3.12"; + public override string ModuleVersion => "0.3.15"; 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. + // 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; @@ -65,6 +66,9 @@ public class AdminStealth : BasePlugin // without ever calling us. foreach (var alias in _hideAliases) AddCommandListener(alias, (player, command) => OnHideTyped(player, command)); + // Players who may not hide get no reply at all, not even CSS's missing-permission one. Only + // a catch-all listener runs before every plugin's command handler, for console and chat. + AddCommandListener(null, OnAnyCommand); // Joining any team, spectators included, would make SimpleAdmin unhide them. AddCommandListener("jointeam", (player, command) => OnJoinTeam(player, command)); AddCommandListener("spectate", (player, command) => OnJoinTeam(player, command)); @@ -79,6 +83,7 @@ public class AdminStealth : BasePlugin _hideTyped.Clear(); _restoreScheduled.Clear(); _restoring.Clear(); + _closingTeamScreen.Clear(); }); } @@ -121,6 +126,14 @@ public class AdminStealth : BasePlugin return player == null ? HookResult.Continue : HookResult.Stop; } + private HookResult OnAnyCommand(CCSPlayerController? player, CommandInfo command) + { + if (player is not { IsValid: true, IsBot: false } || + !_hideAliases.Contains(command.GetArg(0), StringComparer.OrdinalIgnoreCase) || CanHide(player)) + return HookResult.Continue; + return HookResult.Stop; + } + private HookResult OnHideTyped(CCSPlayerController? player, CommandInfo command) { if (player is { IsValid: true } && !_restoring.Contains(player.Slot)) @@ -135,20 +148,42 @@ public class AdminStealth : BasePlugin 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. + 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 _closingTeamScreen = []; + + private void CloseTeamScreen(CCSPlayerController player) + { var slot = player.Slot; + if (!_closingTeamScreen.Add(slot)) + return; + Server.ExecuteCommand("sv_disable_teamselect_menu 1"); - AddTimer(0.3f, () => + Server.NextFrame(() => { var p = Utilities.GetPlayerFromSlot(slot); - if (p is { IsValid: true } && IsHidden(p)) - SendTeamEvent(p, (int)CsTeam.Spectator, p.TeamNum); + 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); - AddTimer(0.6f, () => Server.ExecuteCommand("sv_disable_teamselect_menu 0"), TimerFlags.STOP_ON_MAPCHANGE); - return HookResult.Stop; } private HookResult OnTeamMenu(CCSPlayerController? player, CommandInfo command) @@ -310,7 +345,7 @@ public class AdminStealth : BasePlugin return; } - if (AdminManager.PlayerHasPermissions(player, HidePermission)) + if (CanHide(player)) { _restoring.Add(slot); player.ExecuteClientCommandFromServer(_hideAliases[0]); @@ -318,6 +353,23 @@ public class AdminStealth : BasePlugin 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) { diff --git a/README.md b/README.md index 4c4e7e6..edd38d6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # AdminStealth -A companion to CS2-SimpleAdmin's `css_hide` (`!hide`, `@css/kick`). SimpleAdmin's hide moves the +A companion to CS2-SimpleAdmin's `css_hide` (`!hide`, `@css/kick`, or whatever an +`admin_overrides.json` entry for `css_hide` says: reconnect re-hiding follows it too). SimpleAdmin's hide moves the admin off the scoreboard. This plugin makes it look like they left the server: - When an admin hides, every other player gets a `player_disconnect` event for them, sent to each @@ -19,9 +20,12 @@ admin off the scoreboard. This plugin makes it look like they left the server: the team select screen doesn't open, until the restore hides them again. - The team select screen: the server's "team" menu message (`CS_UM_VGUIMenu`) is never sent to a hidden admin. M opens it on the client regardless, and a player with no team can't close it - without picking, so a pick is answered by telling only that client it joined spectators - (with `sv_disable_teamselect_menu` on around it); nobody else sees a team change. + without picking, so a pick moves them to spectators and straight back to no team, as `css_hide` + does (with `sv_disable_teamselect_menu` on around it), which closes it. The team events aren't + broadcast; for about 0.15 s they're on the spectator list of anyone looking at the scoreboard. - Restoring runs `css_hide` at most once: it toggles, so a second run could unhide them. +- Players who may not use `css_hide` (or its aliases) get no reply at all when they try it, from + console or chat: not even CSS's missing-permission message. Needs our SimpleAdmin fork (1.8.2b-zio2 or later), where moving to spectators doesn't unhide a hidden admin. Never hot-reload CS2-SimpleAdmin while this runs: CSS keeps handing out the old diff --git a/release.sh b/release.sh index a3b6a11..3c7228f 100755 --- a/release.sh +++ b/release.sh @@ -19,8 +19,13 @@ set -euo pipefail FORGEJO_URL="https://git.zio.sh" OWNER="cs2" REPO="AdminStealth" -# Placeholder - replace, or set FORGEJO_TOKEN in the environment instead. Needs write:repository. -# Only uploading needs it; downloading from the public repo doesn't. Don't commit a real token. +# Needs write:repository. Only uploading needs it; downloading from the public repo doesn't. From the +# environment, or else from .forgejo-token in the cs2/ folder above every plugin (outside their +# repos, mode 600). Don't commit a real token. +TOKEN_FILE="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../../.forgejo-token" +if [[ -z "${FORGEJO_TOKEN:-}" && -r "$TOKEN_FILE" ]]; then + FORGEJO_TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")" +fi FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" # Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj @@ -37,7 +42,7 @@ if [[ -z "$TAG" ]]; then exit 1 fi if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then - echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + echo "Set FORGEJO_TOKEN (export it, or put it in $TOKEN_FILE) first." >&2 exit 1 fi for tool in podman jq curl git; do