diff --git a/docs/ADMIN_CONTROLS.md b/docs/ADMIN_CONTROLS.md index cd177f8..30af617 100644 --- a/docs/ADMIN_CONTROLS.md +++ b/docs/ADMIN_CONTROLS.md @@ -138,8 +138,9 @@ It turns "a staff member must be logged into the game to see the queue" into "th > - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO. > - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token. > - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event. +> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored. > -> **Remaining:** the bidirectional-audit slice (§5.5, `BridgeEvents` normalizer + the one-line `CommandLogging` patch) — not yet started. +> **Phase 1 + the bidirectional-audit slice are complete.** Remaining is downstream (website UI + moderation log) and the later Phase 2 (help-page queue) / Phase 3 work. **Wire in, in order:** diff --git a/patches/BridgeModerationAudit.cs b/patches/BridgeModerationAudit.cs new file mode 100644 index 0000000..2b5c686 --- /dev/null +++ b/patches/BridgeModerationAudit.cs @@ -0,0 +1,133 @@ +using System; + +using Server.Accounting; +using Server.Commands; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// 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. + /// + 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"); + } + + /// + /// 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. + /// + 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); + } + } + + /// [bcast / [bc / [b — a staff broadcast. Its message is the command args. + 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); + } + } + + /// Pulls the account from a CommandLogging.Format rendering's trailing "('account')". + 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)); + } + + /// + /// 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. + /// + 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()); + } + } +} diff --git a/patches/README.md b/patches/README.md index c70f191..d59b8b0 100644 --- a/patches/README.md +++ b/patches/README.md @@ -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 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 +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. diff --git a/patches/commandlogging-event.patch b/patches/commandlogging-event.patch new file mode 100644 index 0000000..37d5f3f --- /dev/null +++ b/patches/commandlogging-event.patch @@ -0,0 +1,33 @@ +--- a/Scripts/Commands/Logging.cs ++++ b/Scripts/Commands/Logging.cs +@@ -75,16 +75,27 @@ + return o; + } + ++ /// ++ /// 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. ++ /// ++ public static event Action 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; + diff --git a/tools/scaffolding/BridgeAuditProbe.cs b/tools/scaffolding/BridgeAuditProbe.cs new file mode 100644 index 0000000..de22467 --- /dev/null +++ b/tools/scaffolding/BridgeAuditProbe.cs @@ -0,0 +1,71 @@ +using System; + +using Server.Accounting; +using Server.Commands; +using Server.Mobiles; + +namespace Server.Custom +{ + /// + /// 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). + /// + 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); + } + } + } +}