Merge pull request 'feat(admin): staff write plane + help-page queue' (#1) from feature/admin-controls into main
Reviewed-on: UOM/link#1 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -19,6 +19,11 @@ StatSweepSeconds=30
|
||||
DecaySweepSeconds=60
|
||||
EconomySweepSeconds=300
|
||||
|
||||
# Help-page queue poll. The in-game page queue has no EventSink, so it is diffed on this
|
||||
# interval to emit page.new / page.closed / page.updated. A few seconds is fine for a
|
||||
# support queue; the full open queue is also available on demand via pages.snapshot.
|
||||
PageSweepSeconds=5
|
||||
|
||||
# Shown to a player when they run [link. The website page where they enter the code.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
@@ -29,6 +34,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
|
||||
|
||||
362
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Normal file
362
overlay/Scripts/Custom/Bridge/BridgeAdmin.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +159,7 @@ namespace Server.Custom.Bridge
|
||||
case "reload":
|
||||
BridgeConfig.Load();
|
||||
BridgeSweeps.Rearm();
|
||||
BridgePages.Rearm();
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
@@ -181,6 +182,7 @@ namespace Server.Custom.Bridge
|
||||
BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped,
|
||||
BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors);
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace Server.Custom.Bridge
|
||||
public static int StatSweepSeconds { get; private set; }
|
||||
public static int DecaySweepSeconds { get; private set; }
|
||||
public static int EconomySweepSeconds { get; private set; }
|
||||
public static int PageSweepSeconds { get; private set; }
|
||||
|
||||
public static string LinkUrl { get; private set; }
|
||||
|
||||
@@ -25,6 +26,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()
|
||||
@@ -44,6 +51,9 @@ namespace Server.Custom.Bridge
|
||||
StatSweepSeconds = Config.Get("Bridge.StatSweepSeconds", 30);
|
||||
DecaySweepSeconds = Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||
EconomySweepSeconds = Config.Get("Bridge.EconomySweepSeconds", 300);
|
||||
PageSweepSeconds = Config.Get("Bridge.PageSweepSeconds", 5);
|
||||
if (PageSweepSeconds < 1)
|
||||
PageSweepSeconds = 1;
|
||||
|
||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||
|
||||
@@ -52,15 +62,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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
420
overlay/Scripts/Custom/Bridge/BridgePages.cs
Normal file
420
overlay/Scripts/Custom/Bridge/BridgePages.cs
Normal file
@@ -0,0 +1,420 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Engines.Help;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The in-game help-page (support ticket) queue, surfaced to the website.
|
||||
///
|
||||
/// A player who uses the Help button creates a <see cref="PageEntry"/> — sender, message,
|
||||
/// type, location, and (once a staffer claims it) a handler. The queue lives in memory with
|
||||
/// no EventSink, so — like the sweeps in <see cref="BridgeSweeps"/> — it is polled and diffed:
|
||||
/// a page appearing emits <c>page.new</c>, one leaving emits <c>page.closed</c>, and a
|
||||
/// handled-state change emits <c>page.updated</c>. The whole open queue is also available on
|
||||
/// demand via the <c>pages.snapshot</c> request (the backfill a dashboard uses on connect).
|
||||
///
|
||||
/// A page is keyed by its sender's serial: the queue enforces one page per sender
|
||||
/// (PageQueue.Contains), so the sender serial is a stable page id.
|
||||
///
|
||||
/// Inbound <c>page.respond</c> delivers a message to the player exactly as an in-game staff
|
||||
/// response does (online: a gump now; offline: queued for next login), optionally closing the
|
||||
/// page; <c>page.close</c> just removes it. Both run on the Core thread.
|
||||
/// </summary>
|
||||
public static class BridgePages
|
||||
{
|
||||
private static readonly DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Timer _timer;
|
||||
private static long _sweeps, _new, _closed, _updated;
|
||||
|
||||
private struct Seen
|
||||
{
|
||||
public long SentMs;
|
||||
public bool Handled;
|
||||
}
|
||||
|
||||
// sender serial -> last-seen page identity. Core-thread only.
|
||||
private static readonly Dictionary<int, Seen> _seen = new Dictionary<int, Seen>();
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
BridgeBoot.RegisterHandler("pages.snapshot", OnSnapshot);
|
||||
BridgeBoot.RegisterHandler("page.respond", OnRespond);
|
||||
BridgeBoot.RegisterHandler("page.close", OnClose);
|
||||
|
||||
EventSink.ServerStarted += OnServerStarted;
|
||||
}
|
||||
|
||||
private static void OnServerStarted()
|
||||
{
|
||||
Baseline();
|
||||
Rearm();
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the poll timer from current config. Called by `[bridge reload`.</summary>
|
||||
public static void Rearm()
|
||||
{
|
||||
if (_timer != null)
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
var iv = TimeSpan.FromSeconds(BridgeConfig.PageSweepSeconds);
|
||||
_timer = Timer.DelayCall(iv, iv, Sweep);
|
||||
}
|
||||
|
||||
public static string Status()
|
||||
{
|
||||
return String.Format(
|
||||
"pages(sweeps={0} new={1} closed={2} updated={3} open={4})",
|
||||
_sweeps, _new, _closed, _updated, _seen.Count);
|
||||
}
|
||||
|
||||
/// <summary>Seeds _seen from the current queue without emitting, so a restart/reload does not
|
||||
/// re-announce pages already open.</summary>
|
||||
private static void Baseline()
|
||||
{
|
||||
_seen.Clear();
|
||||
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e == null || e.Sender == null)
|
||||
continue;
|
||||
|
||||
_seen[e.Sender.Serial.Value] = new Seen { SentMs = ToMs(e.Sent), Handled = e.Handler != null };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- poll ----
|
||||
|
||||
private static void Sweep()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sweeps++;
|
||||
|
||||
var cur = new Dictionary<int, PageEntry>();
|
||||
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e == null || e.Sender == null)
|
||||
continue;
|
||||
|
||||
cur[e.Sender.Serial.Value] = e;
|
||||
}
|
||||
|
||||
// Closed: keys in _seen no longer present.
|
||||
if (_seen.Count > 0)
|
||||
{
|
||||
List<int> gone = null;
|
||||
|
||||
foreach (var kv in _seen)
|
||||
{
|
||||
if (!cur.ContainsKey(kv.Key))
|
||||
{
|
||||
if (gone == null)
|
||||
gone = new List<int>();
|
||||
gone.Add(kv.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (gone != null)
|
||||
{
|
||||
foreach (var id in gone)
|
||||
{
|
||||
EmitClosed(id);
|
||||
_seen.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New / replaced / handled-state changed.
|
||||
foreach (var kv in cur)
|
||||
{
|
||||
var e = kv.Value;
|
||||
long sentMs = ToMs(e.Sent);
|
||||
bool handled = e.Handler != null;
|
||||
|
||||
Seen prev;
|
||||
if (!_seen.TryGetValue(kv.Key, out prev))
|
||||
{
|
||||
EmitNew(e);
|
||||
}
|
||||
else if (prev.SentMs != sentMs)
|
||||
{
|
||||
// Same sender, different page (they cancelled and re-paged within a tick).
|
||||
EmitClosed(kv.Key);
|
||||
EmitNew(e);
|
||||
}
|
||||
else if (prev.Handled != handled)
|
||||
{
|
||||
EmitUpdated(e);
|
||||
}
|
||||
|
||||
_seen[kv.Key] = new Seen { SentMs = sentMs, Handled = handled };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] page sweep threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- outbound ----
|
||||
|
||||
private static void EmitNew(PageEntry e)
|
||||
{
|
||||
_new++;
|
||||
var sb = BridgeJson.Begin("page.new").Str("pageId", PageId(e));
|
||||
AppendPageTail(sb, e);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void EmitUpdated(PageEntry e)
|
||||
{
|
||||
_updated++;
|
||||
var sb = BridgeJson.Begin("page.updated").Str("pageId", PageId(e));
|
||||
AppendPageTail(sb, e);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void EmitClosed(int serial)
|
||||
{
|
||||
_closed++;
|
||||
BridgeLink.Emit(BridgeJson.Begin("page.closed")
|
||||
.Str("pageId", "0x" + serial.ToString("X"))
|
||||
.End());
|
||||
}
|
||||
|
||||
private static void OnSnapshot(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
|
||||
var sb = BridgeJson.Begin("pages.list");
|
||||
if (reqId != null)
|
||||
sb.Str("reqId", reqId);
|
||||
|
||||
sb.Append(",\"pages\":[");
|
||||
|
||||
bool first = true;
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e == null || e.Sender == null)
|
||||
continue;
|
||||
|
||||
if (!first)
|
||||
sb.Append(',');
|
||||
first = false;
|
||||
|
||||
sb.Append("{\"pageId\":\"0x").Append(e.Sender.Serial.Value.ToString("X")).Append('"');
|
||||
AppendPageTail(sb, e);
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
sb.Append(']');
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
/// <summary>Appends every page field except the opening pageId, each comma-prefixed, so it
|
||||
/// works both after Begin(...) (events) and after a manual `{"pageId":..` (snapshot array).</summary>
|
||||
private static void AppendPageTail(StringBuilder sb, PageEntry e)
|
||||
{
|
||||
sb.Append(",\"sender\":");
|
||||
WriteSender(sb, e.Sender);
|
||||
|
||||
sb.Str("type", e.Type.ToString());
|
||||
sb.Str("message", e.Message ?? "");
|
||||
sb.Str("map", e.PageMap == null ? null : e.PageMap.Name);
|
||||
sb.Num("x", e.PageLocation.X);
|
||||
sb.Num("y", e.PageLocation.Y);
|
||||
sb.Num("z", e.PageLocation.Z);
|
||||
sb.Num("sentMs", ToMs(e.Sent));
|
||||
sb.Bool("handled", e.Handler != null);
|
||||
|
||||
if (e.Handler != null)
|
||||
sb.Str("handler", e.Handler.Name);
|
||||
}
|
||||
|
||||
private static void WriteSender(StringBuilder sb, Mobile m)
|
||||
{
|
||||
if (m == null)
|
||||
{
|
||||
sb.Append("null");
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||
sb.Append(",\"name\":");
|
||||
BridgeJson.Escape(sb, m.Name ?? "");
|
||||
|
||||
var acct = m.Account as Account;
|
||||
if (acct != null)
|
||||
{
|
||||
sb.Append(",\"acct\":");
|
||||
BridgeJson.Escape(sb, acct.Username);
|
||||
|
||||
var webId = BridgeAccountLink.WebIdFor(acct);
|
||||
if (webId != null)
|
||||
{
|
||||
sb.Append(",\"webId\":");
|
||||
BridgeJson.Escape(sb, webId);
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append('}');
|
||||
}
|
||||
|
||||
// ---- inbound ----
|
||||
|
||||
/// <summary>page.respond {reqId, pageId, message, close?}. Delivers a staff response to the
|
||||
/// player and optionally closes the page.</summary>
|
||||
private static void OnRespond(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var pageId = BridgeJson.GetString(o, "pageId");
|
||||
var message = BridgeJson.GetString(o, "message");
|
||||
bool close = GetBool(o, "close");
|
||||
|
||||
if (String.IsNullOrEmpty(message))
|
||||
{
|
||||
Err(reqId, "respond", pageId, "missing message");
|
||||
return;
|
||||
}
|
||||
|
||||
var e = Find(pageId);
|
||||
if (e == null)
|
||||
{
|
||||
Err(reqId, "respond", pageId, "unknown page");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Same delivery as an in-game staff response: a null handler shows as "Staff".
|
||||
// ResponseEntry queues for an offline sender; SendGump delivers now if online.
|
||||
var re = new ResponseEntry(e.Sender, null, message);
|
||||
re.SendGump();
|
||||
|
||||
if (close)
|
||||
PageQueue.Remove(e);
|
||||
|
||||
Ok(reqId, "respond", pageId, close);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] page.respond threw: {0}", ex.Message);
|
||||
Err(reqId, "respond", pageId, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>page.close {reqId, pageId}. Removes the page from the queue.</summary>
|
||||
private static void OnClose(Dictionary<string, object> o)
|
||||
{
|
||||
var reqId = BridgeJson.GetString(o, "reqId");
|
||||
var pageId = BridgeJson.GetString(o, "pageId");
|
||||
|
||||
var e = Find(pageId);
|
||||
if (e == null)
|
||||
{
|
||||
Err(reqId, "close", pageId, "unknown page");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PageQueue.Remove(e);
|
||||
Ok(reqId, "close", pageId, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] page.close threw: {0}", ex.Message);
|
||||
Err(reqId, "close", pageId, "internal error");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Ok(string reqId, string action, string pageId, bool closed)
|
||||
{
|
||||
var sb = BridgeJson.Begin("page.ok");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action);
|
||||
if (pageId != null) sb.Str("pageId", pageId);
|
||||
sb.Bool("closed", closed);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
private static void Err(string reqId, string action, string pageId, string reason)
|
||||
{
|
||||
var sb = BridgeJson.Begin("page.error");
|
||||
if (reqId != null) sb.Str("reqId", reqId);
|
||||
sb.Str("action", action);
|
||||
if (pageId != null) sb.Str("pageId", pageId);
|
||||
sb.Str("reason", reason);
|
||||
BridgeLink.Emit(sb.End());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static PageEntry Find(string pageId)
|
||||
{
|
||||
int serial;
|
||||
if (!TryParseSerial(pageId, out serial))
|
||||
return null;
|
||||
|
||||
foreach (PageEntry e in PageQueue.List)
|
||||
{
|
||||
if (e != null && e.Sender != null && e.Sender.Serial.Value == serial)
|
||||
return e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string PageId(PageEntry e)
|
||||
{
|
||||
return "0x" + e.Sender.Serial.Value.ToString("X");
|
||||
}
|
||||
|
||||
private static long ToMs(DateTime dt)
|
||||
{
|
||||
return (long)(dt.ToUniversalTime() - Epoch).TotalMilliseconds;
|
||||
}
|
||||
|
||||
private static bool GetBool(Dictionary<string, object> o, string key)
|
||||
{
|
||||
object v;
|
||||
if (o != null && o.TryGetValue(key, out v) && v is bool)
|
||||
return (bool)v;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseSerial(string s, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (String.IsNullOrEmpty(s))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
s = s.Trim();
|
||||
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
value = Convert.ToInt32(s.Substring(2), 16);
|
||||
else
|
||||
value = Convert.ToInt32(s, 10);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
patches/BridgeModerationAudit.cs
Normal file
133
patches/BridgeModerationAudit.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Forwards IN-GAME uses of the write-plane verbs to the website as admin.audit
|
||||
/// (origin=in-game), so the site's moderation log is complete regardless of whether an action
|
||||
/// came from the website or a staff member in the game client. See docs/ADMIN_CONTROLS.md §5.5.
|
||||
///
|
||||
/// Two sources, mirroring how the shard records each:
|
||||
/// - ban / kick: resolved with their target inside the stock generic command, which logs a
|
||||
/// line via CommandLogging.WriteLine. We tap the new CommandLogging.OnWrite event and
|
||||
/// parse the "... banning|kicking <target> ('acct')" line for action and target.
|
||||
/// - broadcast: [bcast carries its message as command args and hits no target, so
|
||||
/// EventSink.Command already sees it whole; we reshape it.
|
||||
///
|
||||
/// Not in overlay/: it references CommandLogging.OnWrite, which exists only after
|
||||
/// patches/commandlogging-event.patch is applied. Shipping it in overlay/ would break the
|
||||
/// build on an unpatched install — the same reason BridgeVendorSale.cs lives in patches/.
|
||||
///
|
||||
/// Runs on the Core thread (both sources raise synchronously in the command path). Every body
|
||||
/// is wrapped: a bridge exception must never escape into a staff command.
|
||||
/// </summary>
|
||||
public static class BridgeModerationAudit
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
if (!BridgeConfig.Enabled)
|
||||
return;
|
||||
|
||||
CommandLogging.OnWrite += OnCommandLog; // ban / kick (resolved, with target)
|
||||
EventSink.Command += OnStaffCommand; // broadcast (carries its message)
|
||||
|
||||
Console.WriteLine("[Bridge] in-game moderation audit attached");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The stock ban/kick commands log "<level> <from> ('acct') banning|kicking
|
||||
/// <target> ('acct')" (Commands.cs KickCommand). Match the verb, take the target's
|
||||
/// account from the trailing "('acct')", and forward. Non-moderation lines are ignored.
|
||||
/// </summary>
|
||||
private static void OnCommandLog(Mobile from, string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (from == null || text == null)
|
||||
return;
|
||||
|
||||
string action;
|
||||
int at;
|
||||
|
||||
if ((at = text.IndexOf(" banning ", StringComparison.Ordinal)) >= 0)
|
||||
action = "ban";
|
||||
else if ((at = text.IndexOf(" kicking ", StringComparison.Ordinal)) >= 0)
|
||||
action = "kick";
|
||||
else
|
||||
return;
|
||||
|
||||
var tail = text.Substring(at + 9); // past " banning " / " kicking "
|
||||
Emit(action, from, ExtractAccount(tail), text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] mod-audit log parse threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>[bcast / [bc / [b — a staff broadcast. Its message is the command args.</summary>
|
||||
private static void OnStaffCommand(CommandEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (e == null || e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player)
|
||||
return;
|
||||
|
||||
var cmd = e.Command;
|
||||
if (cmd == null)
|
||||
return;
|
||||
|
||||
cmd = cmd.ToLowerInvariant();
|
||||
if (cmd != "bcast" && cmd != "bc" && cmd != "b")
|
||||
return;
|
||||
|
||||
Emit("broadcast", e.Mobile, null, e.ArgString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[Bridge] mod-audit command threw: {0}", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pulls the account from a CommandLogging.Format rendering's trailing "('account')".</summary>
|
||||
private static string ExtractAccount(string formatted)
|
||||
{
|
||||
if (formatted == null)
|
||||
return null;
|
||||
|
||||
int open = formatted.LastIndexOf("('", StringComparison.Ordinal);
|
||||
if (open < 0)
|
||||
return null;
|
||||
|
||||
int close = formatted.IndexOf("')", open, StringComparison.Ordinal);
|
||||
if (close < 0)
|
||||
return null;
|
||||
|
||||
return formatted.Substring(open + 2, close - (open + 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits admin.audit with origin=in-game. The actor is the staff member's account name
|
||||
/// (no "web:" prefix — that, plus the origin field, is how the website tells the two
|
||||
/// sources apart). `detail` carries the raw context so nothing is lost if a target could
|
||||
/// not be parsed.
|
||||
/// </summary>
|
||||
private static void Emit(string action, Mobile actor, string target, string detail)
|
||||
{
|
||||
var acct = actor.Account as Account;
|
||||
var actorName = acct != null ? acct.Username : actor.Name;
|
||||
|
||||
BridgeLink.Emit(BridgeJson.Begin("admin.audit")
|
||||
.Str("origin", "in-game")
|
||||
.Str("action", action)
|
||||
.Str("actor", actorName)
|
||||
.Str("target", target)
|
||||
.Str("detail", detail)
|
||||
.End());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,24 @@ Both patches are `git`-format and verified with `git apply --check` against stoc
|
||||
|
||||
Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
|
||||
|
||||
## In-game moderation audit (admin controls §5.5)
|
||||
|
||||
So the website's moderation log stays complete, in-game uses of the write-plane verbs are forwarded to it as `admin.audit` (`origin:"in-game"`). Broadcasts already surface through `EventSink.Command`, but resolved bans/kicks only carry their target inside the command's own `CommandLogging.WriteLine` call — which has no event to subscribe to. One small change fixes that:
|
||||
|
||||
| Item | Target | What |
|
||||
|------|--------|------|
|
||||
| `commandlogging-event.patch` | `Scripts/Commands/Logging.cs` | Adds a `public static event Action<Mobile,string> OnWrite`, raised in `WriteLine` **before** the `m_Enabled` guard so it fires even when file logging is off. |
|
||||
| `BridgeModerationAudit.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber: taps `OnWrite` for ban/kick (parsing the target out of the log line) and `EventSink.Command` for `[bcast`, emitting `admin.audit`. **Not** in `overlay/` because it references `CommandLogging.OnWrite`, which does not exist until the patch is applied. |
|
||||
|
||||
```bash
|
||||
cd <servuo root>
|
||||
git apply --check patches/commandlogging-event.patch # dry run
|
||||
git apply patches/commandlogging-event.patch
|
||||
cp patches/BridgeModerationAudit.cs Scripts/Custom/Bridge/BridgeModerationAudit.cs
|
||||
```
|
||||
|
||||
`Logging.cs` is a **Scripts** file, so this is picked up by the dynamic script build — no core/solution rebuild needed (unlike the Phase 7 `EventSink.cs` patch). Verified end-to-end with `tools/scaffolding/BridgeAuditProbe.cs` (gated by `Bridge.AuditProbeOnStart`): a genuine `[bcast` plus simulated ban/kick log lines produced the expected `admin.audit` frames, target parsed, with non-moderation lines ignored.
|
||||
|
||||
## Note on `Scripts.csproj`
|
||||
|
||||
Phase 0 modifies an existing file but ships as a whole-file overlay (`overlay/Scripts/Scripts.csproj`) because the file is small, we own it operationally, and a copy is less fragile than a diff against a project file. Revisit if it starts drifting from upstream.
|
||||
|
||||
33
patches/commandlogging-event.patch
Normal file
33
patches/commandlogging-event.patch
Normal file
@@ -0,0 +1,33 @@
|
||||
--- a/Scripts/Commands/Logging.cs
|
||||
+++ b/Scripts/Commands/Logging.cs
|
||||
@@ -75,16 +75,27 @@
|
||||
return o;
|
||||
}
|
||||
|
||||
+ /// <summary>
|
||||
+ /// Raised for every staff command log line — even when file logging is disabled — so an
|
||||
+ /// out-of-process consumer sees resolved staff actions. The uo-link bridge subscribes to
|
||||
+ /// forward moderation actions (ban/kick, with the resolved target) to the website.
|
||||
+ /// </summary>
|
||||
+ public static event Action<Mobile, string> OnWrite;
|
||||
+
|
||||
public static void WriteLine(Mobile from, string format, params object[] args)
|
||||
{
|
||||
- if (!m_Enabled)
|
||||
- return;
|
||||
-
|
||||
WriteLine(from, String.Format(format, args));
|
||||
}
|
||||
|
||||
public static void WriteLine(Mobile from, string text)
|
||||
{
|
||||
+ var onWrite = OnWrite;
|
||||
+ if (onWrite != null)
|
||||
+ {
|
||||
+ try { onWrite(from, text); }
|
||||
+ catch { }
|
||||
+ }
|
||||
+
|
||||
if (!m_Enabled)
|
||||
return;
|
||||
|
||||
71
tools/scaffolding/BridgeAuditProbe.cs
Normal file
71
tools/scaffolding/BridgeAuditProbe.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises the in-game moderation-audit forwarding (BridgeModerationAudit) without a game
|
||||
/// client, so the CommandLogging.OnWrite patch and the admin.audit normalizer can be verified
|
||||
/// end-to-end from a stub sidecar.
|
||||
///
|
||||
/// - Broadcast is a *genuine* trigger: CommandSystem.Handle runs [bcast, which raises
|
||||
/// EventSink.Command exactly as a staff keystroke would.
|
||||
/// - Ban/kick can't complete headlessly (they arm a target cursor with no client to click),
|
||||
/// so we call CommandLogging.WriteLine with the stock KickCommand line format — the same
|
||||
/// call that command makes at Commands.cs:1211, which is the point we tap.
|
||||
/// - A non-moderation log line confirms the normalizer ignores everything else.
|
||||
///
|
||||
/// Test scaffolding. Never deployed. Gated behind Bridge.AuditProbeOnStart (absent in a
|
||||
/// shipped Bridge.cfg, so Config.Get returns false and it never runs in production).
|
||||
/// </summary>
|
||||
public static class BridgeAuditProbe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
if (Config.Get("Bridge.AuditProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
var staffAcct = Accounting.Accounts.GetAccount("whitlocktech") as Account;
|
||||
var targetAcct = Accounting.Accounts.GetAccount("seed_010") as Account;
|
||||
|
||||
var from = staffAcct == null ? null : staffAcct[0];
|
||||
var target = targetAcct == null ? null : targetAcct[0];
|
||||
|
||||
if (from == null || target == null)
|
||||
{
|
||||
Console.WriteLine("[AuditProbe] need whitlocktech + seed_010 chars; seed the world first");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine("[AuditProbe] genuine broadcast via [bcast ...");
|
||||
CommandSystem.Handle(from, CommandSystem.Prefix + "bcast in-game audit probe");
|
||||
|
||||
Console.WriteLine("[AuditProbe] simulating a resolved ban log line ...");
|
||||
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
|
||||
from.AccessLevel, CommandLogging.Format(from), "banning", CommandLogging.Format(target));
|
||||
|
||||
Console.WriteLine("[AuditProbe] simulating a resolved kick log line ...");
|
||||
CommandLogging.WriteLine(from, "{0} {1} {2} {3}",
|
||||
from.AccessLevel, CommandLogging.Format(from), "kicking", CommandLogging.Format(target));
|
||||
|
||||
Console.WriteLine("[AuditProbe] a non-moderation line (should be ignored) ...");
|
||||
CommandLogging.WriteLine(from, "{0} {1} used command '{2}'",
|
||||
from.AccessLevel, CommandLogging.Format(from), "Go 1 1 0");
|
||||
|
||||
Console.WriteLine("[AuditProbe] done");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[AuditProbe] FAILED: " + ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
tools/scaffolding/BridgePageProbe.cs
Normal file
61
tools/scaffolding/BridgePageProbe.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Engines.Help;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Puts a couple of genuine PageEntry tickets into the help-page queue so BridgePages
|
||||
/// (poll/stream + snapshot + respond/close) can be verified without a game client.
|
||||
///
|
||||
/// The enqueue is real (PageQueue.Enqueue). The only accommodation for the missing client:
|
||||
/// each entry's InternalTimer would remove the page on its first tick because the sender has
|
||||
/// no NetState (PageQueue.cs:167 treats "no NetState" as a logout), so we call PageEntry.Stop
|
||||
/// to keep the ticket in the queue for the test. Everything the bridge does — detect, snapshot,
|
||||
/// respond, close — then operates on real queue entries.
|
||||
///
|
||||
/// Test scaffolding. Never deployed. Gated behind Bridge.PageProbeOnStart.
|
||||
/// </summary>
|
||||
public static class BridgePageProbe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
if (Config.Get("Bridge.PageProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
Enqueue("seed_030", "My quest is stuck, please help.", PageType.Stuck);
|
||||
Enqueue("seed_031", "Found a bug with a player vendor.", PageType.Bug);
|
||||
Console.WriteLine("[PageProbe] done");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("[PageProbe] FAILED: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Enqueue(string account, string message, PageType type)
|
||||
{
|
||||
var acct = Accounting.Accounts.GetAccount(account) as Account;
|
||||
var sender = acct == null ? null : acct[0];
|
||||
|
||||
if (sender == null)
|
||||
{
|
||||
Console.WriteLine("[PageProbe] {0} has no character in slot 0; seed the world first", account);
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new PageEntry(sender, message, type);
|
||||
PageQueue.Enqueue(entry);
|
||||
entry.Stop(); // keep it in the queue despite the offline sender
|
||||
|
||||
Console.WriteLine("[PageProbe] enqueued {0} page for {1} (0x{2:X})",
|
||||
type, account, sender.Serial.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
tools/stub_sidecar_admin.ps1
Normal file
78
tools/stub_sidecar_admin.ps1
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user