feat(admin): plugin write plane — admin.kick/ban/unban/broadcast
Phase 1 (plugin side) of docs/ADMIN_CONTROLS.md: a staff write plane so the website can moderate the live shard. - BridgeAdmin.cs: inbound admin.kick, admin.ban (timed + indefinite), admin.unban, admin.broadcast. Each requires an `actor`, refuses targets at or above AdminAccessFloor (default CoOwner — Owner-only shield), replies admin.ok/admin.error with the reqId echoed, and emits an admin.audit (origin=web) broadcast. Attribution is web:<actor> in the console log and the ban BanDealer tag. Kicking enumerates NetState.Instances so a character-select session is caught too. - BridgeConfig/Bridge.cfg: AdminWriteEnabled (default OFF — opt-in), AdminAccessFloor, broadcast/reason length caps, ban duration clamp. - tools/stub_sidecar_admin.ps1: live smoke-test harness; *.log gitignored. Verified: compiles clean against ServUO (0 err/warn); live run on the seeded shard confirms all four verbs, the audit stream, timed-ban fields, and the Owner-floor refusal, with no exceptions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
This commit is contained in:
@@ -29,6 +29,24 @@ TownCrierMaxLineLength=200
|
|||||||
TownCrierMaxActive=20
|
TownCrierMaxActive=20
|
||||||
TownCrierMaxDurationSec=86400
|
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
|
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
|
||||||
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
|
||||||
# Config.Get returns the default of false when a key is missing, so a deployed
|
# 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,12 @@ namespace Server.Custom.Bridge
|
|||||||
public static int TownCrierMaxActive { get; private set; }
|
public static int TownCrierMaxActive { get; private set; }
|
||||||
public static int TownCrierMaxDurationSec { 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 bool Enabled { get; private set; }
|
||||||
|
|
||||||
public static void Configure()
|
public static void Configure()
|
||||||
@@ -52,15 +58,37 @@ namespace Server.Custom.Bridge
|
|||||||
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
TownCrierMaxActive = Config.Get("Bridge.TownCrierMaxActive", 20);
|
||||||
TownCrierMaxDurationSec = Config.Get("Bridge.TownCrierMaxDurationSec", 86400);
|
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)
|
if (QueueCap < 16)
|
||||||
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()
|
public static string Describe()
|
||||||
{
|
{
|
||||||
return String.Format(
|
return String.Format(
|
||||||
"enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s)",
|
"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);
|
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
|
||||||
|
AdminWriteEnabled, AdminAccessFloor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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