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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ obj/
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
*.log
|
||||
|
||||
300
docs/ADMIN_CONTROLS.md
Normal file
300
docs/ADMIN_CONTROLS.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# Administrative Controls — Research & Integration Plan
|
||||
|
||||
**Status:** Research + design. No code written yet.
|
||||
**Date:** 2026-07-12
|
||||
**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**.
|
||||
**Companion to** [`PLAN.md`](PLAN.md) (the read/event plane) and [`INTEGRATION.md`](INTEGRATION.md) (the website API). This document covers the **write plane**: staff actions the website should be able to take against the live shard.
|
||||
|
||||
---
|
||||
|
||||
## 1. The question
|
||||
|
||||
The bridge today is almost entirely *outbound*. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs:
|
||||
|
||||
| Verb | File | What it does |
|
||||
|------|------|--------------|
|
||||
| `ping` | `BridgeBoot.cs:139` | Liveness echo. |
|
||||
| `link.confirm` | `BridgeAccountLink.cs` | Ties a game account to a website user. |
|
||||
| `towncrier.add` / `towncrier.remove` | `BridgeTownCrier.cs` | Publishes news to the in-game criers. |
|
||||
|
||||
None of these are *moderation*. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it.
|
||||
|
||||
**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form.
|
||||
|
||||
---
|
||||
|
||||
## 2. How ServUO admin controls actually work
|
||||
|
||||
Four mechanisms, all of which the bridge must respect or reuse.
|
||||
|
||||
### 2.1 The AccessLevel ladder
|
||||
|
||||
`Server/Mobile.cs:431`:
|
||||
|
||||
```
|
||||
Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner
|
||||
```
|
||||
|
||||
Every command is gated on a minimum level (`CommandSystem.Register(name, level, handler)`). This ladder is the shard's whole authorization model. **The bridge has no Mobile and therefore no natural place on this ladder** — see §5, the attribution problem.
|
||||
|
||||
### 2.2 The command system
|
||||
|
||||
Two registration styles:
|
||||
|
||||
- **Simple commands** — `CommandSystem.Register("Save", AccessLevel.Administrator, handler)`. The bridge already uses this for `[bridge` (`BridgeBoot.cs:44`, Administrator-gated).
|
||||
- **Generic/target commands** — `BaseCommand` subclasses in `Commands/Generic/Commands/Commands.cs`, registered as objects (`KillCommand`, `KickCommand`, `FirewallCommand`, …). These are built to be *targeted* in-game (click a mobile). Their **logic** is reusable from the bridge; their **targeting/gump plumbing** is not.
|
||||
|
||||
### 2.3 Command logging (the existing audit trail)
|
||||
|
||||
Staff actions call `CommandLogging.WriteLine(from, ...)`, which writes `Logs/Commands/*.log` **and** is the source of the bridge's own `audit.command` / `audit.set` events (`INTEGRATION.md` §4). Any web-initiated action **must** feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised.
|
||||
|
||||
### 2.4 Account model (the moderation state)
|
||||
|
||||
`Scripts/Accounting/Account.cs`. The durable, offline-capable levers live here:
|
||||
|
||||
| Lever | API | Notes |
|
||||
|-------|-----|-------|
|
||||
| Ban (indefinite) | `acct.Banned = true; acct.SetUnspecifiedBan(from)` | `Account.cs:440`, `:1098` |
|
||||
| Ban (timed) | `acct.SetBanTags(from, DateTime.UtcNow, TimeSpan)` then `acct.Banned = true` | `:1103`; `Banned` getter auto-clears when the window lapses (`:454`) |
|
||||
| Unban | `acct.Banned = false; acct.SetUnspecifiedBan(null)` | clears the tags |
|
||||
| Read ban | `acct.GetBanTags(out when, out dur)` | `:1133` |
|
||||
| Staff level | `acct.AccessLevel = …` | `:557` — promotes/demotes a whole account |
|
||||
| Young status | `acct.Young` | `:471` |
|
||||
|
||||
Account-level state persists and applies whether or not the player is online. Per-*mobile* state (below) generally requires the target resident.
|
||||
|
||||
---
|
||||
|
||||
## 3. Candidate controls
|
||||
|
||||
Grouped by subsystem. **Tier**: **A** = wire in first, **B** = second wave, **N** = never expose remotely. **~~H~~ = excluded.** The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and **cut from the roadmap entirely** per the 2026-07-12 decision — their rows are kept below for the record but will **not** be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph.
|
||||
|
||||
### 3.1 Session control (target online)
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Kick** | `[Kick` → `KickCommand`, `Commands.cs:1170` | `targ.NetState?.Dispose()` | **A** | Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action. |
|
||||
| **Firewall (IP block)** | `[Firewall`, `Commands.cs:1125` | `Firewall.Add(state.Address)` | **H** | Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp. |
|
||||
| **Locate / who** | `[Where`, `[Client` | already have `char.vitals`/`mob.login` | — | Effectively already covered by the event plane. |
|
||||
|
||||
### 3.2 Account moderation (works offline)
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Ban (indefinite)** | `[Ban` → `KickCommand(ban:true)`, `Commands.cs:1225` | `Banned=true; SetUnspecifiedBan` + kick live sessions | **A** | The headline verb. Note the in-game path *also* opens `BanDurationGump` — we replace that with an explicit duration in the request. |
|
||||
| **Ban (timed)** | (gump) | `SetBanTags(actor, now, dur); Banned=true` | **A** | Duration in the request body; auto-expires. |
|
||||
| **Unban** | property edit | `Banned=false; SetUnspecifiedBan(null)` | **A** | |
|
||||
| **Mute / squelch** | property `Squelched` | `mob.Squelched = true` (`Mobile.cs:5807`) | **B** | Per-**character**, not per-account. **Persists** across relog + restart (serialized, `Mobile.cs:6489`/`:6013`); works on offline chars too. Mute an account = squelch each resident character (§7.1). |
|
||||
| **Page-mute** | `PagingSquelched` | set on `PlayerMobile` | **B** | Stops help-page spam without a full mute. |
|
||||
| **Set access level** | property `AccessLevel` | `acct.AccessLevel = …` | **H** | Promoting staff from a web UI is a serious privilege path. Gate hard, or omit. |
|
||||
| **Comments / notes** | account comments | `acct.Comments` | **B** | A staff notes field — pairs naturally with a web moderation panel. |
|
||||
|
||||
### 3.3 Player actions (target online)
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Kill / Resurrect** | `[Kill` / `[Res`, `Commands.cs:966` | `mob.Kill()` / `mob.Resurrect()` | **H** | Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks. |
|
||||
| **Teleport / Bring** | `[Go`, `[Move`, `[Tele` | `mob.MoveToWorld(p, map)` | **B** | "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does. |
|
||||
| **Jail** | region only — `Regions/Jail.cs`, **no stock command** | custom: move to jail point (+ flag) | **H** | Needs us to *build* the action (pick a jail location, decide on release). Region exists; the verb does not. |
|
||||
| **Hide / Unhide** | `[Hide`, `Commands.cs:1066` | `mob.Hidden = bool` | **N** | No remote use case. |
|
||||
| **Set/Get property** | `[Set` / `[Get` / `[Props` | reflection | **N** | Arbitrary property writes = arbitrary power. Keep in-client. |
|
||||
| **Give item / gold** | `[Add`, `Bank` | construct + place | **H** | Compensation flows are real but this is a duplication/economy risk; if wanted, expose *specific* curated grants, never `[add` by type. |
|
||||
|
||||
### 3.4 Support: the help-page queue ★
|
||||
|
||||
`Scripts/Services/Help/PageQueue.cs`. When a player uses the in-game Help button they create a `PageEntry` (`Bug`, `Stuck`, `Account`, `Question`, `Suggestion`, `Harassment`, …) carrying **sender, message, type, location/map, timestamp, and assigned handler**. `PageQueue.List` is the live queue; `PageQueue.Enqueue/Remove` mutate it; a staff reply reaches the player via `ResponseEntry` → `MessageSentGump`.
|
||||
|
||||
This is the single **best** tie-in and deserves its own slice of work:
|
||||
|
||||
- **Stream** new pages as a `page.new` event and removals as `page.closed`.
|
||||
- **Snapshot** the open queue over REST (`GET /pages`).
|
||||
- **Respond** from the website (`POST /pages/{id}/respond`) → delivers a message to the player in-game, exactly like a staff member typing a response.
|
||||
- **Close / assign** a page.
|
||||
|
||||
It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier **A**, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb.
|
||||
|
||||
### 3.5 Broadcast & messaging
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Server broadcast** | `[BCast`, `Handlers.cs` | `World.Broadcast(hue, ascii, text)` | **A** | Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value. |
|
||||
| **Staff message (SMsg)** | `[SMsg`, `Handlers.cs` | send to online staff | **B** | "Post to staff channel" from the site. |
|
||||
| **Tell / private msg** | `[Tell` | `mob.SendMessage` | **B** | Message one player from the web (e.g. auto-reply to a page). |
|
||||
|
||||
### 3.6 World / server operations
|
||||
|
||||
| Control | In-game | Bridge API | Tier | Notes |
|
||||
|---------|---------|-----------|------|-------|
|
||||
| **Save** | `[Save`, `Handlers.cs` (Administrator) | `AutoSave.Save()` | **B** | Trigger a world save from a deploy/admin panel. Emits `world.save.*` we already stream. |
|
||||
| **Background save** | `[BGSave` | | **B** | Non-blocking variant. |
|
||||
| **Shutdown / restart** | console | process-level | **N** | Do this at the process/host layer, not through a game plugin. |
|
||||
| **Freeze / Wipe / DecorateDelete / TelGen** | various | — | **N** | Destructive world-building. In-client only. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Roadmap (decided)
|
||||
|
||||
> **Build status (2026-07-13):** Phase 1 **plugin side is built and live-verified** on the seeded shard — `BridgeAdmin.cs` + config, branch `feature/admin-controls`. All four verbs, the `web:<actor>` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner account was refused) confirmed end-to-end against a booted ServUO, no exceptions. **Remaining for Phase 1 to be usable from the site:** sidecar REST routes (`sidecar/src/web.rs`) and the `INTEGRATION.md` docs. The bidirectional-audit slice (§5.5, incl. the `CommandLogging` patch) is not yet started.
|
||||
|
||||
**Wire in, in order:**
|
||||
|
||||
1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe.
|
||||
2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb.
|
||||
3. **Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save. Add as the web moderation panel matures.
|
||||
|
||||
Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5).
|
||||
|
||||
**Excluded — will not be built:** firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary `[set`/`[get`, `[add`, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client.
|
||||
|
||||
---
|
||||
|
||||
## 5. Authorization & attribution (decided)
|
||||
|
||||
Every in-game moderation command carries a `Mobile from` — the staff member — used for two things the bridge has no natural source for:
|
||||
|
||||
1. **Audit** — `CommandLogging.WriteLine(from, …)` and the `SetBanTags(from, …)` "BanDealer" tag record *who did it*.
|
||||
2. **Authorization** — e.g. `KickCommand` refuses unless `from.AccessLevel > targ.AccessLevel` (`Commands.cs:1200`), so a GM can't ban an Admin.
|
||||
|
||||
The resolved model:
|
||||
|
||||
**Authorization lives on the website.** The website gates these commands behind its own **admin-only** roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary.
|
||||
|
||||
**Sidecar commands carry `CoOwner`-level authority on the shard.** Because the website has already authenticated and authorized the staff user, an inbound `admin.*` is applied as if issued by a synthetic `CoOwner` — the second-highest rung (`Server/Mobile.cs:431`: only `Owner` is above it). This cleanly satisfies the `from.AccessLevel > targ.AccessLevel` guard for every ordinary target.
|
||||
|
||||
**The one shard-side floor: never touch the Owner.** Even at CoOwner authority, an `admin.*` command **refuses any target account whose `AccessLevel >= CoOwner`.** That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). *Note the consequence, plainly:* this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in `Bridge.cfg` (`AdminAccessFloor`).
|
||||
|
||||
**Attribution is an explicit `web:<actor>` string.** Every `admin.*` request carries a required `actor` field — the website username/id of the staff member. The shard:
|
||||
- logs it to the **server console** as `[Bridge][admin] web:<actor> <action> …`. *(Note, corrected during implementation: `CommandLogging.WriteLine` cannot be reused for web actions — it dereferences `from.NetState`/`from.Account`/`from.AccessLevel` (`Scripts/Commands/Logging.cs:93-103`) and there is no staff `Mobile`. So web actions do **not** land in `Logs/Commands/`; the console line plus the `admin.audit` stream plus the website's own log are their durable record. `Logs/Commands/` remains the record for **in-game** staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)*
|
||||
- stores `web:<actor>` in the ban "BanDealer" tag (`SetBanTags` wants a `Mobile from`; we pass `null` for the Mobile and set the tag ourselves — no core edit),
|
||||
- echoes it back in an `admin.audit` event (§5.5) so the website's own moderation record and the game's audit agree.
|
||||
|
||||
**The website keeps its own durable record.** Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's `CommandLogging` + `admin.audit` are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions.
|
||||
|
||||
### 5.5 Bidirectional audit — one moderation picture, both origins
|
||||
|
||||
The website must see moderation actions **whether they originate on the site or in the game client**, in one consistent schema. Two directions:
|
||||
|
||||
- **Web → game (already in the request path).** Each applied `admin.*` emits an unsolicited `admin.audit` broadcast frame to every connected dashboard, tagged `"origin":"web"`, `"actor":"web:<user>"`.
|
||||
- **Game → web (the "full picture" requirement).** When a staff member runs one of these same verbs *in the game client* — `[ban`, `[kick`, `[bcast`, a page-queue response, a mute — the plugin forwards it to the website as the **same** `admin.audit` shape, tagged `"origin":"in-game"`, `"actor":"<staff account/name>"`.
|
||||
|
||||
The raw hook already exists: `BridgeEvents.OnStaffCommand` subscribes to `EventSink.Command` and emits `audit.command` for every staff command (`BridgeEvents.cs:404`), and `OnStaffPropertySet` emits `audit.set`. Those stay as the low-level firehose. On top of them we add a **normalizer** that emits a structured `admin.audit` for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse.
|
||||
|
||||
```json
|
||||
{ "kind": "admin.audit", "origin": "in-game", "action": "ban",
|
||||
"actor": "GreyBeard", "target": "griefer42", "reason": null,
|
||||
"durationSec": 604800, "t": 1783720195626 }
|
||||
```
|
||||
|
||||
**The dispatch path — traced and settled (no longer an open question).** `[ban` and `[kick` *are* registered directly in the command table: `SingleCommandImplementor.Register` calls `CommandSystem.Register(name, level, Redirect)` for each command name (`SingleCommandImplementor.cs:22`), so they sit in `m_Entries` and `EventSink.InvokeCommand(e)` fires for them (`Server/Commands.cs:259`). **So the existing `audit.command` hook already sees them** — the earlier worry that generic commands bypass `EventSink.Command` is wrong.
|
||||
|
||||
The genuine subtlety is *when* it fires and *with what*:
|
||||
|
||||
| Verb shape | Example | What `EventSink.Command` carries | Complete? |
|
||||
|------------|---------|----------------------------------|-----------|
|
||||
| Arg-bearing, no target | `[bcast Server down in 5` | verb **+ full args** | ✅ fully captured |
|
||||
| **Target-cursor** | `[ban` → click victim | verb only, **empty args** | ⚠️ **verb but not the victim** |
|
||||
|
||||
For target-cursor verbs, `Handle` runs `entry.Handler(e)` (→ `Redirect` → `Process` → `from.BeginTarget(...)`, which arms the cursor and returns) and *then* `InvokeCommand(e)` (`Commands.cs:255-259`). The event therefore fires the moment `[ban` is **typed**, before the staff clicks anyone. The resolved action — the actual target and `Account.Banned = true` — happens later inside `KickCommand.Execute`, which calls `CommandLogging.WriteLine(from, "… banning {target}")` **with** the victim (`Commands.cs:1211`).
|
||||
|
||||
**Conclusion:** the reliable choke point for a *resolved* in-game moderation action (verb **and** victim) is `CommandLogging.WriteLine` (`Scripts/Commands/Logging.cs:86`), which is where every command already records its outcome — but it has **no event to subscribe to** today. So the "full picture" needs one small hook:
|
||||
|
||||
- **Add a `WriteLine` event to `Scripts/Commands/Logging.cs`** (a 1-line `Action<Mobile,string>` raised in `WriteLine`). This is a stock file, so it ships as a **`patches/` diff** — the same mechanism Phase 7's `PlayerVendorSale` already established, and arguably the *correct* universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits `admin.audit`.
|
||||
- Broadcasts and other arg-bearing simple commands need **no** patch — the existing `EventSink.Command` hook already carries their full payload; the normalizer just reshapes them.
|
||||
|
||||
---
|
||||
|
||||
## 6. Protocol design
|
||||
|
||||
Reuse the existing inbound machinery verbatim — `BridgeBoot.RegisterHandler(kind, handler)`, Core-thread dispatch via `Timer.DelayCall`, `reqId` echo, and `*.ok` / `*.error` replies — exactly as `BridgeRequests` and `BridgeTownCrier` already do. A new `BridgeAdmin.cs` registers the `admin.*` handlers.
|
||||
|
||||
### Request shape (website → sidecar → shard)
|
||||
|
||||
```json
|
||||
{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech",
|
||||
"account": "griefer42", "durationSec": 604800, "reason": "harassment" }
|
||||
```
|
||||
|
||||
- `reqId` — correlation id, echoed on the reply (as in `BridgeRequests`).
|
||||
- `actor` — **required.** The website staff user. Rejected if absent.
|
||||
- Target — `account` (offline-capable verbs) or `serial` (online mobiles), resolved with the same `ResolveSerial` / `Accounts.GetAccount` helpers `BridgeRequests` uses.
|
||||
- `reason` — recorded in the audit trail.
|
||||
|
||||
### Reply shape (shard → sidecar → website)
|
||||
|
||||
```json
|
||||
{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" }
|
||||
{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" }
|
||||
```
|
||||
|
||||
Map to REST like the rest of `INTEGRATION.md`: `admin.ok` → 200, unknown target → 404, floor-violation/`actor` missing → 403, malformed → 400.
|
||||
|
||||
### Audit event (shard → website, unsolicited)
|
||||
|
||||
Every applied `admin.*` also emits a broadcast audit frame so *all* connected dashboards see it, not just the caller — parallel to the existing `audit.command`, and (per §5.5) emitted for **in-game** uses of the same verbs too:
|
||||
|
||||
```json
|
||||
{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech",
|
||||
"target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 }
|
||||
```
|
||||
|
||||
`origin` is `"web"` for sidecar-initiated actions or `"in-game"` for actions a staff member took in the game client.
|
||||
|
||||
### Verbs for Phase 1
|
||||
|
||||
| kind | target | required fields | shard action |
|
||||
|------|--------|-----------------|--------------|
|
||||
| `admin.kick` | `serial` or `account` | `actor` | dispose live NetState(s) |
|
||||
| `admin.ban` | `account` | `actor` (+ `durationSec` optional) | set ban tags/flag, then kick live sessions |
|
||||
| `admin.unban` | `account` | `actor` | clear ban |
|
||||
| `admin.broadcast` | — | `actor`, `text` (+ `hue`) | `World.Broadcast` |
|
||||
|
||||
Every one: enforce the **Owner floor** on the target (refuse `AccessLevel >= CoOwner`), apply on the Core thread as a synthetic CoOwner, `CommandLogging.WriteLine("web:<actor> …")`, emit `admin.audit` (`origin:"web"`), reply `admin.ok`/`admin.error`.
|
||||
|
||||
### Caps / defense-in-depth (mirroring town-crier)
|
||||
|
||||
- `actor` required and non-empty.
|
||||
- Target floor: refuse any target with `AccessLevel >= CoOwner` (`AdminAccessFloor` in `Bridge.cfg`, default `CoOwner` → only the Owner/CoOwners are shielded).
|
||||
- `reason` length cap; `durationSec` clamp (min/max); `broadcast` text length cap.
|
||||
- Master switch `AdminWriteEnabled` in `Bridge.cfg` (default **off**) so the whole write plane is opt-in per shard.
|
||||
|
||||
---
|
||||
|
||||
## 7. Verification log (all resolved)
|
||||
|
||||
All resolved by source inspection (ServUO checkout at `C:\Users\colby\Desktop\servuo`). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak.
|
||||
|
||||
1. **`Mobile.Squelched` persists — confirmed durable.** Serialized unconditionally (`Server/Mobile.cs:6489` write) and read back in the version ladder at case 9 (`:6013`), so it survives relog **and** a full server restart; no need to persist it ourselves. It gates `OnSaid` (`:7591` → *"You can not say anything, you have been muted."*). Two consequences for the plan: (a) it is **per-Mobile (per-character), not per-account** — "mute the account" means squelch each resident character; (b) it works on **offline** characters too, since logged-off mobiles stay resident in `World`. Phase 3 mute is therefore durable and offline-capable out of the box.
|
||||
2. **Kicking all sessions — settled.** Enumerate `NetState.Instances` (`Server/Network/NetState.cs:583`, a `ReadOnlyCollection<NetState>`), filter on `ns.Account == acct` (`:574`), and `Dispose()` each. This is **strictly better than walking the account's characters' `NetState`**: a client sitting at character-select has a `NetState` with an `Account` but *no* mobile, and only the `Instances` sweep catches it. `admin.kick` and the live-session cleanup in `admin.ban` both use this.
|
||||
3. **In-game capture of resolved bans/kicks (was the ★ risk).** Traced through the dispatch path — settled in §5.5. `[ban`/`[kick` *do* raise `EventSink.Command`, but at type-time without the target. The complete capture point is a **1-line event added to `Scripts/Commands/Logging.cs:86`**, shipped as a `patches/` diff. Broadcasts need no patch.
|
||||
4. **Ban attribution** — pass `null` for the `Mobile from` and set `web:<actor>` as the `BanDealer` tag ourselves. No core edit.
|
||||
5. **Broadcast + town-crier** — keep both; they differ (instant system line vs. looping crier) and both are cheap.
|
||||
6. **Access floor** — `CoOwner` (Owner-only shield). See §5.
|
||||
|
||||
**Nothing in §7 remains open — the plan is implementation-ready.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions — locked 2026-07-12
|
||||
|
||||
- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later.
|
||||
- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`.
|
||||
- **Attribution:** `web:<actor>` in `CommandLogging` and the `BanDealer` tag; no core edits.
|
||||
- **Logging:** the **website keeps its own durable moderation record**; the plugin **forwards in-game uses** of these same verbs to the site as `admin.audit` (`origin:"in-game"`) so the picture is complete from both sides (§5.5).
|
||||
- **Help-page queue:** confirmed, lands as **Phase 2**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where the code goes
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeAdmin.cs` | New. Registers `admin.*` handlers; the CoOwner-authority application + Owner floor; `web` `admin.audit` emission. Mirrors `BridgeTownCrier.cs` structure. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgeEvents.cs` | Extend: normalize in-game moderation verbs into `admin.audit` (`origin:"in-game"`). Broadcasts reshape from the existing `EventSink.Command` hook; ban/kick subscribe to the new `CommandLogging` event (§5.5). |
|
||||
| `patches/commandlogging-event.patch` | New. Adds a 1-line `Action<Mobile,string>` event to `Scripts/Commands/Logging.cs:86` so resolved staff actions (verb **+ target**) are observable. Stock file → ships as a patch, per the Phase-7 precedent. |
|
||||
| `overlay/Scripts/Custom/Bridge/BridgePages.cs` | New (Phase 2). Streams/snapshots/answers the `PageQueue`. |
|
||||
| `overlay/Config/Bridge.cfg` | Add `AdminWriteEnabled` (default off), `AdminAccessFloor` (default `CoOwner`), and the caps. |
|
||||
| `sidecar/src/web.rs` | New REST routes (`POST /admin/*`, `/pages/*`) → inbound lines; map replies to status codes. |
|
||||
| `docs/INTEGRATION.md` | Document the new endpoints + the `admin.audit` / `page.*` events. |
|
||||
| *(website, separate repo)* | Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound `admin.audit` frames. |
|
||||
|
||||
The Phase-1 **verbs** need no core or stock edit — every web-initiated action is an existing script-layer API called from the new `BridgeAdmin.cs` overlay. The only non-overlay change is the **one-line `CommandLogging` event** (`patches/commandlogging-event.patch`), needed solely so *in-game* bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 `patches/` mechanism and touches nothing else.
|
||||
@@ -29,6 +29,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,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()
|
||||
@@ -52,15 +58,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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