AdminStealth 0.1.0: make CS2-SimpleAdmin's css_hide look like leaving, block status for players
This commit is contained in:
commit
61f8ea0ad1
6 changed files with 304 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
release-out/
|
||||||
120
AdminStealth.cs
Normal file
120
AdminStealth.cs
Normal file
|
|
@ -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<ICS2_SimpleAdminApi> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
AdminStealth.csproj
Normal file
22
AdminStealth.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>AdminStealth</AssemblyName>
|
||||||
|
<RootNamespace>AdminStealth</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Compile-time only: the server already has CounterStrikeSharp.API.dll -->
|
||||||
|
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.375">
|
||||||
|
<PrivateAssets>none</PrivateAssets>
|
||||||
|
<ExcludeAssets>runtime</ExcludeAssets>
|
||||||
|
<IncludeAssets>compile; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<!-- Compile-time only: CS2-SimpleAdmin installs it in shared/, copied from the server's copy -->
|
||||||
|
<Reference Include="CS2-SimpleAdminApi">
|
||||||
|
<HintPath>lib/CS2-SimpleAdminApi.dll</HintPath>
|
||||||
|
<Private>false</Private>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
44
README.md
Normal file
44
README.md
Normal file
|
|
@ -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-<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.
|
||||||
BIN
lib/CS2-SimpleAdminApi.dll
Normal file
BIN
lib/CS2-SimpleAdminApi.dll
Normal file
Binary file not shown.
115
release.sh
Executable file
115
release.sh
Executable file
|
|
@ -0,0 +1,115 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build AdminStealth, package it as one tarball and publish it as a Forgejo release.
|
||||||
|
#
|
||||||
|
# ./release.sh <tag> 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-<tag>.tar.gz, laid out like the server's game/csgo/ - extract it there:
|
||||||
|
# tar -xzf AdminStealth-<tag>.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 <tag> (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"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue