Add private admin console and disconnect handling
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { Game } from '../../../packages/domain/src/index.js'
|
||||
|
||||
export interface AdminStatus {
|
||||
uptimeSeconds:number
|
||||
twitchReady:boolean
|
||||
twitchMode:string
|
||||
oauthConfigured:boolean
|
||||
oauthAuthorized:boolean
|
||||
}
|
||||
|
||||
export interface AdminServerDependencies {
|
||||
game:Game
|
||||
password:string
|
||||
csrfToken:string
|
||||
status():Promise<AdminStatus>
|
||||
onStateChange():void
|
||||
isAllowedAddress?(address:string|undefined):boolean
|
||||
}
|
||||
|
||||
const escapeHtml=(value:unknown)=>String(value).replace(/[&<>"']/g,character=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[character]!))
|
||||
const privateAddress=(address:string|undefined)=>Boolean(address?.replace(/^::ffff:/,'').startsWith('10.138.'))
|
||||
const safeEqual=(left:string,right:string)=>{const a=Buffer.from(left),b=Buffer.from(right);return a.length===b.length&&timingSafeEqual(a,b)}
|
||||
function authenticated(req:IncomingMessage,password:string):boolean {
|
||||
const header=req.headers.authorization
|
||||
if(!header?.startsWith('Basic '))return false
|
||||
try{const [username,...parts]=Buffer.from(header.slice(6),'base64').toString('utf8').split(':');return username==='admin'&&safeEqual(parts.join(':'),password)}catch{return false}
|
||||
}
|
||||
async function formBody(req:IncomingMessage):Promise<URLSearchParams>{const chunks:Buffer[]=[];for await(const chunk of req){chunks.push(Buffer.from(chunk));if(chunks.reduce((size,item)=>size+item.length,0)>16_384)throw new Error('Request too large')}return new URLSearchParams(Buffer.concat(chunks).toString('utf8'))}
|
||||
function headers(res:ServerResponse,status:number,type:string){res.writeHead(status,{'content-type':type,'cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",'x-content-type-options':'nosniff','x-frame-options':'DENY','referrer-policy':'no-referrer'})}
|
||||
function redirect(res:ServerResponse,notice:string){res.writeHead(303,{location:`/?notice=${encodeURIComponent(notice)}`,'cache-control':'no-store'});res.end()}
|
||||
|
||||
export function createAdminServer(deps:AdminServerDependencies){
|
||||
if(!deps.password)throw new Error('Admin password is required')
|
||||
return createServer(async(req,res)=>{
|
||||
try{
|
||||
if(!(deps.isAllowedAddress??privateAddress)(req.socket.remoteAddress)){headers(res,403,'text/plain; charset=utf-8');return res.end('Private Twungeon network access required.')}
|
||||
if(!authenticated(req,deps.password)){res.writeHead(401,{'www-authenticate':'Basic realm="Twungeon Admin", charset="UTF-8"','cache-control':'no-store'});return res.end('Authentication required.')}
|
||||
const url=new URL(req.url??'/','http://admin.local')
|
||||
if(req.method==='GET'&&url.pathname==='/health'){headers(res,200,'application/json');return res.end(JSON.stringify({status:'ok',...(await deps.status())}))}
|
||||
if(req.method==='GET'&&url.pathname==='/'){headers(res,200,'text/html; charset=utf-8');return res.end(await render(deps,url.searchParams.get('notice')))}
|
||||
if(req.method==='POST'&&url.pathname==='/action'){
|
||||
const form=await formBody(req)
|
||||
if(!safeEqual(form.get('_csrf')??'',deps.csrfToken)){headers(res,403,'text/plain; charset=utf-8');return res.end('Invalid form token.')}
|
||||
const action=form.get('action'),userId=form.get('userId')??''
|
||||
let notice='Unknown action.'
|
||||
if(action==='disconnect')notice=deps.game.disconnect(userId)?'Player disconnected and AutoGuarded.':'Player was already disconnected or missing.'
|
||||
else if(action==='remove')notice=deps.game.removePlayer(userId)?'Player removed.':'Player not found.'
|
||||
else if(action==='force-phase')notice=deps.game.forceEndPlayerPhase()?'Player phase ended.':'There is no active player phase.'
|
||||
else if(action==='reset-run'){deps.game.resetRunByAdmin();notice='Run reset.'}
|
||||
else {headers(res,400,'text/plain; charset=utf-8');return res.end(notice)}
|
||||
deps.onStateChange();return redirect(res,notice)
|
||||
}
|
||||
headers(res,404,'text/plain; charset=utf-8');res.end('Not found.')
|
||||
}catch(error){console.error(JSON.stringify({level:'error',component:'admin',message:error instanceof Error?error.message:'Unexpected error'}));headers(res,500,'text/plain; charset=utf-8');res.end('Admin request failed.')}
|
||||
})
|
||||
}
|
||||
|
||||
async function render(deps:AdminServerDependencies,notice:string|null):Promise<string>{
|
||||
const state=deps.game.snapshot(),status=await deps.status(),phase=state.phase.kind==='player'?`${state.phase.kind} · ${Math.max(0,Math.ceil((state.phase.deadlineAt-state.serverTime)/1000))}s remaining`:state.phase.kind
|
||||
const token=escapeHtml(deps.csrfToken)
|
||||
const playerRows=state.players.map(player=>`<tr><td>${escapeHtml(player.displayName)}</td><td><code>${escapeHtml(player.twitchUserId)}</code></td><td><span class="${player.connectionState}">${player.connectionState}</span></td><td>${player.lifeState}</td><td>${player.hp}</td><td>${player.ap}</td><td>${player.guard}</td><td><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><input type="hidden" name="userId" value="${escapeHtml(player.twitchUserId)}"><button name="action" value="disconnect">Disconnect</button> <button class="danger" name="action" value="remove">Remove</button></form></td></tr>`).join('')||'<tr><td colspan="8">No players are currently in the dungeon.</td></tr>'
|
||||
const logs=state.actionLog.slice(-40).reverse().map(entry=>`<tr><td>${escapeHtml(entry.occurredAt)}</td><td>${escapeHtml(entry.type)}</td><td>${escapeHtml(entry.message)}</td></tr>`).join('')
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta http-equiv="refresh" content="15"><title>Twungeon Admin</title><style>:root{color-scheme:dark;font:15px system-ui;background:#0d0d12;color:#eee}body{max-width:1200px;margin:auto;padding:24px}h1{margin-bottom:4px}.sub{color:#aaa;margin-top:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.card,section{background:#181820;border:1px solid #343442;border-radius:10px;padding:16px}section{margin-top:16px;overflow:auto}.label{color:#aaa;font-size:12px;text-transform:uppercase}.value{font-size:20px;margin-top:5px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:9px;border-bottom:1px solid #30303a;white-space:nowrap}button{border:0;border-radius:6px;padding:7px 11px;background:#7047eb;color:white;font-weight:650;cursor:pointer}.danger{background:#b42d46}.connected{color:#62d493}.disconnected{color:#e8b95b}.notice{background:#193b2c;border:1px solid #2d8056;padding:10px;border-radius:8px}.actions{display:flex;gap:10px;flex-wrap:wrap}code{font-size:12px}</style></head><body><h1>Twungeon Admin</h1><p class="sub">Private operator console · refreshes every 15 seconds</p>${notice?`<p class="notice">${escapeHtml(notice)}</p>`:''}<div class="cards"><div class="card"><div class="label">Run</div><div class="value">Floor ${state.floorNumber}</div></div><div class="card"><div class="label">Phase</div><div class="value">${escapeHtml(phase)}</div></div><div class="card"><div class="label">Players</div><div class="value">${state.players.length}</div></div><div class="card"><div class="label">Twitch</div><div class="value">${status.twitchReady?'Ready':'Not ready'}</div></div><div class="card"><div class="label">OAuth</div><div class="value">${status.oauthAuthorized?'Authorized':status.oauthConfigured?'Not authorized':'Not configured'}</div></div><div class="card"><div class="label">Uptime</div><div class="value">${Math.floor(status.uptimeSeconds/60)}m</div></div></div><section><h2>Controls</h2><div class="actions"><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button name="action" value="force-phase">End player phase</button></form><form method="post" action="/action"><input type="hidden" name="_csrf" value="${token}"><button class="danger" name="action" value="reset-run">Reset run</button></form></div></section><section><h2>Players</h2><table><thead><tr><th>Name</th><th>Twitch ID</th><th>Connection</th><th>Life</th><th>HP</th><th>AP</th><th>Guard</th><th>Actions</th></tr></thead><tbody>${playerRows}</tbody></table></section><section><h2>Recent action log</h2><table><thead><tr><th>Time</th><th>Event</th><th>Message</th></tr></thead><tbody>${logs}</tbody></table></section></body></html>`
|
||||
}
|
||||
@@ -11,6 +11,10 @@ export interface Config {
|
||||
twitchTokenFile: string
|
||||
extensionSecret: string
|
||||
oauthConfigured: boolean
|
||||
adminEnabled: boolean
|
||||
adminHost: string
|
||||
adminPort: number
|
||||
adminPassword: string
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
@@ -24,11 +28,20 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
const twitchRedirectUri = env.TWITCH_OAUTH_REDIRECT_URI ?? `${publicBaseUrl}/oauth/callback`
|
||||
const twitchTokenFile = env.TWITCH_TOKEN_FILE ?? '/var/lib/twungeon/twitch-token.json'
|
||||
const oauthConfigured = Boolean(twitchClientId && twitchClientSecret && env.TWITCH_CHANNEL_LOGIN)
|
||||
const adminEnabled = env.ADMIN_ENABLED === 'true'
|
||||
const adminHost = env.ADMIN_HOST ?? '10.138.4.44'
|
||||
const adminPort = Number(env.ADMIN_PORT ?? 3001)
|
||||
const adminPassword = env.ADMIN_PASSWORD ?? ''
|
||||
const missing: string[] = []
|
||||
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error('PORT must be an integer from 1 to 65535')
|
||||
}
|
||||
if (!Number.isInteger(adminPort) || adminPort < 1 || adminPort > 65535 || adminPort===port) {
|
||||
throw new Error('ADMIN_PORT must be an integer from 1 to 65535 and differ from PORT')
|
||||
}
|
||||
if(adminEnabled && !/^10\.138\.\d{1,3}\.\d{1,3}$/.test(adminHost))throw new Error('ADMIN_HOST must be an address on 10.138.0.0/16')
|
||||
if(adminEnabled && adminPassword.length<16)throw new Error('ADMIN_PASSWORD must contain at least 16 characters when admin is enabled')
|
||||
if (!resurrectionRewardId.trim()) {
|
||||
throw new Error('CHANNEL_POINTS_RESURRECTION_REWARD_ID is required')
|
||||
}
|
||||
@@ -69,6 +82,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
twitchRedirectUri,
|
||||
twitchTokenFile,
|
||||
extensionSecret: env.TWITCH_EXTENSION_SECRET ?? '',
|
||||
oauthConfigured
|
||||
oauthConfigured,
|
||||
adminEnabled,
|
||||
adminHost,
|
||||
adminPort,
|
||||
adminPassword
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -9,6 +10,7 @@ import { generateFloor } from '../../../packages/dungeon-generator/src/index.js'
|
||||
import { LiveTwitchAdapter, SyntheticTwitchAdapter } from '../../../packages/twitch-adapter/src/index.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import { TwitchOAuthService } from './twitchOAuth.js'
|
||||
import { createAdminServer } from './admin.js'
|
||||
|
||||
const config=loadConfig(), startedAt=Date.now();let id=0
|
||||
const game=new Game({clock:{now:()=>Date.now()},random:{next:()=>Math.random()},ids:{next:p=>`${p}-${++id}`},generateFloor,resurrectionRewardId:config.resurrectionRewardId})
|
||||
@@ -23,7 +25,18 @@ function html(res:ServerResponse,status:number,title:string,message:string){cons
|
||||
async function body(req:IncomingMessage):Promise<unknown>{const chunks:Buffer[]=[];for await(const chunk of req)chunks.push(Buffer.from(chunk));if(chunks.reduce((n,b)=>n+b.length,0)>64_000)throw new Error('Request too large');return JSON.parse(Buffer.concat(chunks).toString('utf8')||'{}')}
|
||||
function bearer(req:IncomingMessage):string|null{const h=req.headers.authorization;return h?.startsWith('Bearer ')?h.slice(7):null}
|
||||
function sessionUser(token:string|null):string|null{if(!token)return null;const session=sessions.get(token);if(!session||session.expiresAt<=Date.now()){if(session)sessions.delete(token);return null}return session.userId}
|
||||
function connectedClientCount(userId:string):number{let count=0;for(const clientUserId of clients.values())if(clientUserId===userId)count++;return count}
|
||||
function authenticateClient(ws:WebSocket,userId:string|null):boolean{
|
||||
const previousUserId=clients.get(ws)??null
|
||||
if(previousUserId===userId)return false
|
||||
clients.set(ws,userId)
|
||||
let changed=false
|
||||
if(previousUserId && connectedClientCount(previousUserId)===0)changed=game.disconnect(previousUserId)||changed
|
||||
if(userId)changed=game.bindExtension(userId,true)||changed
|
||||
return changed
|
||||
}
|
||||
function broadcast(){const sequence=game.snapshot().nextEventSequence-1;for(const [ws,userId] of clients)if(ws.readyState===WebSocket.OPEN)ws.send(JSON.stringify({type:'snapshot',sequence,state:userId?game.personalizedSnapshot(userId):game.snapshot()}))}
|
||||
export const adminServer=config.adminEnabled?createAdminServer({game,password:config.adminPassword,csrfToken:randomBytes(32).toString('base64url'),onStateChange:broadcast,status:async()=>{const oauthStatus=oauth?await oauth.status():{configured:false,authorized:false};return{uptimeSeconds:Math.floor((Date.now()-startedAt)/1000),twitchReady:twitch.ready,twitchMode:config.twitchEnabled?'configured':'synthetic',oauthConfigured:oauthStatus.configured,oauthAuthorized:oauthStatus.authorized}}}):null
|
||||
|
||||
export const server=createServer(async(req,res)=>{
|
||||
try{
|
||||
@@ -53,7 +66,7 @@ export const server=createServer(async(req,res)=>{
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/session'){
|
||||
const data=await body(req) as any,identity=await twitch.verifyExtensionToken(String(data.token??''))
|
||||
if(!identity)return json(res,401,{error:'UNAUTHENTICATED'})
|
||||
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId)
|
||||
const token=`session-${crypto.randomUUID()}`;sessions.set(token,{userId:identity.twitchUserId,expiresAt:Date.now()+15*60_000});game.bindExtension(identity.twitchUserId,connectedClientCount(identity.twitchUserId)>0)
|
||||
return json(res,200,{token,twitchUserId:identity.twitchUserId,state:game.personalizedSnapshot(identity.twitchUserId)})
|
||||
}
|
||||
if(req.method==='POST'&&url.pathname==='/api/extension/spawn'){
|
||||
@@ -78,7 +91,10 @@ export const server=createServer(async(req,res)=>{
|
||||
})
|
||||
const wss=new WebSocketServer({noServer:true})
|
||||
server.on('upgrade',(req,socket,head)=>{const url=new URL(req.url??'/',config.publicBaseUrl);if(url.pathname!=='/ws'){socket.destroy();return}wss.handleUpgrade(req,socket,head,ws=>wss.emit('connection',ws,req))})
|
||||
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null);clients.set(ws,userId);if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>clients.delete(ws))})
|
||||
wss.on('connection',ws=>{clients.set(ws,null);ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.snapshot()}));ws.on('message',raw=>{try{const message=JSON.parse(raw.toString());if(message.type==='authenticate'){const userId=sessionUser(typeof message.token==='string'?message.token:null),changed=authenticateClient(ws,userId);if(changed)broadcast();else if(userId)ws.send(JSON.stringify({type:'snapshot',sequence:game.snapshot().nextEventSequence-1,state:game.personalizedSnapshot(userId)}))}}catch{/* ignore malformed client messages */}});ws.on('close',()=>{const userId=clients.get(ws)??null;clients.delete(ws);if(userId && connectedClientCount(userId)===0 && game.disconnect(userId))broadcast()})})
|
||||
setInterval(()=>{const before=game.snapshot().nextEventSequence;game.tick();if(game.snapshot().nextEventSequence!==before)broadcast()},250).unref()
|
||||
if(twitch instanceof LiveTwitchAdapter)void twitch.start({onSpawn:message=>{game.spawn(message);broadcast()},onRedemption:message=>{game.resurrect(message);broadcast()},onConnection:ready=>console.log(JSON.stringify({level:'info',component:'twitch-adapter',ready}))}).catch(error=>console.error(JSON.stringify({level:'error',component:'twitch-adapter',message:error instanceof Error?error.message:'Connection failed'})))
|
||||
if(process.env.NODE_ENV!=='test')server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
|
||||
if(process.env.NODE_ENV!=='test'){
|
||||
server.listen(config.port,()=>console.log(`Twungeon listening on ${config.publicBaseUrl}`))
|
||||
adminServer?.listen(config.adminPort,config.adminHost,()=>console.log(`Twungeon admin listening on http://${config.adminHost}:${config.adminPort}`))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user