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:
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