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
363 lines
13 KiB
C#
363 lines
13 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|
|
}
|