commit 61f8ea0ad18ee108173b7e9d26794b98096a8a61 Author: Astra Date: Sun Sep 27 18:06:48 2026 +0100 AdminStealth 0.1.0: make CS2-SimpleAdmin's css_hide look like leaving, block status for players diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a3935e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +release-out/ diff --git a/AdminStealth.cs b/AdminStealth.cs new file mode 100644 index 0000000..eb119e3 --- /dev/null +++ b/AdminStealth.cs @@ -0,0 +1,120 @@ +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Core.Capabilities; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Entities; +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. 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.1.0"; + public override string ModuleAuthor => "astra"; + public override string ModuleDescription => "Makes CS2-SimpleAdmin's css_hide look like leaving the server"; + + private static readonly PluginCapability SimpleAdminCapability = new("simpleadmin:api"); + private ICS2_SimpleAdminApi? _simpleAdmin; + + public override void Load(bool hotReload) + { + AddCommandListener("status", OnStatus); + } + + 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"); + return; + } + _simpleAdmin.OnAdminToggleSilent += OnAdminToggleSilent; + } + + 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 void OnAdminToggleSilent(int slot, bool hidden) + { + if (!hidden) + return; + + var admin = Utilities.GetPlayerFromSlot(slot); + if (admin is not { IsValid: true, IsBot: false }) + return; + + // 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, + 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 != slot) + leave.FireEventToClient(player); + } + leave.Free(); + } + + [GameEventHandler(HookMode.Pre)] + public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) + { + if (IsHidden(@event.Userid)) + info.DontBroadcast = true; + 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) + { + if (IsHidden(@event.Userid)) + info.DontBroadcast = true; + return HookResult.Continue; + } + + private bool IsHidden(CCSPlayerController? player) + { + return player is { IsValid: true, IsBot: false } && _simpleAdmin?.IsAdminSilent(player) == true; + } +} diff --git a/AdminStealth.csproj b/AdminStealth.csproj new file mode 100644 index 0000000..59daa26 --- /dev/null +++ b/AdminStealth.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + AdminStealth + AdminStealth + + + + + none + runtime + compile; build; native; contentfiles; analyzers; buildtransitive + + + + lib/CS2-SimpleAdminApi.dll + false + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9de121e --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# AdminStealth + +A companion to CS2-SimpleAdmin's `css_hide` (`!hide`, `@css/kick`). 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 + 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. +- `status` from a player's console is blocked and prints nothing. It still works from the server + console and RCON. + +WebPanelBridge leaves hidden admins out of `css_webpanel_status`, so simpleadmin-web doesn't show +them either. + +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 + +- 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. + +## Install + +Build with `cd plugins/AdminStealth && ../../build.sh`. Then copy the contents of +`compiled/AdminStealth/` to `game/csgo/addons/counterstrikesharp/plugins/AdminStealth/` on the +server. + +Or install a release, which unpacks into `game/csgo/`: + +``` +tar -xzf AdminStealth-.tar.gz -C /srv/cs2/game/csgo +``` + +To publish one, commit, bump `ModuleVersion`, then run +`FORGEJO_TOKEN=... ./release.sh v`. It rebuilds the plugin from the committed +source in the SDK container, tags HEAD, and uploads `AdminStealth-.tar.gz` to the git.zio.sh +release. The plugin has no config. diff --git a/lib/CS2-SimpleAdminApi.dll b/lib/CS2-SimpleAdminApi.dll new file mode 100644 index 0000000..3b594c7 Binary files /dev/null and b/lib/CS2-SimpleAdminApi.dll differ diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..a3b6a11 --- /dev/null +++ b/release.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Build AdminStealth, package it as one tarball and publish it as a Forgejo release. +# +# ./release.sh e.g. ./release.sh v0.14.0 +# +# Commit first: the plugin is rebuilt here from the committed source, so the release always matches +# the tag. The tag is created on HEAD and pushed to origin if it doesn't exist yet. Re-running with +# the same tag replaces that release's attachment. +# +# Asset: AdminStealth-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf AdminStealth-.tar.gz -C /srv/cs2/game/csgo +# +# addons/counterstrikesharp/plugins/AdminStealth/ AdminStealth.dll, .pdb, .deps.json +# +# No configs/: the plugin has no settings. CS2-SimpleAdminApi.dll (lib/) is compile-only; the +# server has it in shared/ from CS2-SimpleAdmin. +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. +FORGEJO_TOKEN="${FORGEJO_TOKEN:-CHANGE_ME_FORGEJO_TOKEN}" + +# Only these ship from the publish output. CounterStrikeSharp.API.dll is excluded by the csproj +# (ExcludeAssets=runtime); the server already provides it. +PLUGIN_FILES=( + AdminStealth.dll + AdminStealth.pdb + AdminStealth.deps.json +) + +TAG="${1:-}" +if [[ -z "$TAG" ]]; then + echo "usage: $0 (e.g. v0.1.0)" >&2 + exit 1 +fi +if [[ "$FORGEJO_TOKEN" == "CHANGE_ME_FORGEJO_TOKEN" ]]; then + echo "Set FORGEJO_TOKEN (edit release.sh or export it) first." >&2 + exit 1 +fi +for tool in podman jq curl git; do + command -v "$tool" >/dev/null || { echo "'$tool' is required." >&2; exit 1; } +done + +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then + echo "Tracked files have uncommitted changes; commit them so the tag matches the release." >&2 + exit 1 +fi + +# The tag should name the version the plugin reports in `css_plugins list`. +version="$(sed -n 's/.*ModuleVersion => "\(.*\)";.*/\1/p' AdminStealth.cs)" +if [[ "${TAG#v}" != "$version" ]]; then + echo "Tag $TAG doesn't match ModuleVersion $version in AdminStealth.cs." >&2 + exit 1 +fi + +# --- Build ----------------------------------------------------------------------- +OUT="release-out" +PUBLISH="$OUT/publish" +rm -rf bin obj "$OUT" +podman run --rm -v "$(pwd)":/src:Z -w /src \ + mcr.microsoft.com/dotnet/sdk:10.0 dotnet publish -c Release -o "/src/$PUBLISH" + +# --- Stage (layout of game/csgo/) and package ---------------------------------------- +STAGE="$OUT/stage" +PLUGIN_DIR="$STAGE/addons/counterstrikesharp/plugins/AdminStealth" +mkdir -p "$PLUGIN_DIR" +for file in "${PLUGIN_FILES[@]}"; do + cp "$PUBLISH/$file" "$PLUGIN_DIR/" +done + +TARBALL="AdminStealth-${TAG}.tar.gz" +tar -czf "$OUT/$TARBALL" -C "$STAGE" addons +rm -rf "$STAGE" "$PUBLISH" + +echo "Packaged:" +tar -tzf "$OUT/$TARBALL" + +# --- Tag --------------------------------------------------------------------------- +if ! git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + git tag -a "$TAG" -m "$TAG" +fi +git push origin "refs/tags/$TAG" + +# --- Release ----------------------------------------------------------------------- +API="$FORGEJO_URL/api/v1/repos/$OWNER/$REPO" +AUTH=(-H "Authorization: token $FORGEJO_TOKEN") + +release_json="$(curl -fsS "${AUTH[@]}" "$API/releases/tags/$TAG" 2>/dev/null || true)" +if [[ -z "$release_json" ]]; then + body="$(git log -1 --format=%B "$TAG")" + release_json="$(jq -n --arg tag "$TAG" --arg body "$body" \ + '{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}' | + curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -X POST --data @- "$API/releases")" + echo "Created release $TAG" +else + echo "Release $TAG already exists, replacing its attachment" +fi +release_id="$(jq -r '.id' <<<"$release_json")" + +existing_id="$(jq -r --arg n "$TARBALL" '.assets[]? | select(.name == $n) | .id' <<<"$release_json")" +if [[ -n "$existing_id" ]]; then + curl -fsS "${AUTH[@]}" -X DELETE "$API/releases/$release_id/assets/$existing_id" >/dev/null +fi +curl -fsS "${AUTH[@]}" -X POST -F "attachment=@$OUT/$TARBALL" \ + "$API/releases/$release_id/assets?name=$TARBALL" >/dev/null +echo "Uploaded $TARBALL" + +echo +echo "Download URL:" +echo " $FORGEJO_URL/$OWNER/$REPO/releases/download/$TAG/$TARBALL"