feat(admin): plugin write plane — admin.kick/ban/unban/broadcast

Phase 1 (plugin side) of docs/ADMIN_CONTROLS.md: a staff write plane so the
website can moderate the live shard.

- BridgeAdmin.cs: inbound admin.kick, admin.ban (timed + indefinite),
  admin.unban, admin.broadcast. Each requires an `actor`, refuses targets at
  or above AdminAccessFloor (default CoOwner — Owner-only shield), replies
  admin.ok/admin.error with the reqId echoed, and emits an admin.audit
  (origin=web) broadcast. Attribution is web:<actor> in the console log and
  the ban BanDealer tag. Kicking enumerates NetState.Instances so a
  character-select session is caught too.
- BridgeConfig/Bridge.cfg: AdminWriteEnabled (default OFF — opt-in),
  AdminAccessFloor, broadcast/reason length caps, ban duration clamp.
- tools/stub_sidecar_admin.ps1: live smoke-test harness; *.log gitignored.

Verified: compiles clean against ServUO (0 err/warn); live run on the seeded
shard confirms all four verbs, the audit stream, timed-ban fields, and the
Owner-floor refusal, with no exceptions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
2026-07-13 01:55:11 -05:00
parent 8e400daf26
commit 5968007882
4 changed files with 488 additions and 2 deletions

View File

@@ -29,6 +29,24 @@ TownCrierMaxLineLength=200
TownCrierMaxActive=20
TownCrierMaxDurationSec=86400
# Admin write plane (staff moderation from the website). OFF by default: the whole
# feature is opt-in per shard. When enabled, inbound admin.* commands (kick/ban/unban/
# broadcast) are honored. Authorization is enforced on the website; the shard trusts the
# loopback socket and applies a hard floor below.
AdminWriteEnabled=false
# The one shard-side safety floor. An admin.* command refuses any target whose AccessLevel
# is at or above this, so even a compromised sidecar can never touch the Owner. Values are
# AccessLevel names (Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer,
# Administrator, Developer, CoOwner, Owner). Default CoOwner => only Owner/CoOwners shielded.
AdminAccessFloor=CoOwner
# Defense-in-depth caps on admin.* payloads (mirroring the town-crier caps).
AdminBroadcastMaxLength=300
AdminReasonMaxLength=400
# Clamp on a timed ban's duration, seconds. A ban with no/zero duration is indefinite.
AdminBanMaxDurationSec=31536000
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed

View File

