// 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'); // The page with the site's name already in it, so it never shows the default first. const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`); const indexHtml = require('fs').readFileSync(path.join(publicDir, 'index.html'), 'utf8') .replace('