mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
854 lines
35 KiB
JavaScript
854 lines
35 KiB
JavaScript
import express from 'express';
|
|
import { createServer } from 'http';
|
|
import { fileURLToPath } from 'url';
|
|
import { Server } from 'socket.io';
|
|
import crypto from 'crypto';
|
|
import dotenv from 'dotenv';
|
|
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
|
|
import {
|
|
buildHealthPayload,
|
|
checkCooldown,
|
|
getCachedPayload,
|
|
isAdminMetricsAuthorized,
|
|
isAdminMetricsTokenStrong
|
|
} from './ops.js';
|
|
|
|
dotenv.config();
|
|
|
|
function hashPassword(password) {
|
|
if (!password) return null;
|
|
const salt = process.env.SERVER_SALT || 'koalasync_salt_3i';
|
|
return crypto.createHmac('sha256', salt).update(password).digest('hex');
|
|
}
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
|
|
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
|
|
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
|
|
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
|
|
const ROOM_LIST_COOLDOWN_MS = 10000;
|
|
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
|
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
|
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
|
|
|
|
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
|
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
|
|
}
|
|
|
|
export const app = express();
|
|
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
|
|
|
export const healthResponseCache = new Map();
|
|
|
|
// Health Check with Rate Limiting
|
|
app.get('/', (req, res) => {
|
|
const clientIp = req.ip;
|
|
if (!checkHealthRate(clientIp)) {
|
|
return res.status(429).json({ error: 'Too many requests. Try again later.' });
|
|
}
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json(getCachedPayload(
|
|
healthResponseCache,
|
|
'root',
|
|
HEALTH_RESPONSE_CACHE_TTL_MS,
|
|
() => ({ status: 'online', service: 'KoalaSync Relay' })
|
|
));
|
|
});
|
|
|
|
app.get('/health', (req, res) => {
|
|
const clientIp = req.ip;
|
|
if (!checkHealthRate(clientIp)) {
|
|
return res.status(429).json({ error: 'Rate limited' });
|
|
}
|
|
const authHeader = req.get('authorization');
|
|
const includeMetrics = isAdminMetricsAuthorized(authHeader, ADMIN_METRICS_TOKEN);
|
|
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
|
|
return res.status(429).json({ error: 'Rate limited' });
|
|
}
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json(getCachedPayload(
|
|
healthResponseCache,
|
|
includeMetrics ? 'health-admin' : 'health-basic',
|
|
HEALTH_RESPONSE_CACHE_TTL_MS,
|
|
() => buildHealthPayload({
|
|
rooms,
|
|
connections: io.engine?.clientsCount ?? 0,
|
|
includeMetrics,
|
|
uptime: process.uptime(),
|
|
rateLimitSizes: {
|
|
connections: connectionCounts.size,
|
|
events: eventCounts.size,
|
|
health: healthCounts.size,
|
|
adminMetricsAuth: adminMetricsAuthCounts.size,
|
|
authFailures: failedAuthAttempts.size,
|
|
roomList: roomListCooldowns.size
|
|
},
|
|
rateLimitDenied
|
|
})
|
|
));
|
|
});
|
|
|
|
export const httpServer = createServer(app);
|
|
|
|
// Socket.IO setup with security constraints
|
|
export const io = new Server(httpServer, {
|
|
cors: {
|
|
origin: (origin, callback) => {
|
|
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
|
|
callback(null, true);
|
|
} else {
|
|
log('CORS', `Rejected origin: ${(origin || '').replace(/[\r\n]/g, '')}`);
|
|
callback(new Error('Not allowed by CORS'));
|
|
}
|
|
},
|
|
methods: ["GET", "POST"]
|
|
},
|
|
maxHttpBufferSize: 4096, // 4KB max per message (headroom for JOIN_ROOM payloads)
|
|
transports: ['websocket'],
|
|
allowUpgrades: false
|
|
});
|
|
|
|
/**
|
|
* In-memory storage
|
|
*/
|
|
export const rooms = new Map();
|
|
const socketToRoom = new Map();
|
|
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
|
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
|
|
|
function log(type, message, details = '') {
|
|
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
|
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
|
|
if (!debugLogging && isVerbose) return;
|
|
|
|
const timestamp = new Date().toISOString();
|
|
console.log(`[${timestamp}] [${type}] ${message}`, details);
|
|
}
|
|
|
|
// Rate Limiting & Security
|
|
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
|
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
|
|
|
function checkAuthRate(ip, roomId) {
|
|
const key = `${ip}:${roomId}`;
|
|
const now = Date.now();
|
|
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
|
|
|
// Block for 15 mins if 5 fails in 2 mins
|
|
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
|
|
return false;
|
|
}
|
|
|
|
// Reset if last attempt was long ago
|
|
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
|
|
record.count = 0;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function recordAuthFailure(ip, roomId) {
|
|
if (failedAuthAttempts.size > 200000) {
|
|
const now = Date.now();
|
|
// 1. Clear expired entries (> 15 mins)
|
|
for (const [key, record] of failedAuthAttempts.entries()) {
|
|
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
|
failedAuthAttempts.delete(key);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 2. If still over 200k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
|
if (failedAuthAttempts.size > 200000) {
|
|
log('SECURITY', 'failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
|
|
for (const [key] of failedAuthAttempts.entries()) {
|
|
if (failedAuthAttempts.size <= 190000) {
|
|
break;
|
|
}
|
|
failedAuthAttempts.delete(key);
|
|
}
|
|
}
|
|
}
|
|
const key = `${ip}:${roomId}`;
|
|
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
|
record.count++;
|
|
record.lastAttempt = Date.now();
|
|
failedAuthAttempts.delete(key); // Remove first to update insertion order (moves key to the end of iteration)
|
|
failedAuthAttempts.set(key, record);
|
|
}
|
|
|
|
// Periodically clean up old auth failure records (every 15 minutes)
|
|
const authFailureCleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, record] of failedAuthAttempts.entries()) {
|
|
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
|
failedAuthAttempts.delete(key);
|
|
}
|
|
}
|
|
}, 15 * 60 * 1000);
|
|
|
|
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
|
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
|
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
|
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
|
|
|
// Actual rate-limit denial counters (incremented only when a request is denied)
|
|
const rateLimitDenied = {
|
|
connections: 0,
|
|
events: 0,
|
|
health: 0,
|
|
adminMetricsAuth: 0,
|
|
roomList: 0
|
|
};
|
|
|
|
// Clean up connection counts and event counts to prevent memory leak
|
|
const rateLimitCleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, entry] of connectionCounts.entries()) {
|
|
if (now > entry.resetTime) {
|
|
connectionCounts.delete(ip);
|
|
}
|
|
}
|
|
for (const [socketId, entry] of eventCounts.entries()) {
|
|
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
|
eventCounts.delete(socketId);
|
|
}
|
|
}
|
|
for (const [ip, entry] of healthCounts.entries()) {
|
|
if (now > entry.resetTime) {
|
|
healthCounts.delete(ip);
|
|
}
|
|
}
|
|
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
|
|
if (now > entry.resetTime) {
|
|
adminMetricsAuthCounts.delete(ip);
|
|
}
|
|
}
|
|
for (const [socketId] of roomListCooldowns.entries()) {
|
|
if (!io.sockets.sockets.has(socketId)) {
|
|
roomListCooldowns.delete(socketId);
|
|
}
|
|
}
|
|
}, 60000);
|
|
|
|
function checkConnectionRate(ip) {
|
|
const now = Date.now();
|
|
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
|
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
|
entry.count++;
|
|
connectionCounts.set(ip, entry);
|
|
if (entry.count <= 10) return true;
|
|
rateLimitDenied.connections++;
|
|
return false;
|
|
}
|
|
|
|
function checkEventRate(socketId) {
|
|
const now = Date.now();
|
|
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
|
|
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
|
|
entry.count++;
|
|
eventCounts.set(socketId, entry);
|
|
if (entry.count <= 30) return true;
|
|
rateLimitDenied.events++;
|
|
return false;
|
|
}
|
|
|
|
function checkHealthRate(ip) {
|
|
const now = Date.now();
|
|
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
|
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
|
entry.count++;
|
|
healthCounts.set(ip, entry);
|
|
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
|
|
rateLimitDenied.health++;
|
|
return false;
|
|
}
|
|
|
|
function checkAdminMetricsAuthRate(ip) {
|
|
const now = Date.now();
|
|
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
|
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
|
entry.count++;
|
|
adminMetricsAuthCounts.set(ip, entry);
|
|
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
|
|
rateLimitDenied.adminMetricsAuth++;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Central peer teardown. Removes a socket from all room state and notifies
|
|
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
|
*
|
|
* @param {string} socketId - The socket.id being removed.
|
|
* @param {string} roomId - The room it belongs to.
|
|
* @param {string} reason - Log label ('disconnect', 'leave', 'reaper', 'dedupe', 'room-switch').
|
|
*/
|
|
function removePeerFromRoom(socketId, roomId, reason) {
|
|
const room = rooms.get(roomId);
|
|
if (!room) return;
|
|
|
|
const peerData = room.peerData.get(socketId);
|
|
if (!peerData) return; // Already cleaned up
|
|
|
|
const { peerId } = peerData;
|
|
|
|
// 1. Remove from room data structures
|
|
room.peers.delete(socketId);
|
|
room.peerIds.delete(socketId);
|
|
room.peerData.delete(socketId);
|
|
|
|
// 2. Remove from global maps
|
|
socketToRoom.delete(socketId);
|
|
const currentSocketId = peerToSocket.get(peerId);
|
|
if (currentSocketId === socketId) {
|
|
peerToSocket.delete(peerId);
|
|
}
|
|
|
|
// 3. Notify remaining peers (use io.to so the removed socket itself
|
|
// doesn't receive it — it has already left or is disconnecting)
|
|
const isPeerStillConnected = Array.from(room.peerData.values()).some(data => data.peerId === peerId);
|
|
if (!isPeerStillConnected) {
|
|
io.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, status: 'left' });
|
|
}
|
|
|
|
// 3.5. Clean up active lobby if a peer leaves
|
|
if (room.activeLobby) {
|
|
room.activeLobby.readyPeers = room.activeLobby.readyPeers.filter(id => id !== peerId);
|
|
if (room.activeLobby.readyPeers.length <= 1 || room.activeLobby.initiatorPeerId === peerId) {
|
|
room.activeLobby = null; // Dissolve lobby
|
|
}
|
|
}
|
|
|
|
// 4. Delete empty room
|
|
if (room.peers.size === 0) {
|
|
rooms.delete(roomId);
|
|
log('ROOM', `Deleted empty room after ${reason}: ${roomId.substring(0, 3)}***`);
|
|
}
|
|
|
|
log('ROOM', `Peer ${peerId} removed (${reason}) from room ${roomId.substring(0, 3)}***`);
|
|
}
|
|
|
|
io.on('connection', (socket) => {
|
|
// Get real client IP behind proxy/CDN
|
|
const forwardedFor = socket.handshake.headers['x-forwarded-for'];
|
|
const clientIp = forwardedFor ? forwardedFor.split(',')[0].trim() : socket.handshake.address;
|
|
socket._clientIp = clientIp;
|
|
|
|
// 1. Connection Rate Limit
|
|
if (!checkConnectionRate(clientIp)) {
|
|
log('SECURITY', `Rate limit exceeded for IP: ${clientIp}`);
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
|
|
// 2. Token & Version Validation
|
|
const clientToken = socket.handshake.query.token;
|
|
const clientVersion = socket.handshake.query.version;
|
|
|
|
if (clientToken !== OFFICIAL_SERVER_TOKEN) {
|
|
log('AUTH', `Unauthorized connection attempt from ${clientIp}`);
|
|
socket.emit(EVENTS.ERROR, { message: 'Unauthorized' });
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
|
|
if (clientVersion) {
|
|
if (typeof clientVersion !== 'string') {
|
|
log('AUTH', `Invalid version type from ${clientIp}`);
|
|
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
const parts = clientVersion.split('.').map(Number);
|
|
const cMaj = parts[0], cMin = parts[1], cPatch = parts[2] || 0;
|
|
const [mMaj, mMin, mPatch] = MIN_VERSION.split('.').map(Number);
|
|
if (isNaN(cMaj) || isNaN(cMin) || isNaN(cPatch)) {
|
|
log('AUTH', `Invalid version format (${clientVersion}) from ${clientIp}`);
|
|
socket.emit(EVENTS.ERROR, { message: 'Invalid version format' });
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
const tooOld = cMaj < mMaj || (cMaj === mMaj && cMin < mMin) || (cMaj === mMaj && cMin === mMin && cPatch < mPatch);
|
|
if (tooOld) {
|
|
log('AUTH', `Version too old (${clientVersion}) from ${clientIp}`);
|
|
socket.emit(EVENTS.ERROR, { message: `Version too old. Minimum: ${MIN_VERSION}` });
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
log('CONN', `New connection: ${socket.id} from ${clientIp}`);
|
|
|
|
socket.on(EVENTS.JOIN_ROOM, async (payload) => {
|
|
if (!checkEventRate(socket.id)) {
|
|
log('SECURITY', `Event rate limit exceeded for socket (JOIN): ${socket.id}`);
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
if (!payload || typeof payload.roomId !== 'string') return;
|
|
|
|
// --- S-1 & S-5: Sanitize and clamp all incoming fields ---
|
|
const password = typeof payload.password === 'string' ? payload.password.substring(0, 128) : null;
|
|
const peerId = typeof payload.peerId === 'string' ? payload.peerId.substring(0, 16) : null;
|
|
const protocolVersion = typeof payload.protocolVersion === 'string' ? payload.protocolVersion.substring(0, 16) : null;
|
|
const roomId = String(payload.roomId || '').replace(/[^a-zA-Z0-9\-]/g, '').substring(0, 64);
|
|
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
|
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
|
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
|
|
|
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
|
|
|
try {
|
|
// Protocol check
|
|
if (protocolVersion !== PROTOCOL_VERSION) {
|
|
log('AUTH', `Protocol mismatch from ${peerId}: ${protocolVersion}`);
|
|
socket.emit(EVENTS.ERROR, { message: 'Incompatible protocol version' });
|
|
return;
|
|
}
|
|
|
|
// Cleanup old room if re-joining
|
|
const oldMapping = socketToRoom.get(socket.id);
|
|
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
|
return; // Already in this room with same peerId, ignore to prevent spam
|
|
}
|
|
if (oldMapping && oldMapping.roomId !== roomId) {
|
|
socket.leave(oldMapping.roomId);
|
|
removePeerFromRoom(socket.id, oldMapping.roomId, 'room-switch');
|
|
}
|
|
|
|
const ip = socket._clientIp || socket.handshake.address;
|
|
if (!checkAuthRate(ip, roomId)) {
|
|
log('AUTH', `Auth rate limit blocked ${ip} from room ${roomId.substring(0, 3)}***`);
|
|
socket.emit(EVENTS.ERROR, { message: "Too many failed attempts. Try again later." });
|
|
return;
|
|
}
|
|
|
|
let room = rooms.get(roomId);
|
|
let createdByMe = false;
|
|
|
|
if (!room) {
|
|
// Acquire per-room creation lock to prevent race conditions
|
|
let lockPromise = roomCreationLocks.get(roomId);
|
|
if (lockPromise) {
|
|
await lockPromise;
|
|
room = rooms.get(roomId);
|
|
}
|
|
if (!room) {
|
|
// Create and store lock before async boundary
|
|
let resolveLock;
|
|
lockPromise = new Promise(resolve => { resolveLock = resolve; });
|
|
roomCreationLocks.set(roomId, lockPromise);
|
|
try {
|
|
if (rooms.size >= MAX_ROOMS) {
|
|
log('ROOM', `Server at capacity: ${rooms.size}/${MAX_ROOMS} rooms — rejecting join`);
|
|
socket.emit(EVENTS.ERROR, { message: "Server capacity reached" });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = hashPassword(password);
|
|
room = {
|
|
passwordHash,
|
|
peers: new Set(),
|
|
peerIds: new Map(),
|
|
peerData: new Map(),
|
|
lastActivity: Date.now()
|
|
};
|
|
rooms.set(roomId, room);
|
|
createdByMe = true;
|
|
log('ROOM', `Created room: ${roomId.substring(0, 3)}***`);
|
|
} finally {
|
|
roomCreationLocks.delete(roomId);
|
|
resolveLock();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!room) {
|
|
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
|
return;
|
|
}
|
|
|
|
if (!createdByMe) {
|
|
if (room.passwordHash) {
|
|
if (!password || hashPassword(password) !== room.passwordHash) {
|
|
recordAuthFailure(ip, roomId);
|
|
log('AUTH', `Invalid password from ${ip} for room ${roomId.substring(0, 3)}***`);
|
|
socket.emit(EVENTS.ERROR, { message: "Invalid password" });
|
|
return;
|
|
}
|
|
}
|
|
if (room.peers.size >= MAX_PEERS_PER_ROOM) {
|
|
log('ROOM', `Room full (${room.peers.size}/${MAX_PEERS_PER_ROOM}): ${roomId.substring(0, 3)}***`);
|
|
socket.emit(EVENTS.ERROR, { message: "Room full" });
|
|
return;
|
|
}
|
|
|
|
// Peer Deduplication: Remove existing socket for the same peerId
|
|
const dedupeSids = [];
|
|
for (const [sid, data] of room.peerData.entries()) {
|
|
if (data.peerId === peerId && sid !== socket.id) {
|
|
dedupeSids.push(sid);
|
|
}
|
|
}
|
|
for (const sid of dedupeSids) {
|
|
// Re-check: the socket might have been replaced by another concurrent join
|
|
const currentMapping = room.peerData.get(sid);
|
|
if (!currentMapping || currentMapping.peerId !== peerId) continue;
|
|
|
|
const oldSocket = io.sockets.sockets.get(sid);
|
|
if (oldSocket) {
|
|
oldSocket.emit(EVENTS.ERROR, { message: 'Deduplication: Another session with this ID joined. Disconnecting...' });
|
|
oldSocket.leave(roomId);
|
|
oldSocket.disconnect(true);
|
|
log('DEDUPE', `Kicked old session for peer ${peerId}`);
|
|
}
|
|
removePeerFromRoom(sid, roomId, 'dedupe');
|
|
}
|
|
}
|
|
|
|
socket.join(roomId);
|
|
room.peers.add(socket.id);
|
|
room.peerIds.set(socket.id, peerId);
|
|
room.peerData.set(socket.id, {
|
|
peerId,
|
|
username: username || null,
|
|
tabTitle: tabTitle || null,
|
|
mediaTitle: mediaTitle || null,
|
|
lastSeen: Date.now()
|
|
});
|
|
socketToRoom.set(socket.id, { roomId, peerId });
|
|
peerToSocket.set(peerId, socket.id);
|
|
|
|
socket.to(roomId).emit(EVENTS.PEER_STATUS, { peerId, username: username || null, tabTitle: tabTitle || null, mediaTitle: mediaTitle || null, status: 'joined' });
|
|
socket.emit(EVENTS.ROOM_DATA, {
|
|
roomId,
|
|
peers: Array.from(room.peers).map(sid => room.peerData.get(sid)),
|
|
activeLobby: room.activeLobby || null
|
|
});
|
|
log('ROOM', `Peer ${peerId} joined: ${roomId.substring(0, 3)}***`);
|
|
} catch (err) {
|
|
log('ERROR', `Join error for ${socket.id}`, err);
|
|
if (socket.connected) {
|
|
socket.emit(EVENTS.ERROR, { message: "Join error" });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Relay Loop with Rate Limiting
|
|
const relayEvents = [
|
|
EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
|
|
EVENTS.PEER_STATUS, EVENTS.FORCE_SYNC_PREPARE,
|
|
EVENTS.FORCE_SYNC_ACK, EVENTS.FORCE_SYNC_EXECUTE,
|
|
EVENTS.EPISODE_LOBBY, EVENTS.EPISODE_READY,
|
|
EVENTS.EPISODE_LOBBY_CANCEL
|
|
];
|
|
|
|
relayEvents.forEach(eventName => {
|
|
socket.on(eventName, (data) => {
|
|
try {
|
|
if (!checkEventRate(socket.id)) {
|
|
log('SECURITY', `Event rate limit exceeded for socket: ${socket.id}`);
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
|
|
if (!data || typeof data !== 'object') return;
|
|
|
|
const mapping = socketToRoom.get(socket.id);
|
|
if (mapping) {
|
|
const room = rooms.get(mapping.roomId);
|
|
if (room) {
|
|
room.lastActivity = Date.now();
|
|
|
|
// --- S-2 & S-3: Sanitize ALL relay fields (strings, numbers, booleans) ---
|
|
const clamp = (val, max) => typeof val === 'string' ? val.substring(0, max) : undefined;
|
|
const clampNum = (val, min, max) => typeof val === 'number' && Number.isFinite(val) ? Math.max(min, Math.min(max, val)) : undefined;
|
|
const validState = (val) => (val === 'playing' || val === 'paused') ? val : undefined;
|
|
const validBool = (val) => typeof val === 'boolean' ? val : undefined;
|
|
|
|
const existing = room.peerData.get(socket.id) || { peerId: mapping.peerId };
|
|
room.peerData.set(socket.id, {
|
|
...existing,
|
|
username: data.username !== undefined ? (clamp(data.username, 30) ?? existing.username) : existing.username,
|
|
tabTitle: data.tabTitle !== undefined ? (clamp(data.tabTitle, 100) ?? existing.tabTitle) : existing.tabTitle,
|
|
mediaTitle: data.mediaTitle !== undefined ? (clamp(data.mediaTitle, 100) ?? existing.mediaTitle) : existing.mediaTitle,
|
|
playbackState: data.playbackState !== undefined ? (validState(data.playbackState) ?? existing.playbackState) : existing.playbackState,
|
|
currentTime: data.currentTime !== undefined ? (clampNum(data.currentTime, 0, 86400) ?? existing.currentTime) : existing.currentTime,
|
|
volume: data.volume !== undefined ? (clampNum(data.volume, 0, 1) ?? existing.volume) : existing.volume,
|
|
muted: data.muted !== undefined ? (validBool(data.muted) ?? existing.muted) : existing.muted,
|
|
lastSeen: Date.now()
|
|
});
|
|
|
|
// --- S-3: Construct clean relay payload — never forward raw client data ---
|
|
const relayPayload = {
|
|
senderId: mapping.peerId,
|
|
seq: clampNum(data.seq, 0, Number.MAX_SAFE_INTEGER),
|
|
currentTime: clampNum(data.currentTime, 0, 86400),
|
|
targetTime: clampNum(data.targetTime, 0, 86400),
|
|
playbackState: validState(data.playbackState),
|
|
username: clamp(data.username, 30),
|
|
tabTitle: clamp(data.tabTitle, 100),
|
|
mediaTitle: clamp(data.mediaTitle, 100),
|
|
volume: clampNum(data.volume, 0, 1),
|
|
muted: validBool(data.muted),
|
|
peerId: mapping.peerId,
|
|
status: typeof data.status === 'string' ? data.status.substring(0, 16) : undefined,
|
|
expectedTitle: clamp(data.expectedTitle, 100),
|
|
title: clamp(data.title, 100),
|
|
actionTimestamp: clampNum(data.actionTimestamp, 0, Number.MAX_SAFE_INTEGER),
|
|
};
|
|
// Strip undefined keys for clean wire format
|
|
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
|
socket.to(mapping.roomId).emit(eventName, relayPayload);
|
|
|
|
// --- Side-effects: Server-side Episode Lobby Tracking ---
|
|
if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) {
|
|
room.activeLobby = {
|
|
expectedTitle: relayPayload.expectedTitle,
|
|
initiatorPeerId: mapping.peerId,
|
|
readyPeers: [mapping.peerId]
|
|
};
|
|
} else if (eventName === EVENTS.EPISODE_READY && room.activeLobby) {
|
|
if (!room.activeLobby.readyPeers.includes(mapping.peerId)) {
|
|
room.activeLobby.readyPeers.push(mapping.peerId);
|
|
}
|
|
} else if ((eventName === EVENTS.FORCE_SYNC_PREPARE || eventName === EVENTS.FORCE_SYNC_EXECUTE || eventName === EVENTS.EPISODE_LOBBY_CANCEL) && room.activeLobby) {
|
|
room.activeLobby = null;
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log('ERROR', `Relay handler error for ${eventName}: ${err.message}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on(EVENTS.GET_ROOMS, () => {
|
|
if (!checkEventRate(socket.id)) {
|
|
log('SECURITY', `Event rate limit exceeded for socket (GET_ROOMS): ${socket.id}`);
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
|
|
rateLimitDenied.roomList++;
|
|
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
|
|
return;
|
|
}
|
|
const list = Array.from(rooms.entries()).map(([id, r]) => ({
|
|
id,
|
|
peerCount: r.peers.size,
|
|
hasPassword: !!r.passwordHash
|
|
}));
|
|
socket.emit(EVENTS.ROOM_LIST, { rooms: list });
|
|
});
|
|
|
|
socket.on(EVENTS.LEAVE_ROOM, () => {
|
|
const mapping = socketToRoom.get(socket.id);
|
|
if (mapping) {
|
|
socket.leave(mapping.roomId);
|
|
removePeerFromRoom(socket.id, mapping.roomId, 'leave');
|
|
}
|
|
});
|
|
|
|
socket.on(EVENTS.EVENT_ACK, (data) => {
|
|
if (!checkEventRate(socket.id)) {
|
|
log('SECURITY', `Event rate limit exceeded for socket (ACK): ${socket.id}`);
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
if (!data || typeof data !== 'object') return;
|
|
if (typeof data.targetId !== 'string') return;
|
|
if (data.actionTimestamp !== undefined && (typeof data.actionTimestamp !== 'number' || !Number.isFinite(data.actionTimestamp))) return;
|
|
|
|
const senderMapping = socketToRoom.get(socket.id);
|
|
const targetSocketId = peerToSocket.get(data.targetId);
|
|
const targetMapping = targetSocketId ? socketToRoom.get(targetSocketId) : null;
|
|
|
|
// Security: Only relay ACK if both peers are in the same room
|
|
if (senderMapping && targetMapping && senderMapping.roomId === targetMapping.roomId) {
|
|
io.to(targetSocketId).emit(EVENTS.EVENT_ACK, {
|
|
senderId: senderMapping.peerId,
|
|
actionTimestamp: data.actionTimestamp
|
|
});
|
|
} else {
|
|
log('SECURITY', `Blocked cross-room ACK attempt from ${socket.id} to ${data.targetId}`);
|
|
}
|
|
});
|
|
|
|
socket.on(EVENTS.PING, (data) => {
|
|
if (!checkEventRate(socket.id)) {
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
if (!data || typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
|
|
|
|
if (typeof data.target === 'string' && data.target.length > 0) {
|
|
const targetSocketId = peerToSocket.get(data.target);
|
|
const senderMapping = socketToRoom.get(socket.id);
|
|
if (targetSocketId && senderMapping && data.target !== senderMapping.peerId) {
|
|
const targetMapping = socketToRoom.get(targetSocketId);
|
|
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
|
|
io.to(targetSocketId).emit(EVENTS.PING, { t: data.t, sender: senderMapping.peerId });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
socket.emit(EVENTS.PONG, { t: data.t });
|
|
});
|
|
|
|
socket.on(EVENTS.PONG, (data) => {
|
|
if (!checkEventRate(socket.id)) {
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
if (!data || typeof data.target !== 'string' || data.target.length === 0) return;
|
|
if (typeof data.t !== 'number' || !Number.isFinite(data.t)) return;
|
|
|
|
const senderMapping = socketToRoom.get(socket.id);
|
|
if (!senderMapping || data.target === senderMapping.peerId) return;
|
|
|
|
const targetSocketId = peerToSocket.get(data.target);
|
|
if (!targetSocketId) return;
|
|
|
|
const targetMapping = socketToRoom.get(targetSocketId);
|
|
if (targetMapping && targetMapping.roomId === senderMapping.roomId) {
|
|
io.to(targetSocketId).emit(EVENTS.PONG, { t: data.t });
|
|
}
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
eventCounts.delete(socket.id);
|
|
roomListCooldowns.delete(socket.id);
|
|
const mapping = socketToRoom.get(socket.id);
|
|
if (mapping) {
|
|
// Socket is already disconnected — no need to call socket.leave().
|
|
// removePeerFromRoom uses io.to() for notifications, which correctly
|
|
// excludes this dead socket since it has already left all rooms.
|
|
removePeerFromRoom(socket.id, mapping.roomId, 'disconnect');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Active Room & Dead Peer Cleanup (Every 2m)
|
|
const roomCleanupInterval = setInterval(() => {
|
|
const now = Date.now();
|
|
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
|
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
|
|
|
// Snapshot room keys to avoid mutation during iteration
|
|
const roomIds = Array.from(rooms.keys());
|
|
for (const roomId of roomIds) {
|
|
const room = rooms.get(roomId);
|
|
if (!room) continue; // Room may have been deleted between snapshot and now
|
|
// 1. Prune dead peers
|
|
// Snapshot keys first — we must not mutate peerData while iterating it.
|
|
const staleSids = [];
|
|
for (const [sid, data] of room.peerData.entries()) {
|
|
if (data.lastSeen && data.lastSeen < peerCutoff) {
|
|
staleSids.push(sid);
|
|
}
|
|
}
|
|
for (const sid of staleSids) {
|
|
// Gracefully evict the socket from the Socket.IO room if it is
|
|
// still technically connected (zombie with no heartbeat).
|
|
const deadSocket = io.sockets?.sockets?.get(sid);
|
|
if (deadSocket) deadSocket.leave(roomId);
|
|
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
|
removePeerFromRoom(sid, roomId, 'reaper');
|
|
}
|
|
|
|
// 2. Prune empty or inactive rooms
|
|
const currentRoom = rooms.get(roomId);
|
|
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
|
|
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
|
|
rooms.delete(roomId);
|
|
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
|
|
}
|
|
}
|
|
}, 2 * 60 * 1000);
|
|
|
|
export function startServer(port = PORT, host) {
|
|
if (httpServer.listening) return Promise.resolve(httpServer);
|
|
return new Promise((resolve, reject) => {
|
|
const onError = (err) => {
|
|
httpServer.off('listening', onListening);
|
|
reject(err);
|
|
};
|
|
const onListening = () => {
|
|
httpServer.off('error', onError);
|
|
const address = httpServer.address();
|
|
const actualPort = address && typeof address === 'object' ? address.port : port;
|
|
log('SERVER', `KoalaSync Relay running on port ${actualPort}`);
|
|
resolve(httpServer);
|
|
};
|
|
httpServer.once('error', onError);
|
|
httpServer.once('listening', onListening);
|
|
if (host) {
|
|
httpServer.listen(port, host);
|
|
} else {
|
|
httpServer.listen(port);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- M-4: Graceful Shutdown ---
|
|
function gracefulShutdown(signal) {
|
|
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
|
// 1. Notify all connected clients so they can display a meaningful message
|
|
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Reconnecting automatically...' });
|
|
// 2. Stop accepting new HTTP connections
|
|
httpServer.close(() => {
|
|
log('SERVER', 'HTTP server closed. Exiting.');
|
|
process.exit(0);
|
|
});
|
|
// 3. Safety net: force-exit after 5s if connections don't drain
|
|
setTimeout(() => {
|
|
log('SERVER', 'Force-exit after timeout.');
|
|
process.exit(1);
|
|
}, 5000);
|
|
}
|
|
|
|
export async function stopServerForTests() {
|
|
clearInterval(authFailureCleanupInterval);
|
|
clearInterval(rateLimitCleanupInterval);
|
|
clearInterval(roomCleanupInterval);
|
|
rooms.clear();
|
|
socketToRoom.clear();
|
|
peerToSocket.clear();
|
|
roomCreationLocks.clear();
|
|
connectionCounts.clear();
|
|
failedAuthAttempts.clear();
|
|
eventCounts.clear();
|
|
healthCounts.clear();
|
|
adminMetricsAuthCounts.clear();
|
|
roomListCooldowns.clear();
|
|
healthResponseCache.clear();
|
|
io.removeAllListeners();
|
|
io.disconnectSockets(true);
|
|
if (!httpServer.listening) return;
|
|
await new Promise((resolve, reject) => {
|
|
httpServer.close((err) => err ? reject(err) : resolve());
|
|
});
|
|
}
|
|
|
|
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
|
|
if (isMainModule) {
|
|
startServer(PORT);
|
|
|
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
|
process.exit(1);
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
log('ERROR', `Unhandled rejection: ${reason}`);
|
|
process.exit(1);
|
|
});
|
|
}
|