From 7e19d82a02e58b168e0cc6edfb82398ed5705c84 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 22 Sep 2026 19:50:05 -0400 Subject: [PATCH] Add isolated read-only spectator monitor for public sharing --- deploy/playable/install-spectator.py | 28 +++++ docs/spectator-monitor.md | 60 +++++++++ scripts/spectator-server.py | 176 +++++++++++++++++++++++++++ scripts/test-spectator.py | 57 +++++++++ spectator/app.js | 46 +++++++ spectator/index.html | 11 ++ spectator/style.css | 1 + 7 files changed, 379 insertions(+) create mode 100644 deploy/playable/install-spectator.py create mode 100644 docs/spectator-monitor.md create mode 100644 scripts/spectator-server.py create mode 100644 scripts/test-spectator.py create mode 100644 spectator/app.js create mode 100644 spectator/index.html create mode 100644 spectator/style.css diff --git a/deploy/playable/install-spectator.py b/deploy/playable/install-spectator.py new file mode 100644 index 0000000..8be673b --- /dev/null +++ b/deploy/playable/install-spectator.py @@ -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.') diff --git a/docs/spectator-monitor.md b/docs/spectator-monitor.md new file mode 100644 index 0000000..00a817e --- /dev/null +++ b/docs/spectator-monitor.md @@ -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 +``` diff --git a/scripts/spectator-server.py b/scripts/spectator-server.py new file mode 100644 index 0000000..7fbd4fc --- /dev/null +++ b/scripts/spectator-server.py @@ -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() diff --git a/scripts/test-spectator.py b/scripts/test-spectator.py new file mode 100644 index 0000000..6878938 --- /dev/null +++ b/scripts/test-spectator.py @@ -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() diff --git a/spectator/app.js b/spectator/app.js new file mode 100644 index 0000000..aa40fc0 --- /dev/null +++ b/spectator/app.js @@ -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(); diff --git a/spectator/index.html b/spectator/index.html new file mode 100644 index 0000000..806fb92 --- /dev/null +++ b/spectator/index.html @@ -0,0 +1,11 @@ + +AzerothAI · Living Realm +
+

AZEROTHAI / SPECTATOR

A world with a life of its own.

Follow the residents of Azeroth as they adventure, meet, and build a shared history.

View only
+
Connecting to the realm…
+
Residents online—50 resident limit
Alliance—25 resident target
Horde—25 resident target
Adventuring parties—Observed bot groups
+ +

IN THE WORLD

Meet the residents

NameFactionClass / raceLevelLocationGuild

Waiting for the first observation.

+

SHARED STORIES

Social life

BETTER TOGETHER

Parties & guilds

Current parties

Guild ambitions

+ +
diff --git a/spectator/style.css b/spectator/style.css new file mode 100644 index 0000000..5c86a27 --- /dev/null +++ b/spectator/style.css @@ -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}}