From d02c8454dfd11c1ce5665687fac3f302f72e8eb2 Mon Sep 17 00:00:00 2001 From: Astra Date: Fri, 25 Sep 2026 19:59:44 +0100 Subject: [PATCH] WebPanelBridge 0.1.0: live server status for simpleadmin-web over RCON --- .gitignore | 3 ++ README.md | 44 ++++++++++++++++ WebPanelBridge.cs | 79 +++++++++++++++++++++++++++++ WebPanelBridge.csproj | 17 +++++++ release.sh | 114 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 WebPanelBridge.cs create mode 100644 WebPanelBridge.csproj 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/README.md b/README.md new file mode 100644 index 0000000..2e956ac --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# WebPanelBridge + +Reports live match state to [simpleadmin-web](../../go/simpleadmin-web) over RCON: the hostname, +map, team scores, warmup state, and each connected player's userid, name, SteamID64, team, +kills, deaths and ping. It changes nothing in the game. + +Built for CounterStrikeSharp **1.0.375** (`net10.0`) on Metamod:Source 2.0.0.1469. No other +dependencies. + +## The command + +`css_webpanel_status` is server-only: it runs from the server console or RCON, never from a +player. It prints one JSON object per line, each prefixed with `wpb `, then `wpb end`: + +``` +wpb {"v":1,"hostname":"zio.sh | Random Skills","map":"de_mirage","maxPlayers":24,"scoreT":7,"scoreCt":9,"warmup":false} +wpb {"userid":2,"name":"astra","steamid":"76561198012345601","team":3,"kills":21,"deaths":9,"ping":12,"bot":false} +wpb end +``` + +`team` is 1 for spectators, 2 for T and 3 for CT. Bots have `steamid` "0". `maxPlayers` is +`sv_visiblemaxplayers` when that's set, otherwise the slot count. + +The prefix lets the panel pick these lines out of anything else the server prints into the same +RCON response. `v` is the format version; the panel rejects output it doesn't recognise. + +## Install + +Build with `cd plugins/WebPanelBridge && ../../build.sh`. Then copy the contents of +`compiled/WebPanelBridge/` to `game/csgo/addons/counterstrikesharp/plugins/WebPanelBridge/` on the +server. + +Or install a release, which unpacks into `game/csgo/`: + +``` +tar -xzf WebPanelBridge-.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 `WebPanelBridge-.tar.gz` to the git.zio.sh +release. The plugin has no config. + +Check it from RCON with `css_webpanel_status`. diff --git a/WebPanelBridge.cs b/WebPanelBridge.cs new file mode 100644 index 0000000..b9e8086 --- /dev/null +++ b/WebPanelBridge.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Core; +using CounterStrikeSharp.API.Core.Attributes.Registration; +using CounterStrikeSharp.API.Modules.Commands; +using CounterStrikeSharp.API.Modules.Cvars; +using CounterStrikeSharp.API.Modules.Utils; + +namespace WebPanelBridge; + +// Reports live match state to simpleadmin-web over RCON. The server console command prints one +// JSON object per line, each prefixed with "wpb ", so the web panel can pick them out of whatever +// else lands in the RCON response. Nothing here changes game state. +public class WebPanelBridge : BasePlugin +{ + public override string ModuleName => "WebPanelBridge"; + public override string ModuleVersion => "0.1.0"; + public override string ModuleAuthor => "astra"; + public override string ModuleDescription => "Live server status for simpleadmin-web, over RCON"; + + private const string Prefix = "wpb "; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + + private record ServerLine(int V, string Hostname, string Map, int MaxPlayers, int ScoreT, int ScoreCt, bool Warmup); + + private record PlayerLine(int Userid, string Name, string Steamid, int Team, int Kills, int Deaths, uint Ping, bool Bot); + + [ConsoleCommand("css_webpanel_status", "Prints live server status as JSON lines for simpleadmin-web")] + [CommandHelper(whoCanExecute: CommandUsage.SERVER_ONLY)] + public void OnStatus(CCSPlayerController? caller, CommandInfo command) + { + int scoreT = 0, scoreCt = 0; + foreach (var team in Utilities.FindAllEntitiesByDesignerName("cs_team_manager")) + { + if (team.TeamNum == (byte)CsTeam.Terrorist) + scoreT = team.Score; + else if (team.TeamNum == (byte)CsTeam.CounterTerrorist) + scoreCt = team.Score; + } + + var warmup = Utilities.FindAllEntitiesByDesignerName("cs_gamerules") + .FirstOrDefault()?.GameRules?.WarmupPeriod ?? false; + + var maxPlayers = Server.MaxPlayers; + var visible = ConVar.Find("sv_visiblemaxplayers")?.GetPrimitiveValue() ?? -1; + if (visible > 0) + maxPlayers = visible; + + var server = new ServerLine(1, ConVar.Find("hostname")?.StringValue ?? "", Server.MapName, maxPlayers, + scoreT, scoreCt, warmup); + command.ReplyToCommand(Prefix + JsonSerializer.Serialize(server, JsonOptions)); + + foreach (var player in Utilities.GetPlayers()) + { + if (player is not { IsValid: true, IsHLTV: false } || player.Connected != PlayerConnectedState.Connected) + continue; + + var stats = player.ActionTrackingServices?.MatchStats; + var line = new PlayerLine( + player.UserId ?? -1, + player.PlayerName, + player.IsBot ? "0" : player.SteamID.ToString(), + player.TeamNum, + stats?.Kills ?? 0, + stats?.Deaths ?? 0, + player.Ping, + player.IsBot); + command.ReplyToCommand(Prefix + JsonSerializer.Serialize(line, JsonOptions)); + } + + command.ReplyToCommand(Prefix + "end"); + } +} diff --git a/WebPanelBridge.csproj b/WebPanelBridge.csproj new file mode 100644 index 0000000..d68d986 --- /dev/null +++ b/WebPanelBridge.csproj @@ -0,0 +1,17 @@ + + + net10.0 + enable + enable + WebPanelBridge + WebPanelBridge + + + + + none + runtime + compile; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..6d8d791 --- /dev/null +++ b/release.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Build WebPanelBridge, 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: WebPanelBridge-.tar.gz, laid out like the server's game/csgo/ - extract it there: +# tar -xzf WebPanelBridge-.tar.gz -C /srv/cs2/game/csgo +# +# addons/counterstrikesharp/plugins/WebPanelBridge/ WebPanelBridge.dll, .pdb, .deps.json +# +# No configs/: the plugin has no settings. +set -euo pipefail + +FORGEJO_URL="https://git.zio.sh" +OWNER="cs2" +REPO="WebPanelBridge" +# 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=( + WebPanelBridge.dll + WebPanelBridge.pdb + WebPanelBridge.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' WebPanelBridge.cs)" +if [[ "${TAG#v}" != "$version" ]]; then + echo "Tag $TAG doesn't match ModuleVersion $version in WebPanelBridge.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/WebPanelBridge" +mkdir -p "$PLUGIN_DIR" +for file in "${PLUGIN_FILES[@]}"; do + cp "$PUBLISH/$file" "$PLUGIN_DIR/" +done + +TARBALL="WebPanelBridge-${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"