summaryrefslogtreecommitdiff
path: root/server.js
diff options
context:
space:
mode:
Diffstat (limited to 'server.js')
-rw-r--r--server.js344
1 files changed, 344 insertions, 0 deletions
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);