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. Public so the /// account plane (unlink) resolves targets the same way the moderation plane does. /// public 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. Public /// so the account plane (unlink) enforces the identical floor. /// public 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; } } } }