@@ -0,0 +1,362 @@
using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Network;
namespace Server.Custom.Bridge
{
/// <summary>
/// The staff write plane: moderation actions the website drives against the live shard.
/// Phase 1 verbs are admin.kick, admin.ban, admin.unban, admin.broadcast.
///
/// Every handler runs on the Core thread (BridgeBoot marshals inbound lines through
/// Timer.DelayCall first), so they may touch accounts, mobiles, and the network freely.
///
/// Trust model (docs/ADMIN_CONTROLS.md §5): authorization is enforced on the *website* —
/// these commands are gated there behind admin/moderator roles. The shard trusts the
/// loopback socket exactly as town-crier does, and applies inbound commands with an implicit
/// CoOwner authority. Its one hard floor is <see cref="Protected"/>: a command refuses any
/// target at or above BridgeConfig.AdminAccessFloor (default CoOwner), so a compromised or
/// buggy sidecar can never ban, kick, or otherwise touch the Owner.
///
/// The whole plane is opt-in: nothing here acts unless BridgeConfig.AdminWriteEnabled is set.
/// Attribution rides on a required "actor" field (the website staff user); every applied
/// action logs to the console and emits an admin.audit event the website persists.
/// </summary>
public static class BridgeAdmin
{
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("admin.kick", OnKick);
BridgeBoot.RegisterHandler("admin.ban", OnBan);
BridgeBoot.RegisterHandler("admin.unban", OnUnban);
BridgeBoot.RegisterHandler("admin.broadcast", OnBroadcast);
}
// ---- admin.kick ----
/// <summary>Disconnects every live session of the target account. Target by serial or account.</summary>
private static void OnKick(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "kick";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
int kicked = KickAccountSessions(acct);
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Num("sessions", kicked)
.Str("reason", reason)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("target", acct.Username).Num("sessions", kicked);
BridgeLink.Emit(sb.End());
}
// ---- admin.ban ----
/// <summary>
/// Bans an account (offline-capable) and disconnects any live sessions. A positive
/// durationSec makes it a timed ban that auto-expires; zero/absent is indefinite. Mirrors
/// the in-game [ban path (KickCommand), but takes the duration explicitly instead of a gump.
/// </summary>
private static void OnBan(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "ban";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
if (Protected(acct))
{
Err(reqId, action, "target is protected staff; refused");
return;
}
int durationSec = BridgeJson.GetInt(o, "durationSec", 0);
if (durationSec < 0)
durationSec = 0;
if (durationSec > BridgeConfig.AdminBanMaxDurationSec)
durationSec = BridgeConfig.AdminBanMaxDurationSec;
if (durationSec > 0)
acct.SetBanTags(null, DateTime.UtcNow, TimeSpan.FromSeconds(durationSec));
else
acct.SetUnspecifiedBan(null); // clears any prior duration tags -> indefinite
// SetBanTags/SetUnspecifiedBan(null) clear the BanDealer tag; set our own attribution.
acct.SetTag("BanDealer", WebActor(actor));
acct.Banned = true;
int kicked = KickAccountSessions(acct);
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Num("durationSec", durationSec)
.Num("sessions", kicked)
.Str("reason", reason)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action).Str("target", acct.Username).Num("durationSec", durationSec).Num("sessions", kicked);
BridgeLink.Emit(sb.End());
}
// ---- admin.unban ----
private static void OnUnban(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "unban";
if (!Ready(reqId, action, actor))
return;
var acct = ResolveTargetAccount(o);
if (acct == null)
{
Err(reqId, action, "unknown or accountless target");
return;
}
acct.Banned = false;
acct.SetUnspecifiedBan(null); // clears BanTime/BanDuration/BanDealer tags
var reason = Reason(o);
Log(actor, action, acct.Username, reason);
BridgeLink.Emit(AuditBegin(action, actor, acct.Username)
.Str("reason", reason)
.End());
Ok(reqId, action, acct.Username);
}
// ---- admin.broadcast ----
private static void OnBroadcast(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
var actor = BridgeJson.GetString(o, "actor");
const string action = "broadcast";
if (!Ready(reqId, action, actor))
return;
var text = BridgeJson.GetString(o, "text");
if (String.IsNullOrEmpty(text))
{
Err(reqId, action, "missing text");
return;
}
if (text.Length > BridgeConfig.AdminBroadcastMaxLength)
text = text.Substring(0, BridgeConfig.AdminBroadcastMaxLength);
// Default to the staff-broadcast green; callers may override.
int hue = BridgeJson.GetInt(o, "hue", 0x35);
World.Broadcast(hue, false, text);
Log(actor, action, null, text);
BridgeLink.Emit(AuditBegin(action, actor, null)
.Num("hue", hue)
.Str("text", text)
.End());
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
BridgeLink.Emit(sb.End());
}
// ---- shared prologue / replies ----
/// <summary>Common gate: the write plane must be enabled and an actor must be present.</summary>
private static bool Ready(string reqId, string action, string actor)
{
if (!BridgeConfig.AdminWriteEnabled)
{
Err(reqId, action, "admin write plane disabled");
return false;
}
if (String.IsNullOrEmpty(actor) || actor.Trim().Length == 0)
{
Err(reqId, action, "missing actor");
return false;
}
return true;
}
private static void Ok(string reqId, string action, string target)
{
var sb = BridgeJson.Begin("admin.ok");
if (reqId != null) sb.Str("reqId", reqId);
sb.Str("action", action);
if (target != null) sb.Str("target", target);
BridgeLink.Emit(sb.End());
}
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("admin.error");
if (reqId != null) sb.Str("reqId", reqId);
if (action != null) sb.Str("action", action);
sb.Str("reason", reason);
BridgeLink.Emit(sb.End());
}
/// <summary>
/// Opens an admin.audit frame (origin=web) with the common fields. Broadcast to every
/// connected dashboard so the website's moderation log stays complete regardless of which
/// client issued the action. The in-game counterpart (origin=in-game) is emitted from
/// BridgeEvents; see docs/ADMIN_CONTROLS.md §5.5.
/// </summary>
private static System.Text.StringBuilder AuditBegin(string action, string actor, string target)
{
return BridgeJson.Begin("admin.audit")
.Str("origin", "web")
.Str("action", action)
.Str("actor", WebActor(actor))
.Str("target", target);
}
private static string WebActor(string actor)
{
return "web:" + actor;
}
/// <summary>Reads and length-clamps the optional reason string.</summary>
private static string Reason(Dictionary<string, object> o)
{
var reason = BridgeJson.GetString(o, "reason");
if (reason != null && reason.Length > BridgeConfig.AdminReasonMaxLength)
reason = reason.Substring(0, BridgeConfig.AdminReasonMaxLength);
return reason;
}
private static void Log(string actor, string action, string target, string detail)
{
Console.WriteLine("[Bridge][admin] {0} {1} target={2} detail={3}",
WebActor(actor), action, target ?? "-", detail ?? "-");
}
// ---- target resolution & floor ----
/// <summary>
/// Resolves the command's target account, by "serial" (a player mobile's account) or by
/// "account" (username). Returns null if neither resolves to a real account.
/// </summary>
private static Account ResolveTargetAccount(Dictionary<string, object> o)
{
var serialStr = BridgeJson.GetString(o, "serial");
if (serialStr != null)
{
var m = ResolveSerial(serialStr);
return m == null ? null : m.Account as Account;
}
var acctName = BridgeJson.GetString(o, "account");
return acctName == null ? null : Accounts.GetAccount(acctName) as Account;
}
/// <summary>
/// The one shard-side safety floor. Protects any account whose effective access level —
/// the account's own or the highest of its characters' — is at or above the configured
/// floor. Even under CoOwner authority the Owner is never reachable from the web.
/// </summary>
private static bool Protected(Account acct)
{
var lvl = acct.AccessLevel;
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m != null && m.AccessLevel > lvl)
lvl = m.AccessLevel;
}
return lvl >= BridgeConfig.AdminAccessFloor;
}
/// <summary>
/// Disconnects every live NetState bound to this account. Enumerating NetState.Instances
/// (rather than walking the account's characters) also catches a session parked at
/// character-select, which has an account but no mobile yet. Snapshot first, since Dispose
/// mutates the instance set.
/// </summary>
private static int KickAccountSessions(Account acct)
{
var doomed = new List<NetState>();
foreach (var ns in NetState.Instances)
{
if (ns != null && ns.Account == acct)
doomed.Add(ns);
}
foreach (var ns in doomed)
ns.Dispose();
return doomed.Count;
}
private static Mobile ResolveSerial(string serialStr)
{
try
{
var s = serialStr.Trim();
int value;
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
value = Convert.ToInt32(s.Substring(2), 16);
else
value = Convert.ToInt32(s, 10);
return World.FindMobile(value);
}
catch
{
return null;
}
}
}
}

