From e69ad01e700e80fa959dfee08cd556ca0d777ffd Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 1 Nov 2026 05:43:24 -0800 Subject: Yesod: self-hosted WebXR rooms Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 --- server.js | 344 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 server.js (limited to 'server.js') diff --git a/server.js b/server.js new file mode 100644 index 0000000..0dc3f9a --- /dev/null +++ b/server.js @@ -0,0 +1,344 @@ +// VR rooms server: serves the WebXR client, keeps the list of open rooms, and +// runs EasyRTC signaling for Networked-A-Frame. Voice and avatar updates flow +// peer-to-peer over WebRTC, relayed through coturn when a direct path isn't +// possible, so the server only sees who is in which room. +'use strict'; + +const http = require('http'); +const path = require('path'); +const crypto = require('crypto'); +const express = require('express'); +const { Server } = require('socket.io'); +const easyrtc = require('open-easyrtc'); + +const PORT = Number(process.env.PORT || 8095); +const HOST = process.env.HOST || '127.0.0.1'; +const TURN_SECRET = process.env.TURN_SECRET || ''; +const TURN_URLS = splitList(process.env.TURN_URLS); +const STUN_URLS = splitList(process.env.STUN_URLS); +const TURN_TTL_SECONDS = 12 * 60 * 60; +const HAS_RELAY = Boolean(TURN_SECRET && TURN_URLS.length); + +// What the lobby calls the site, and where the sky is drawn from (the worlds +// show the real sky over that place). +const SITE_NAME = (process.env.SITE_NAME || 'Yesod').trim().slice(0, 40); +const SKY_LATITUDE = envNumber('SKY_LATITUDE', 51.4769, -90, 90); +const SKY_LONGITUDE = envNumber('SKY_LONGITUDE', -0.0005, -180, 180); + +const APP_NAME = 'yesod'; +const WORLDS = new Set(require('./public/worlds.json').map((w) => w.id)); + +// Limits that keep a 1 vCPU / 2 GB VPS comfortable. Everyone in a room talks +// to everyone else directly, so a room past ~8 strains the headsets first. +// The first four can be changed in the environment; keep coturn's quotas in +// step (deploy/turnserver.conf). +const MAX_PEOPLE = envNumber('MAX_PEOPLE', 8, 2, 32); // per room +const MAX_ROOMS = envNumber('MAX_ROOMS', 10, 1, 1000); // open at once +const MAX_CONNECTIONS = envNumber('MAX_CONNECTIONS', 60, 2, 10000); // people in rooms, all rooms together +const MAX_CONNECTIONS_PER_IP = envNumber('MAX_CONNECTIONS_PER_IP', 6, 1, 1000); // a household behind one address +const MAX_ROOMS_PER_IP = 2; // open at once, started from one address +const MAX_CREATES_PER_IP = 6; // rooms started per window +const PENDING_MS = 2 * 60 * 1000; // a new room waits this long for its host +const EMPTY_MS = 60 * 1000; // an empty room waits this long (time to reload) + +// Failed room passwords allowed per IP per window. +const MAX_FAILURES = 10; +const WINDOW_MS = 10 * 60 * 1000; + +function splitList(value) { + return (value || '').split(',').map((s) => s.trim()).filter(Boolean); +} + +// A number from the environment, or the fallback when it's unset or out of range. +function envNumber(name, fallback, min, max) { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (Number.isFinite(value) && value >= min && value <= max) return value; + console.error(`Ignoring ${name}=${raw}: expected a number from ${min} to ${max}.`); + return fallback; +} + +// nginx forwards the visitor's address; anything else is a direct local connection. +function clientIp(remoteAddress, headers) { + const isLoopback = /^(::ffff:)?127\.|^::1$/.test(remoteAddress || ''); + return (isLoopback && headers['x-real-ip']) || remoteAddress || 'unknown'; +} + +// --- per-IP counters (in memory only, never logged) ------------------------ + +function counter(limit) { + const entries = new Map(); // ip -> { count, resetAt } + return { + full(ip) { + const entry = entries.get(ip); + if (entry && Date.now() > entry.resetAt) entries.delete(ip); + return (entries.get(ip) || { count: 0 }).count >= limit; + }, + add(ip) { + const entry = entries.get(ip); + if (!entry || Date.now() > entry.resetAt) entries.set(ip, { count: 1, resetAt: Date.now() + WINDOW_MS }); + else entry.count += 1; + }, + sweep() { + for (const [ip, entry] of entries) if (Date.now() > entry.resetAt) entries.delete(ip); + }, + }; +} + +const failures = counter(MAX_FAILURES); +const creates = counter(MAX_CREATES_PER_IP); + +// --- rooms ----------------------------------------------------------------- + +// Passwords ignore case and surrounding spaces, which is kinder on a headset keyboard. +function hashPassword(password) { + return crypto.createHash('sha256').update(password.trim().toLowerCase()).digest(); +} + +function cleanName(value) { + return String(value || '') + .replace(/[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁦-⁩]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 20); +} + +const rooms = new Map(); // id -> room +const sessions = new Map(); // easyrtcid -> { room, ip }, for everyone signed in to a room + +function createRoom({ world, host, password, ip }) { + const id = crypto.randomBytes(5).toString('hex'); + const room = { + id, + world, + host, + password: password ? hashPassword(password) : null, + ip, + startedAt: Date.now(), + emptySince: Date.now(), + used: false, // someone has joined it + members: new Set(), + }; + rooms.set(id, room); + return room; +} + +let rtc = null; // EasyRTC's public object, once it's listening + +// Forgets the room here and in EasyRTC, which otherwise keeps every room ever made. +function deleteRoom(room) { + rooms.delete(room.id); + if (!rtc) return; + rtc.app(APP_NAME, (err, appObj) => { + if (!err && appObj.isRoomSync(room.id)) appObj.deleteRoom(room.id, () => {}); + }); +} + +function leaveRoom(roomId, easyrtcid) { + const room = rooms.get(roomId); + if (room && room.members.delete(easyrtcid) && room.members.size === 0) room.emptySince = Date.now(); +} + +// Returns null when the person may join, or the reason they can't. +function joinProblem(room, password, ip) { + if (!room) return { status: 404, error: 'That room has closed.' }; + if (room.members.size >= MAX_PEOPLE) return { status: 409, error: 'That room is full.' }; + if (sessions.size >= MAX_CONNECTIONS) return { status: 503, error: 'The server is full right now. Try again later.' }; + if ([...sessions.values()].filter((s) => s.ip === ip).length >= MAX_CONNECTIONS_PER_IP) { + return { status: 429, error: 'Too many people from your network are already in rooms.' }; + } + if (!room.password) return null; + if (failures.full(ip)) return { status: 429, error: 'Too many attempts. Try again in 10 minutes.' }; + const candidate = typeof password === 'string' ? password : ''; + if (!candidate) return { status: 401, error: 'This room needs a password.' }; + if (crypto.timingSafeEqual(hashPassword(candidate), room.password)) return null; + failures.add(ip); + return { status: 401, error: 'Wrong password.' }; +} + +setInterval(() => { + const now = Date.now(); + for (const room of rooms.values()) { + const wait = room.used ? EMPTY_MS : PENDING_MS; + if (room.members.size === 0 && now - room.emptySince > wait) deleteRoom(room); + } + failures.sweep(); + creates.sweep(); +}, 15 * 1000).unref(); + +// --- ICE servers: short-lived TURN credentials (coturn "use-auth-secret") -- + +// Each connection gets its own TURN username, so coturn's per-user quota is per person. +function iceServers(easyrtcid) { + const servers = STUN_URLS.map((urls) => ({ urls })); + if (HAS_RELAY) { + const username = `${Math.floor(Date.now() / 1000) + TURN_TTL_SECONDS}:${easyrtcid}`; + const credential = crypto.createHmac('sha1', TURN_SECRET).update(username).digest('base64'); + servers.push({ urls: TURN_URLS, username, credential }); + } + return servers; +} + +// --- HTTP ------------------------------------------------------------------- + +const app = express(); +app.disable('x-powered-by'); +app.set('trust proxy', 'loopback'); + +app.use((req, res, next) => { + res.set({ + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Permissions-Policy': 'microphone=(self), display-capture=(self), xr-spatial-tracking=(self), camera=(), geolocation=()', + }); + next(); +}); + +app.get('/healthz', (req, res) => res.type('text').send('ok')); + +const api = express.Router(); +api.use(express.json({ limit: '1kb' })); +api.use((req, res, next) => { + res.set('Cache-Control', 'no-store'); + req.visitor = clientIp(req.socket.remoteAddress, req.headers); + next(); +}); + +// What the lobby shows about a room. Never includes addresses or passwords. +function describe(room) { + return { + id: room.id, + world: room.world, + host: room.host, + seconds: Math.floor((Date.now() - room.startedAt) / 1000), + people: room.members.size, + max: MAX_PEOPLE, + locked: Boolean(room.password), + }; +} + +// What the page needs to know about this site before anything else. +api.get('/site', (req, res) => { + res.json({ name: SITE_NAME, sky: { latitude: SKY_LATITUDE, longitude: SKY_LONGITUDE } }); +}); + +// The lobby's list: rooms someone is in right now, and whether there's a +// relay to hide people's addresses behind. +api.get('/rooms', (req, res) => { + res.json({ rooms: [...rooms.values()].filter((room) => room.members.size > 0).map(describe), relay: HAS_RELAY }); +}); + +// One room from a shared link, including one that just emptied (someone reloading). +api.get('/rooms/:id', (req, res) => { + const room = rooms.get(req.params.id); + if (!room) return res.status(404).json({ error: 'That room has closed.' }); + res.json(describe(room)); +}); + +api.post('/rooms', (req, res) => { + const body = req.body || {}; + const world = String(body.world || ''); + const host = cleanName(body.host); + const password = typeof body.password === 'string' ? body.password.slice(0, 64) : ''; + const ip = req.visitor; + + if (!WORLDS.has(world)) return res.status(400).json({ error: 'Unknown world.' }); + if (!host) return res.status(400).json({ error: 'Enter a name.' }); + if (rooms.size >= MAX_ROOMS) return res.status(503).json({ error: 'There are too many rooms open right now. Join one, or try again later.' }); + if ([...rooms.values()].filter((r) => r.ip === ip).length >= MAX_ROOMS_PER_IP || creates.full(ip)) { + return res.status(429).json({ error: "You've started too many rooms. Try again in a few minutes." }); + } + creates.add(ip); + const room = createRoom({ world, host, password: password.trim() ? password : '', ip }); + res.status(201).json({ id: room.id }); +}); + +// Lets the lobby say "wrong password" or "full" before connecting; EasyRTC checks again on connect. +api.post('/rooms/:id/check', (req, res) => { + const room = rooms.get(req.params.id); + const problem = joinProblem(room, req.body && req.body.password, req.visitor); + if (problem) return res.status(problem.status).json({ error: problem.error }); + res.json({ ok: true, world: room.world, host: room.host }); +}); + +app.use('/api', api); + +const publicDir = path.join(__dirname, 'public'); +app.use('/assets', express.static(path.join(publicDir, 'assets'), { maxAge: '7d' })); +app.use('/vendor', express.static(path.join(publicDir, 'vendor'), { maxAge: '30d', immutable: true })); +app.use(express.static(publicDir, { maxAge: 0 })); + +const webServer = http.createServer(app); +const socketServer = new Server(webServer, { serveClient: true, maxHttpBufferSize: 1e6 }); + +// --- EasyRTC signaling ------------------------------------------------------ + +easyrtc.setOption('logLevel', process.env.EASYRTC_LOG_LEVEL || 'error'); +easyrtc.setOption('demosEnable', false); +easyrtc.setOption('roomDefaultEnable', false); +easyrtc.setOption('roomMaxConnection', MAX_PEOPLE); +easyrtc.setOption('appIceServers', []); + +easyrtc.events.on('authenticate', (socket, easyrtcid, appName, username, credential, msg, next) => { + const ip = clientIp(socket.handshake.address, socket.handshake.headers); + if (appName !== APP_NAME) { + return next(new easyrtc.util.ConnectionError(`Unknown app ${appName}`)); + } + const roomId = credential && String(credential.room); + const problem = joinProblem(rooms.get(roomId), credential && credential.password, ip); + if (problem) { + return next(new easyrtc.util.ConnectionError(`Rejected connection (${problem.status})`)); + } + sessions.set(easyrtcid, { room: roomId, ip }); + socket.once('disconnect', () => { + sessions.delete(easyrtcid); + leaveRoom(roomId, easyrtcid); + }); + next(null); +}); + +easyrtc.events.on('getIceConfig', (connectionObj, callback) => callback(null, iceServers(connectionObj.getEasyrtcid()))); + +// Each connection may only join the room it signed in to. +easyrtc.events.on('roomJoin', (connectionObj, roomName, roomParameter, callback) => { + const easyrtcid = connectionObj.getEasyrtcid(); + const session = sessions.get(easyrtcid); + const room = rooms.get(roomName); + if (!session || session.room !== roomName || !room) { + return callback(new easyrtc.util.ConnectionError(`Not signed in to room ${roomName}`)); + } + easyrtc.events.defaultListeners.roomJoin(connectionObj, roomName, roomParameter, (err, ...rest) => { + if (!err) { + room.members.add(easyrtcid); + room.used = true; + } + callback(err, ...rest); + }); +}); + +easyrtc.events.on('roomLeave', (connectionObj, roomName, next) => { + leaveRoom(roomName, connectionObj.getEasyrtcid()); + easyrtc.events.defaultListeners.roomLeave(connectionObj, roomName, next); +}); + +easyrtc.listen(app, socketServer, null, (err, pub) => { + if (err) { + console.error('EasyRTC failed to start', err); + process.exit(1); + } + rtc = pub; +}); + +webServer.listen(PORT, HOST, () => { + const relay = TURN_URLS.length ? `TURN ${TURN_URLS.join(', ')}` : 'no TURN configured'; + console.log(`yesod listening on http://${HOST}:${PORT} (${relay})`); +}); + +function shutdown() { + socketServer.close(); + webServer.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 3000).unref(); +} +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); -- cgit v1.2.3