From 61f8ea0ad18ee108173b7e9d26794b98096a8a61 Mon Sep 17 00:00:00 2001 From: Astra Date: Sun, 27 Sep 2026 18:06:48 +0100 Subject: [PATCH] AdminStealth 0.1.0: make CS2-SimpleAdmin's css_hide look like leaving, block status for players --- .gitignore | 3 + AdminStealth.cs | 120 +++++++++++++++++++++++++++++++++++++ AdminStealth.csproj | 22 +++++++ README.md | 44 ++++++++++++++ lib/CS2-SimpleAdminApi.dll | Bin 0 -> 12800 bytes release.sh | 115 +++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 .gitignore create mode 100644 AdminStealth.cs create mode 100644 AdminStealth.csproj create mode 100644 README.md create mode 100644 lib/CS2-SimpleAdminApi.dll create mode 100755 release.sh 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 0000000000000000000000000000000000000000..3b594c763973e54689c774e53a8af74e65c4b7f1 GIT binary patch literal 12800 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 literal 0 HcmV?d00001 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"