Add isolated read-only spectator monitor for public sharing

This commit is contained in:
Codex
2026-09-22 19:50:05 -04:00
parent 8e96c7ff47
commit 7e19d82a02
7 changed files with 379 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Install the isolated spectator service; public Funnel setup is a separate step."""
from pathlib import Path
import shutil
import subprocess
repo = Path(__file__).resolve().parents[2]
python = shutil.which('python3')
assert python
units = Path.home() / '.config/systemd/user'
units.mkdir(parents=True, exist_ok=True)
(units / 'azerothai-spectator.service').write_text(f'''[Unit]
Description=AzerothAI read-only spectator monitor
After=network-online.target azerothai-runtime.service
[Service]
WorkingDirectory={repo}
ExecStart={python} {repo}/scripts/spectator-server.py
Restart=always
RestartSec=5
NoNewPrivileges=true
UMask=0077
[Install]
WantedBy=default.target
''')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
subprocess.run(['systemctl', '--user', 'enable', '--now', 'azerothai-spectator.service'], check=True)
print('Spectator ready on loopback port 8790. Public sharing:')
print('sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790')
print('Keep the existing private dashboard on port 443.')
+60
View File
@@ -0,0 +1,60 @@
# Spectator monitor
A separate loopback service serves a public, read-only projection of the realm.
It never forwards viewer paths, methods, cookies, authorization headers or query
parameters to the administrative application. It uses a fixed local GET to
`http://127.0.0.1:8787/api/state`, caches the sanitized result for ten seconds, and
marks delayed observations. Visitors refresh every fifteen seconds.
Published fields: online bot names, class, race, level, faction, zone, guild;
observed bot parties; guild goals and progress; confirmed social activity.
Only autonomous bot-to-bot reply transcripts are public. Human conversations,
whispers, memories, account/character IDs, tokens, configuration and PC telemetry
are excluded. All text is rendered with textContent, never HTML interpolation.
The HTTP service allows only explicit static paths and `/api/realm`, `/health`,
`/robots.txt`. Mutation methods return 405; admin paths return 404.
## Install and publish
Run as the realm owner from the repository:
```sh
python3 scripts/test-spectator.py
python3 deploy/playable/install-spectator.py
```
The user service restarts automatically and listens only on `127.0.0.1:8790`.
User lingering, already used by runtime recovery, is required for boot startup.
Keep the existing private Tailscale Serve dashboard on HTTPS 443. Publish only
the spectator service on a separate HTTPS port:
```sh
sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790
```
If Tailscale presents a Funnel enablement link, the tailnet owner must complete
that authorization. Verify `tailscale funnel status` lists **only port 8443** as
public and port 443 remains tailnet-only. Share the printed HTTPS URL including
`:8443`. Visitors do not need Tailscale. This is a public link, without a password.
The host PC, Ubuntu VM and Tailscale must remain running.
Do not publish port 8787 or 8788, or change the administrative listener into a
Funnel. Hiding controls in the browser would not protect their APIs.
To turn public access off without disturbing the private dashboard:
```sh
sudo tailscale funnel --https=8443 off
```
To stop the local spectator service:
```sh
systemctl --user disable --now azerothai-spectator
```
After changing spectator source or assets, restart its service:
```sh
systemctl --user restart azerothai-spectator
```
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Read-only public realm projection. Never proxy arbitrary routes or headers."""
import json
import math
import os
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] / 'spectator'
ALLIANCE = {'Human', 'Dwarf', 'Night Elf', 'Gnome', 'Draenei'}
HORDE = {'Orc', 'Undead', 'Tauren', 'Troll', 'Blood Elf'}
def text(value, limit=200):
return str(value or '')[:limit]
def number(value, default=0):
return value if type(value) in (int, float) and math.isfinite(value) else default
def project(state):
"""Explicit field allowlist: no settings, IDs, memories, whispers or host data."""
managed = [b for b in state.get('bots', []) if b.get('autonomy', {}).get('managed')]
names = {b['name'] for b in managed}
bots = []
parties = {}
for b in managed:
if b.get('status') != 'online':
continue
world = b.get('world') or {}
race = text(b.get('race'), 40)
team = world.get('team')
faction = ('Alliance' if team == 0 else 'Horde' if team == 1 else
'Alliance' if race in ALLIANCE else 'Horde' if race in HORDE else 'Unknown')
bots.append({'name': text(b.get('name'), 40), 'class': text(b.get('role'), 40),
'race': race, 'level': number(b.get('level')), 'zone': text(b.get('zone'), 100),
'faction': faction, 'guild': text(world.get('guildName'), 80)})
members = sorted({text(n, 40) for n in world.get('groupNames', []) if n in names})
if world.get('groupId') and len(members) > 1:
parties[tuple(members)] = {'members': members, 'faction': faction}
community = state.get('community') or {}
feed = []
for e in community.get('feed', []):
if e.get('status') != 'confirmed' or e.get('kind') not in {'conversation', 'relationship', 'party', 'leave', 'recruit', 'guild'}:
continue
# Only completed autonomous bot-to-bot exchanges. Human conversations,
# whispers and synthetic opener prompts never enter this public feed.
if e['kind'] == 'conversation' and not (
str(e.get('id', '')).startswith('social-') and str(e.get('id', '')).endswith(':reply')
and len(e.get('names', [])) == 2 and all(n in names for n in e['names'])):
continue
feed.append({'at': text(e.get('at'), 40), 'kind': e['kind'],
'names': [text(n, 80) for n in e.get('names', [])[:5]],
'text': text(e.get('text'), 1200)})
guilds = [{'name': text(g.get('name'), 80), 'goal': text(g.get('goal'), 30),
'progress': number(g.get('progress')), 'target': number(g.get('target'), 20)}
for g in community.get('guilds', [])[:100]]
return {'rosterAt': number(state.get('aleRosterAt')), 'fetchedAt': int(time.time()),
'bots': sorted(bots, key=lambda b: (b['faction'], b['name'])),
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
class Snapshot:
def __init__(self):
self.lock = threading.Lock()
self.data = None
self.checked = 0
self.failed = False
def get(self):
with self.lock:
if time.monotonic() - self.checked > 10:
try:
# Fixed local endpoint. Viewer input cannot affect this request.
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787/api/state', timeout=5) as response:
raw = response.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise ValueError('State too large')
self.data = project(json.loads(raw))
self.failed = False
except Exception:
self.failed = True
self.checked = time.monotonic()
if self.data is None:
return None
return {**self.data, 'stale': self.failed or time.time() - self.data['rosterAt'] > 120}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
SNAPSHOT = Snapshot()
ASSETS = {'/': ('index.html', 'text/html; charset=utf-8'),
'/app.js': ('app.js', 'text/javascript; charset=utf-8'),
'/style.css': ('style.css', 'text/css; charset=utf-8')}
class Handler(BaseHTTPRequestHandler):
server_version = 'AzerothAI-Spectator'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(10)
def reply(self, status, body, content_type='application/json; charset=utf-8'):
self.send_response(status)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('X-Robots-Tag', 'noindex, nofollow')
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path in ASSETS:
filename, content_type = ASSETS[path]
self.reply(200, (ROOT / filename).read_bytes(), content_type)
elif path == '/api/realm':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data or {'error': 'Realm temporarily unavailable'}).encode())
elif path == '/health':
self.reply(200, b'{"service":"spectator","readOnly":true}')
elif path == '/robots.txt':
self.reply(200, b'User-agent: *\nDisallow: /\n', 'text/plain; charset=utf-8')
else:
self.reply(404, b'{"error":"Not found"}')
do_HEAD = do_GET
def reject(self):
self.close_connection = True
self.reply(405, b'{"error":"This monitor is read-only"}')
do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_TRACE = do_CONNECT = reject
def log_message(self, format, *args):
pass
class Server(ThreadingHTTPServer):
daemon_threads = True
slots = threading.BoundedSemaphore(24)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
if __name__ == '__main__':
Server(('127.0.0.1', int(os.environ.get('AZEROTHAI_SPECTATOR_PORT', '8790'))), Handler).serve_forever()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import importlib.util
import json
import threading
import unittest
import urllib.error
import urllib.request
from pathlib import Path
spec = importlib.util.spec_from_file_location('spectator', Path(__file__).with_name('spectator-server.py'))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
class SpectatorTests(unittest.TestCase):
def test_public_projection_excludes_secrets_and_whispers(self):
bots = [{'name': n, 'status': 'online', 'race': r, 'role': 'Death Knight', 'level': 55,
'autonomy': {'managed': True, 'accountId': 123}, 'memories': ['PRIVATE_MEMORY'],
'world': {'team': team, 'groupId': 'internal-guid', 'groupNames': ['A', 'B', 'HumanPlayer']}}
for n, r, team in [('A', 'Human', 0), ('B', 'Orc', 1)]]
feed = [{'id': i, 'kind': 'conversation', 'status': 'confirmed', 'names': names, 'text': t}
for i, names, t in [('whisper-1', ['HumanPlayer', 'A'], 'PRIVATE_WHISPER'),
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
('social-1:reply', ['A', 'B'], 'Hello friend'),
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]]
projected = m.project({'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}})
serialized = json.dumps(projected)
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
self.assertNotIn(private, serialized)
self.assertEqual([b['faction'] for b in projected['bots']], ['Alliance', 'Horde'])
self.assertEqual([e['text'] for e in projected['feed']], ['Hello friend'])
def test_http_allowlist_and_mutations(self):
server = m.Server(('127.0.0.1', 0), m.Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
base = 'http://127.0.0.1:' + str(server.server_port)
try:
for route in ['/', '/app.js', '/style.css', '/health']:
with urllib.request.urlopen(base + route) as response:
self.assertEqual(response.status, 200)
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy'])
for path in ['/api/state', '/api/actions', '/api/ale/operator', '/api/operations', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '//api/state']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(base + path)
self.assertEqual(error.exception.code, 404)
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
self.assertEqual(error.exception.code, 405)
finally:
server.shutdown()
server.server_close()
worker.join()
if __name__ == '__main__':
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const $ = id => document.getElementById(id);
let realm;
function el(tag, text, cls) { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; }
function empty(node, text) { node.replaceChildren(el('p', text, 'empty')); }
function renderRoster() {
if (!realm) return;
const term = $('search').value.trim().toLowerCase(), faction = $('faction').value;
const bots = realm.bots.filter(b => (faction === 'all' || b.faction === faction) && [b.name,b.class,b.race,b.zone,b.guild].join(' ').toLowerCase().includes(term));
$('roster').replaceChildren(...bots.map(b => {
const row = el('tr'); row.append(el('td', b.name));
const team = el('td'); team.append(el('span', b.faction, 'tag ' + b.faction.toLowerCase())); row.append(team);
const role = el('td', b.class); role.append(el('small', b.race)); row.append(role, el('td', b.level), el('td', b.zone || 'Unknown'), el('td', b.guild || '—')); return row;
}));
$('roster-empty').hidden = bots.length > 0;
$('roster-empty').textContent = realm.bots.length ? 'No residents match this search.' : 'No residents are currently reported online.';
}
function renderFeed() {
if (!realm) return;
const entries = realm.feed.filter(e => $('activity-filter').value === 'all' || e.kind === $('activity-filter').value);
if (!entries.length) return empty($('feed'), 'No confirmed activity of this type yet. New encounters will appear here.');
$('feed').replaceChildren(...entries.map(e => {
const node = el('article', undefined, 'entry');
node.append(el('span', e.kind + ' · ' + new Date(e.at).toLocaleString(), 'meta'), el('h3', e.names.join(' ↔ ')), el('p', e.text)); return node;
}));
}
function render() {
$('total').textContent = realm.bots.length;
$('alliance').textContent = realm.bots.filter(b => b.faction === 'Alliance').length;
$('horde').textContent = realm.bots.filter(b => b.faction === 'Horde').length;
$('parties-count').textContent = realm.parties.length;
$('status').className = 'status' + (realm.stale ? ' stale' : '');
$('status').textContent = (realm.stale ? 'Updates delayed · Showing last known roster' : 'Live realm observations') + (realm.rosterAt ? ' · Observed ' + new Date(realm.rosterAt * 1000).toLocaleTimeString() : '');
renderRoster(); renderFeed();
$('parties').replaceChildren(...realm.parties.map(p => { const n=el('article',undefined,'group');n.append(el('strong',p.members.join(' · ')),el('p',p.faction));return n; }));
if (!realm.parties.length) empty($('parties'), 'No bot parties observed yet.');
const goals={quest:'Complete quests together',support:'Help guildmates in combat',explore:'Explore other maps'};
$('guilds').replaceChildren(...realm.guilds.map(g => { const n=el('article',undefined,'group');n.append(el('strong',g.name),el('p',goals[g.goal]||'Shared adventures'),el('p',g.progress+' contributions · milestone '+g.target));const p=el('progress');p.max=Math.max(1,g.target);p.value=g.progress;p.setAttribute('aria-label',g.name+' goal progress');n.append(p);return n; }));
if (!realm.guilds.length) empty($('guilds'), 'Waiting for guild observations.');
}
async function refresh() {
try { const response=await fetch('/api/realm',{cache:'no-store',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error();realm=await response.json();render(); }
catch { $('status').className='status stale';$('status').textContent=realm?'Connection interrupted · Showing the last observation. Retrying automatically.':'Realm temporarily unavailable. Retrying automatically.'; }
finally { setTimeout(refresh,15000); }
}
$('search').addEventListener('input',renderRoster);$('faction').addEventListener('change',renderRoster);$('activity-filter').addEventListener('change',renderFeed);refresh();
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>AzerothAI · Living Realm</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
<body><main>
<header><div><p class="eyebrow">AZEROTHAI / SPECTATOR</p><h1>A world with a life of its own.</h1><p class="intro">Follow the residents of Azeroth as they adventure, meet, and build a shared history.</p></div><span class="badge">View only</span></header>
<div class="status" role="status" id="status">Connecting to the realm…</div>
<section class="stats" aria-label="Realm population"><article><span>Residents online</span><strong id="total">—</strong><small>50 resident limit</small></article><article class="alliance"><span>Alliance</span><strong id="alliance">—</strong><small>25 resident target</small></article><article class="horde"><span>Horde</span><strong id="horde">—</strong><small>25 resident target</small></article><article><span>Adventuring parties</span><strong id="parties-count">—</strong><small>Observed bot groups</small></article></section>
<nav aria-label="Realm sections"><a href="#residents">Residents</a><a href="#activity">Social life</a><a href="#groups">Parties & guilds</a></nav>
<section id="residents" class="panel"><div class="heading"><div><p class="eyebrow">IN THE WORLD</p><h2>Meet the residents</h2></div><div class="filters"><label><span class="sr-only">Search residents</span><input id="search" type="search" placeholder="Find a name, class, or zone…"></label><label><span class="sr-only">Faction</span><select id="faction"><option value="all">Both factions</option><option>Alliance</option><option>Horde</option></select></label></div></div><div class="table-wrap"><table><thead><tr><th>Name</th><th>Faction</th><th>Class / race</th><th>Level</th><th>Location</th><th>Guild</th></tr></thead><tbody id="roster"></tbody></table></div><p id="roster-empty" class="empty">Waiting for the first observation.</p></section>
<div class="columns"><section id="activity" class="panel"><div class="heading"><div><p class="eyebrow">SHARED STORIES</p><h2>Social life</h2></div><label><span class="sr-only">Activity type</span><select id="activity-filter"><option value="all">All activity</option><option value="conversation">Conversations</option><option value="relationship">Relationships</option></select></label></div><div id="feed" class="feed"></div></section><section id="groups" class="panel"><div class="heading"><div><p class="eyebrow">BETTER TOGETHER</p><h2>Parties & guilds</h2></div></div><div class="group-body"><h3>Current parties</h3><div id="parties"></div><h3 class="guild-title">Guild ambitions</h3><div id="guilds"></div></div></section></div>
<footer>Automatic updates every 15 seconds · Population can briefly dip while a replacement joins.<br>Bot-to-bot conversations only. Player whispers stay private.</footer>
</main></body></html>
+1
View File
@@ -0,0 +1 @@
:root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e6e8ed;background:#0b1017;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 15% 0,#23314455,transparent 55%)}main{max-width:1280px;margin:auto;padding:48px 28px}header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start}h1{font-family:Georgia,serif;font-size:clamp(30px,4vw,48px);font-weight:400;letter-spacing:-1px;margin:12px 0}h2{font-size:22px;margin:5px 0}h3{font-size:15px;margin:0 0 14px}.eyebrow{font-size:11px;letter-spacing:2px;color:#c3a56f;margin:0}.intro{max-width:640px;color:#a9b2c1;line-height:1.7}.badge{border:1px solid #415245;border-radius:30px;color:#a8c8a5;white-space:nowrap;padding:8px 14px;font-size:12px}.status{color:#a9b2c1;margin:20px 0;font-size:13px}.status.stale{color:#f2bd79}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}.stats article{background:#121a25;border:1px solid #293241;border-radius:14px;padding:22px;display:flex;flex-direction:column;gap:6px}.stats span{font-size:13px;color:#bec7d3}.stats strong{font-size:40px;font-weight:500;color:#dcc18e}.stats small{font-size:11px;color:#8e9aaa}.stats .alliance strong{color:#8dbbff}.stats .horde strong{color:#ef9995}nav{display:flex;gap:25px;padding:26px 0}a{color:#bcc7d5;text-decoration:none;font-size:14px}a:hover{color:#efd69f}.panel{background:#111923;border:1px solid #293241;border-radius:14px;overflow:hidden;scroll-margin-top:20px}.heading{padding:24px;display:flex;align-items:center;justify-content:space-between;gap:18px;border-bottom:1px solid #27303c}.filters{display:flex;gap:8px}input,select{font:inherit;font-size:13px;background:#0c131c;border:1px solid #354153;color:#dfe5ec;padding:11px;border-radius:7px}input{width:250px}input:focus,select:focus,a:focus-visible{outline:2px solid #c3a56f;outline-offset:3px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap}th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#919eaf;padding:15px 22px}td{font-size:13px;padding:15px 22px;border-top:1px solid #202b38;color:#b5c0ce}td:first-child{color:#eee6d9;font-weight:600}td small{display:block;color:#8b98aa;font-size:11px;margin-top:4px}.tag{font-size:11px;border-radius:4px;padding:4px 7px;background:#263347;color:#a6c8fb}.tag.horde{background:#38282e;color:#eab0a7}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px;margin-top:22px;align-items:start}.feed{max-height:660px;overflow-y:auto;padding:0 24px}.entry{padding:22px 0;border-bottom:1px solid #293241}.entry:last-child{border:0}.entry .meta{font-size:10px;color:#a6b0bd;text-transform:uppercase;letter-spacing:.8px}.entry h3{margin:10px 0;color:#d8c19a}.entry p{font-size:14px;line-height:1.7;white-space:pre-wrap;margin:0;color:#b6c1d0;overflow-wrap:anywhere}.group-body{padding:24px}.group{background:#17212e;border-radius:9px;padding:16px;margin-bottom:12px;font-size:14px;line-height:1.6}.group strong{display:block;color:#dfcfb2}.group p{font-size:12px;color:#aab7c8;margin:5px 0}.guild-title{margin-top:30px}progress{width:100%;height:6px;accent-color:#bea573}.empty{color:#95a2b4;font-size:13px;line-height:1.7;padding:20px;margin:0}footer{margin-top:30px;color:#8593a6;font-size:12px;line-height:1.9}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:850px){.columns{grid-template-columns:1fr}.heading{align-items:flex-start;flex-direction:column}.filters{width:100%}input{width:100%}.filters label:first-child{flex:1}}@media(max-width:540px){main{padding:28px 16px}.stats{grid-template-columns:1fr 1fr;gap:10px}.stats article{padding:17px}.stats strong{font-size:34px}header{display:block}.badge{display:inline-block;margin-top:8px}.heading{padding:20px}.filters{flex-direction:column}select{max-width:100%}nav{gap:20px}nav a{font-size:13px}}