diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index d85df63..7a60eeb 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -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
diff --git a/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
new file mode 100644
index 0000000..1171713
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
@@ -0,0 +1,362 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Accounting;
+using Server.Network;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// 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 : 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.
+ ///
+ 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 ----
+
+ /// Disconnects every live session of the target account. Target by serial or account.
+ private static void OnKick(Dictionary 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 ----
+
+ ///
+ /// 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.
+ ///
+ private static void OnBan(Dictionary 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 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 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 ----
+
+ /// Common gate: the write plane must be enabled and an actor must be present.
+ 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());
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Reads and length-clamps the optional reason string.
+ private static string Reason(Dictionary 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 ----
+
+ ///
+ /// 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.
+ ///
+ private static Account ResolveTargetAccount(Dictionary 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ private static int KickAccountSessions(Account acct)
+ {
+ var doomed = new List();
+
+ 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;
+ }
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 5d39760..ece27ff 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -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;
}
+ ///
+ /// 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.
+ ///
+ 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);
}
}
}
diff --git a/tools/stub_sidecar_admin.ps1 b/tools/stub_sidecar_admin.ps1
new file mode 100644
index 0000000..3c02424
--- /dev/null
+++ b/tools/stub_sidecar_admin.ps1
@@ -0,0 +1,78 @@
+param(
+ [int] $Port = 7788,
+ [string] $Log = "$PSScriptRoot\sc_admin.log"
+)
+
+# Phase-1 admin write-plane harness. Connects as the sidecar, waits for the shard,
+# fires admin.* commands covering the happy paths and every guard, logs the replies.
+# Requires Bridge.cfg AdminWriteEnabled=true and the seeded world (seed_00x accounts).
+
+function Say($msg) {
+ for ($i = 0; $i -lt 5; $i++) {
+ try { "$msg" | Out-File -FilePath $Log -Append -Encoding utf8; return }
+ catch { Start-Sleep -Milliseconds 100 }
+ }
+}
+
+"" | Out-File -FilePath $Log -Encoding utf8
+Say "[admin] starting on 127.0.0.1:$Port"
+
+$listener = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, $Port)
+$listener.Server.SetSocketOption('Socket', 'ReuseAddress', $true)
+
+$bound = $false
+for ($i = 0; $i -lt 30 -and -not $bound; $i++) {
+ try { $listener.Start(); $bound = $true }
+ catch { Start-Sleep -Seconds 1 }
+}
+if (-not $bound) { Say "[admin] could not bind"; exit 1 }
+
+Say "[admin] listening"
+$client = $listener.AcceptTcpClient()
+Say "[admin] === shard connected ==="
+
+$stream = $client.GetStream()
+$reader = New-Object System.IO.StreamReader($stream)
+$writer = New-Object System.IO.StreamWriter($stream)
+$writer.AutoFlush = $true
+
+Start-Sleep -Milliseconds 500
+
+$requests = @(
+ # happy path, no target needed
+ '{"kind":"admin.broadcast","reqId":"a-bcast","actor":"whitlocktech","text":"uo-link admin test broadcast"}',
+ # ban an offline seed account (timed), then unban
+ '{"kind":"admin.ban","reqId":"a-ban","actor":"whitlocktech","account":"seed_001","durationSec":3600,"reason":"harness test"}',
+ '{"kind":"admin.unban","reqId":"a-unban","actor":"whitlocktech","account":"seed_001"}',
+ # kick an offline account -> should succeed with sessions:0
+ '{"kind":"admin.kick","reqId":"a-kick","actor":"whitlocktech","account":"seed_002"}',
+ # floor: whitlocktech is Owner -> must be refused
+ '{"kind":"admin.ban","reqId":"a-floor","actor":"whitlocktech","account":"whitlocktech"}',
+ # unknown target
+ '{"kind":"admin.ban","reqId":"a-unknown","actor":"whitlocktech","account":"does_not_exist"}',
+ # missing actor -> refused by the shared gate
+ '{"kind":"admin.ban","reqId":"a-noactor","account":"seed_003"}'
+)
+
+foreach ($r in $requests) {
+ $writer.WriteLine($r)
+ Say "[admin] -> $r"
+ Start-Sleep -Milliseconds 400
+}
+
+# Drain greedily: block on ReadLine with an idle timeout so a buffered burst is fully read
+# (the DataAvailable-gated pattern drops the tail of a burst that a StreamReader pre-buffers).
+$stream.ReadTimeout = 2500
+try {
+ while ($true) {
+ $line = $reader.ReadLine()
+ if ($null -eq $line) { break }
+ Say "[admin] <- $line"
+ }
+} catch {
+ Say "[admin] read window closed (idle)"
+}
+
+Say "[admin] done"
+$client.Close()
+$listener.Stop()