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()