Files
link/overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Claude 808f6ab68b feat(protocol2): website account provisioning & unlinking (Part A)
Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the
website can create game accounts and unlink them, gated by a shard-wide
signup mode. The existing [link flow is unchanged.

Overlay:
- BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized
  falls back to game), AccountCreateEnabled (mode-following default),
  RequireIpForCreate, name/password caps, and a boot warning when the core
  Accounts.AutoCreateAccounts setting contradicts the mode.
- BridgeAccounts (new): account.create (mode gate, actor required, char-safety
  mirrored from AccountHandler, collision check, per-IP cap via CanCreate/
  LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link,
  account.audit; password never logged or echoed) and account.unlink (Owner
  floor via BridgeAdmin.Protected, clears the tag).
- BridgeAccountLink: in-game [unlink command, emits account.unlinked.
- BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse.

Sidecar:
- POST /accounts/create, DELETE /link/:account, respond_account status mapping
  (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400).
- store.record_unlink drops the mirrored link row.
- PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2).

Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429;
PROTOCOL_2.md Part A marked built.

Verified: sidecar cargo check clean; overlay compiles in the full ServUO
Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 07:42:06 -05:00

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