View File

@@ -25,6 +25,12 @@ namespace Server.Custom.Bridge
public static int TownCrierMaxActive { get; private set; }
public static int TownCrierMaxDurationSec { get; private set; }
public static bool AdminWriteEnabled { get; private set; }
public static AccessLevel AdminAccessFloor { get; private set; }
public static int AdminBroadcastMaxLength { get; private set; }
public static int AdminReasonMaxLength { get; private set; }
public static int AdminBanMaxDurationSec { get; private set; }
public static bool Enabled { get; private set; }
public static void Configure()
@@ -52,15 +58,37 @@ namespace Server.Custom.Bridge
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
AdminWriteEnabled = Config.Get("Bridge.AdminWriteEnabled", false);
AdminAccessFloor = ParseAccessLevel(Config.Get("Bridge.AdminAccessFloor", "CoOwner"), AccessLevel.CoOwner);
AdminBroadcastMaxLength = Config.Get("Bridge.AdminBroadcastMaxLength", 300);
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
if (QueueCap < 16)
QueueCap = 16;
}
/// <summary>
/// Parses an AccessLevel name from config, case-insensitively, falling back to the given
/// default on anything unrecognized so a typo can never open the floor wider than intended.
/// </summary>
private static AccessLevel ParseAccessLevel(string value, AccessLevel fallback)
{
AccessLevel parsed;
if (!String.IsNullOrEmpty(value) && Enum.TryParse(value.Trim(), true, out parsed) &&
Enum.IsDefined(typeof(AccessLevel), parsed))
return parsed;
Console.WriteLine("[Bridge] unrecognized AdminAccessFloor '{0}', using {1}", value, fallback);
return fallback;
}
public static string Describe()
{
return String.Format(
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds);
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s) adminWrite={7}(floor={8})",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
AdminWriteEnabled, AdminAccessFloor);
}
}
}