From 81c50eff162af97da1840620f8c81f6fc852ac40 Mon Sep 17 00:00:00 2001 From: Koala <6156589+Shik3i@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:48:49 +0200 Subject: [PATCH] Harden room discovery and health metrics --- PRIVACY.md | 2 +- SECURITY.md | 2 +- docker-compose.caddy.example.yml | 3 +- docker-compose.ip.example.yml | 3 +- docs/HOW_IT_WORKS.md | 4 +- extension/content.js | 27 ++++---- extension/popup.js | 91 +++++++++++++++++++++++-- scripts/test-content-video-finder.js | 64 +++++++++++++++++ scripts/test-popup-refresh-cooldown.mjs | 27 ++++++++ scripts/test-server-ops.mjs | 66 ++++++++++++++++++ server/.env.example | 4 +- server/README.md | 10 ++- server/index.js | 35 ++++++++-- server/ops.js | 76 +++++++++++++++++++++ server/package-lock.json | 10 --- server/package.json | 1 - 16 files changed, 379 insertions(+), 46 deletions(-) create mode 100644 scripts/test-content-video-finder.js create mode 100644 scripts/test-popup-refresh-cooldown.mjs create mode 100644 scripts/test-server-ops.mjs create mode 100644 server/ops.js diff --git a/PRIVACY.md b/PRIVACY.md index cab4d00..92ad8cd 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -7,7 +7,7 @@ KoalaSync is designed with a **Security-First & Volatile** architecture. This me ## 1. Data Processing (In-Memory Only) KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed. - **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room. -- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password. +- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime. - **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect. ### Data Retention diff --git a/SECURITY.md b/SECURITY.md index 5f7f151..14841da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,7 +73,7 @@ Encrypt sensitive findings with our PGP key (available on request). KoalaSync's security is grounded in its architecture: - **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect. -- **bcrypt room passwords**: Plaintext passwords never stored. Brute-force protection: 5 attempts → 15-minute IP lockout. +- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout. - **Rate limiting**: Connection rate (IP-based, 60s window) and event rate (per-socket, 10s window). - **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server. - **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`. diff --git a/docker-compose.caddy.example.yml b/docker-compose.caddy.example.yml index 8ea7619..41b813c 100644 --- a/docker-compose.caddy.example.yml +++ b/docker-compose.caddy.example.yml @@ -8,7 +8,8 @@ services: - PORT=3000 # Port KoalaSync listens on inside the container - MIN_VERSION=1.0.0 # Minimum client version allowed to connect - MAX_ROOMS=100 # Maximum number of rooms that can exist - - MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room + - MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room + - ADMIN_METRICS_TOKEN= # Optional: enables aggregate-only /health metrics with Bearer auth pids_limit: 2048 # Limits the container to 2048 process IDs for safety networks: # Attaches the service to the networks listed below - caddy_net # Joins the pre-existing Caddy network for reverse proxying diff --git a/docker-compose.ip.example.yml b/docker-compose.ip.example.yml index 66227c4..1d2bda1 100644 --- a/docker-compose.ip.example.yml +++ b/docker-compose.ip.example.yml @@ -10,7 +10,8 @@ services: - PORT=3000 # Port KoalaSync listens on inside the container - MIN_VERSION=1.0.0 # Minimum client version allowed to connect - MAX_ROOMS=100 # Maximum number of rooms that can exist - - MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room + - MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room + - ADMIN_METRICS_TOKEN= # Optional: enables aggregate-only /health metrics with Bearer auth pids_limit: 2048 # Limits the container to 2048 process IDs for safety networks: # Attaches the service to the networks listed below bond0_network: # Network name as referenced by the service diff --git a/docs/HOW_IT_WORKS.md b/docs/HOW_IT_WORKS.md index ef08a54..6fa5bc3 100644 --- a/docs/HOW_IT_WORKS.md +++ b/docs/HOW_IT_WORKS.md @@ -51,7 +51,7 @@ Click **"Create Room"** in the popup's Room tab: 4. **Server-side processing**: - All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars. - - The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored). + - The server **hashes the password** with a keyed SHA-256 HMAC and stores only that hash in RAM (the plaintext is never stored). - A new room object is created in memory with the peer's data. - The server responds with `ROOM_DATA` containing the list of peers in the room. @@ -89,7 +89,7 @@ When your friend opens the link in their browser: - After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`. - `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`. - `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server. - - The server validates the password against the stored bcrypt hash. + - The server validates the password against the stored keyed SHA-256 HMAC hash. - On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers. - The join page updates to show "✅ Successfully joined!". diff --git a/extension/content.js b/extension/content.js index e40bcc9..ba8d37d 100644 --- a/extension/content.js +++ b/extension/content.js @@ -80,27 +80,26 @@ // --- Helper: find the best video element on the page --- // Prefers larger, visible videos over tiny preview/trailer elements. function findVideo(root = document) { - const allVideos = root.querySelectorAll('video'); - if (allVideos.length === 0) { - // Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive) - // or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.) - // while guaranteeing 100% airtight compatibility with all video web components in the wild. - const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player'); - for (const el of potentialHosts) { - if (el.shadowRoot) { - const found = findVideo(el.shadowRoot); - if (found) return found; - } + const candidates = Array.from(root.querySelectorAll('video')); + + // Scan likely media hosts even when light-DOM videos exist; many players + // expose a tiny preview/ad video outside Shadow DOM and the real player inside. + const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player'); + for (const el of potentialHosts) { + if (el.shadowRoot) { + const found = findVideo(el.shadowRoot); + if (found) candidates.push(found); } - return null; } + if (candidates.length === 0) return null; + // Multiple videos found → pick the best one - if (allVideos.length === 1) return allVideos[0]; + if (candidates.length === 1) return candidates[0]; let best = null; let bestScore = -1; - for (const v of allVideos) { + for (const v of candidates) { if (v.tagName !== 'VIDEO') continue; // Score: visible area + bonus for unmuted + bonus for longer duration const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0); diff --git a/extension/popup.js b/extension/popup.js index ecc5418..edd3207 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -66,21 +66,32 @@ let popupIntervals = []; let populateTabsToken = null; let errorToken = 0; let forceSyncDone = false; +let connectionErrorTimer = null; +let pendingConnectionErrorMsg = null; +let roomListRefreshTimer = null; +const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000; // --- Helpers --- +function clearConnectionErrorTimer() { + if (connectionErrorTimer) { + clearTimeout(connectionErrorTimer); + connectionErrorTimer = null; + } + pendingConnectionErrorMsg = null; +} // --- Initialization --- async function init() { // Load Settings const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']); - + let activeLang = data.locale; if (!activeLang) { const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0]; activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en'; chrome.storage.sync.set({ locale: activeLang }); } - + await loadLocale(activeLang); translateDOM(); @@ -148,8 +159,8 @@ async function init() { // Check for invite link on landing page checkInviteLink(); - // Initial room list fetch - chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' }); + // Initialize public rooms placeholder + renderEmpty(elements.publicRooms, 'rooms'); // Debug Info Refresh popupIntervals.push(setInterval(refreshDebugInfo, 2000)); @@ -952,7 +963,12 @@ function showToast(message, type = 'info', duration = 3000) { if (!container) return; const toast = document.createElement('div'); toast.className = `toast toast-${type}`; - toast.textContent = message; + toast.textContent = message || ''; + toast.style.whiteSpace = 'pre-wrap'; + + const delay = Math.max(0, duration - 600); + toast.style.animation = `toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in ${delay}ms forwards`; + container.appendChild(toast); setTimeout(() => toast.remove(), duration); } @@ -981,6 +997,7 @@ elements.roomId.addEventListener('input', () => { }); elements.joinBtn.addEventListener('click', async () => { + clearConnectionErrorTimer(); if (elements.joinBtn.disabled) return; const roomIdInput = elements.roomId.value.trim(); const isCreating = !roomIdInput; @@ -1040,6 +1057,7 @@ elements.joinBtn.addEventListener('click', async () => { }); elements.leaveBtn.addEventListener('click', async () => { + clearConnectionErrorTimer(); chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' }); await chrome.storage.sync.set({ roomId: '', password: '' }); elements.roomId.value = ''; @@ -1080,6 +1098,13 @@ if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => { }); elements.refreshRooms.addEventListener('click', () => { + if (elements.refreshRooms.disabled) return; + elements.refreshRooms.disabled = true; + roomListRefreshTimer = setTimeout(() => { + elements.refreshRooms.disabled = false; + roomListRefreshTimer = null; + }, ROOM_LIST_REFRESH_COOLDOWN_MS); + elements.publicRooms.replaceChildren(); const el = document.createElement('div'); el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;'; @@ -1310,7 +1335,25 @@ chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'LOG_UPDATE') { refreshLogs(); if (msg.log && msg.log.type === 'error') { - showError(msg.log.message); + const errMsg = msg.log.message || ''; + const isConnErr = typeof errMsg === 'string' && ( + errMsg.toLowerCase().includes('connection') || + errMsg.toLowerCase().includes('websocket') + ); + if (isConnErr) { + pendingConnectionErrorMsg = msg.log.message; + if (!connectionErrorTimer) { + connectionErrorTimer = setTimeout(() => { + if (pendingConnectionErrorMsg) { + showError(pendingConnectionErrorMsg); + } + connectionErrorTimer = null; + pendingConnectionErrorMsg = null; + }, 5000); + } + } else { + showError(msg.log.message); + } } } else if (msg.type === 'ACTION_UPDATE') { const state = msg.state; @@ -1366,6 +1409,9 @@ chrome.runtime.onMessage.addListener((msg) => { updatePeerList(msg.peers); if (msg.peers) detectPeerChanges(msg.peers); } else if (msg.type === 'CONNECTION_STATUS') { + if (msg.status === 'connected') { + clearConnectionErrorTimer(); + } if (msg.status !== 'reconnecting') { applyConnectionStatus(msg.status); reconnectSlowMode = false; @@ -1397,6 +1443,13 @@ chrome.runtime.onMessage.addListener((msg) => { } else if (msg.type === 'ROOM_LIST') { updateRoomList(msg.rooms); } else if (msg.type === 'JOIN_STATUS') { + if (joinBtnTimeout) { + clearTimeout(joinBtnTimeout); + joinBtnTimeout = null; + } + elements.joinBtn.disabled = false; + elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); + if (msg.success) { // Final confirmation of join from background chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => { @@ -1589,6 +1642,28 @@ elements.copyLogs.addEventListener('click', () => { const original = elements.copyLogs.textContent; elements.copyLogs.textContent = getMessage('TOAST_LOGS_COPIED'); setTimeout(() => { elements.copyLogs.textContent = original; }, 2000); + + // Construct rich multiline toast summary + const verStr = safe(status.version, '?'); + const logsCount = logs.length; + const historyCount = history.length; + const peersCount = Array.isArray(status.peers) ? status.peers.length : 0; + + let summary = `📋 Debug Report Copied!\n`; + summary += `• Version: v${verStr}\n`; + summary += `• System Logs: ${logsCount} entries\n`; + summary += `• Sync Actions: ${historyCount} captured\n`; + if (status.roomId) { + summary += `• Peers in Room: ${peersCount}\n`; + } + if (rawVideo && vs.found) { + const stateStr = vs.paused === true ? 'Paused' : (vs.paused === false ? 'Playing' : 'Unknown'); + const timeStr = typeof vs.currentTime === 'number' ? `${Math.round(vs.currentTime)}s` : '?'; + summary += `• Video: ${stateStr} at ${timeStr}\n`; + } + summary += `Paste it in markdown to view!`; + + showToast(summary, 'success', 5000); }).catch(() => { showToast(getMessage('TOAST_COPY_FAILED'), 'error'); }); @@ -1753,6 +1828,10 @@ window.addEventListener('unload', () => { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; } + if (roomListRefreshTimer) { + clearTimeout(roomListRefreshTimer); + roomListRefreshTimer = null; + } }); // --- Episode Lobby UI --- diff --git a/scripts/test-content-video-finder.js b/scripts/test-content-video-finder.js new file mode 100644 index 0000000..a80443b --- /dev/null +++ b/scripts/test-content-video-finder.js @@ -0,0 +1,64 @@ +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); + +const contentPath = path.join(__dirname, '..', 'extension', 'content.js'); +const source = fs.readFileSync(contentPath, 'utf8'); + +function extractFunction(name, text) { + const start = text.indexOf(`function ${name}`); + assert.notStrictEqual(start, -1, `${name} not found`); + + const bodyStart = text.indexOf('{', start); + let depth = 0; + for (let i = bodyStart; i < text.length; i++) { + if (text[i] === '{') depth++; + if (text[i] === '}') depth--; + if (depth === 0) return text.slice(start, i + 1); + } + throw new Error(`${name} body did not terminate`); +} + +function makeVideo(name, width, height, options = {}) { + return { + name, + tagName: 'VIDEO', + videoWidth: width, + videoHeight: height, + offsetWidth: width, + offsetHeight: height, + muted: options.muted ?? true, + duration: options.duration ?? 0 + }; +} + +const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 }); +const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 }); + +const shadowRoot = { + querySelectorAll(selector) { + if (selector === 'video') return [shadowPlayer]; + return []; + } +}; + +const shadowHost = { shadowRoot }; + +const fakeDocument = { + querySelectorAll(selector) { + if (selector === 'video') return [lightPreview]; + return [shadowHost]; + } +}; + +const fnSource = extractFunction('findVideo', source); +const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument); + +const selected = findVideo(fakeDocument); +assert.strictEqual( + selected, + shadowPlayer, + 'findVideo should score Shadow DOM videos together with light DOM videos' +); + +console.log('content video finder tests passed'); diff --git a/scripts/test-popup-refresh-cooldown.mjs b/scripts/test-popup-refresh-cooldown.mjs new file mode 100644 index 0000000..0bb4445 --- /dev/null +++ b/scripts/test-popup-refresh-cooldown.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cwd } from 'node:process'; + +const popupPath = path.join(cwd(), 'extension', 'popup.js'); +const source = fs.readFileSync(popupPath, 'utf8'); + +assert.match( + source, + /const ROOM_LIST_REFRESH_COOLDOWN_MS\s*=\s*11000;/, + 'popup should define an 11 second room-list refresh cooldown' +); + +assert.match( + source, + /elements\.refreshRooms\.disabled\s*=\s*true;/, + 'refresh button should be disabled while cooldown is active' +); + +assert.match( + source, + /setTimeout\(\(\)\s*=>\s*{\s*elements\.refreshRooms\.disabled\s*=\s*false;/s, + 'refresh button should be re-enabled after the cooldown' +); + +console.log('popup refresh cooldown tests passed'); diff --git a/scripts/test-server-ops.mjs b/scripts/test-server-ops.mjs new file mode 100644 index 0000000..79f9ca8 --- /dev/null +++ b/scripts/test-server-ops.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { + buildHealthPayload, + checkCooldown, + isAdminMetricsAuthorized +} from '../server/ops.js'; + +const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token'); +assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics'); + +const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token'); +assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics'); + +const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token'); +assert.equal(correctAuth, true, 'correct bearer token should authorize metrics'); + +const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', ''); +assert.equal(disabledAuth, false, 'empty admin token disables admin metrics'); + +const cooldowns = new Map(); +assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes'); +assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails'); +assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes'); + +const roomA = { peers: new Set(['a', 'b']), activeLobby: null }; +const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } }; +const rooms = new Map([['room-a', roomA], ['room-b', roomB]]); + +const basicHealth = buildHealthPayload({ + rooms, + connections: 5, + includeMetrics: false, + now: 1234, + uptime: 99, + memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }), + rateLimitSizes: { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 } +}); + +assert.deepEqual( + Object.keys(basicHealth).sort(), + ['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(), + 'basic health should not expose extended metrics' +); + +const adminHealth = buildHealthPayload({ + rooms, + connections: 5, + includeMetrics: true, + now: 1234, + uptime: 99, + memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }), + rateLimitSizes: { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 } +}); + +assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count'); +assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies'); +assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size'); +assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size'); +assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory'); +assert.deepEqual( + adminHealth.rateLimitEntries, + { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 }, + 'admin metrics should expose aggregate rate-limit map sizes' +); + +console.log('server ops tests passed'); diff --git a/server/.env.example b/server/.env.example index 815b75b..3de0782 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,4 +1,6 @@ PORT=3000 MIN_VERSION=1.0.0 MAX_ROOMS=1000 -MAX_PEERS_PER_ROOM=50 +MAX_PEERS_PER_ROOM=25 +# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer +ADMIN_METRICS_TOKEN= diff --git a/server/README.md b/server/README.md index 92feb74..7824a55 100644 --- a/server/README.md +++ b/server/README.md @@ -14,10 +14,17 @@ Copy `.env.example` to `.env` and configure your settings. ```bash PORT=3000 MAX_ROOMS=1000 -MAX_PEERS_PER_ROOM=50 +MAX_PEERS_PER_ROOM=25 MIN_VERSION=1.0.0 +# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer +ADMIN_METRICS_TOKEN= ``` +### Health & Metrics +`GET /` and `GET /health` are IP-rate-limited. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp. + +If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer ` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data. + ### Docker (Recommended) The server is available as a pre-built image on GHCR. ```bash @@ -38,6 +45,7 @@ npm start ## Security - **Rate Limiting**: IP-based connection limits and socket-based event limits. +- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket. - **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`. - **Single Source of Truth**: The server imports constants directly from the root `shared/` directory. - **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity. diff --git a/server/index.js b/server/index.js index 07d5232..0d01023 100644 --- a/server/index.js +++ b/server/index.js @@ -4,6 +4,7 @@ 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, isAdminMetricsAuthorized } from './ops.js'; dotenv.config(); @@ -15,8 +16,10 @@ function hashPassword(password) { 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) || 50; +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; const app = express(); app.set('trust proxy', 1); // For real client IP through reverse proxy @@ -35,13 +38,20 @@ app.get('/health', (req, res) => { if (!checkHealthRate(clientIp)) { return res.status(429).json({ error: 'Rate limited' }); } - res.json({ - status: 'ok', - uptime: process.uptime(), - rooms: rooms.size, + const includeMetrics = isAdminMetricsAuthorized(req.get('authorization'), ADMIN_METRICS_TOKEN); + res.json(buildHealthPayload({ + rooms, connections: io.engine?.clientsCount ?? 0, - timestamp: Date.now() - }); + includeMetrics, + uptime: process.uptime(), + rateLimitSizes: { + connections: connectionCounts.size, + events: eventCounts.size, + health: healthCounts.size, + authFailures: failedAuthAttempts.size, + roomList: roomListCooldowns.size + } + })); }); const httpServer = createServer(app); @@ -139,6 +149,7 @@ setInterval(() => { const eventCounts = new Map(); // socketId -> { count, resetTime } const healthCounts = new Map(); // ip -> { count, resetTime } +const roomListCooldowns = new Map(); // socketId -> last allowed timestamp // Clean up connection counts and event counts to prevent memory leak setInterval(() => { @@ -158,6 +169,11 @@ setInterval(() => { healthCounts.delete(ip); } } + for (const [socketId] of roomListCooldowns.entries()) { + if (!io.sockets.sockets.has(socketId)) { + roomListCooldowns.delete(socketId); + } + } }, 60000); function checkConnectionRate(ip) { @@ -541,6 +557,10 @@ io.on('connection', (socket) => { socket.disconnect(true); return; } + if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) { + 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, @@ -584,6 +604,7 @@ io.on('connection', (socket) => { 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(). diff --git a/server/ops.js b/server/ops.js new file mode 100644 index 0000000..ef05281 --- /dev/null +++ b/server/ops.js @@ -0,0 +1,76 @@ +import crypto from 'crypto'; + +export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) { + const lastAllowedAt = cooldowns.get(key) || 0; + if (now - lastAllowedAt < windowMs) { + return false; + } + cooldowns.set(key, now); + return true; +} + +export function isAdminMetricsAuthorized(authHeader, adminToken) { + if (!adminToken || typeof adminToken !== 'string') return false; + if (!authHeader || typeof authHeader !== 'string') return false; + + const prefix = 'Bearer '; + if (!authHeader.startsWith(prefix)) return false; + + const provided = authHeader.slice(prefix.length); + if (!provided) return false; + + const expectedBuffer = Buffer.from(adminToken); + const providedBuffer = Buffer.from(provided); + + if (expectedBuffer.length !== providedBuffer.length) return false; + return crypto.timingSafeEqual(expectedBuffer, providedBuffer); +} + +export function buildHealthPayload({ + rooms, + connections, + includeMetrics = false, + now = Date.now(), + uptime = 0, + memoryUsage = () => process.memoryUsage(), + rateLimitSizes = {} +}) { + const payload = { + status: 'ok', + uptime, + rooms: rooms.size, + connections, + timestamp: now + }; + + if (!includeMetrics) return payload; + + const roomValues = Array.from(rooms.values()); + const roomSizes = roomValues.map(room => room.peers?.size || 0); + const peers = roomSizes.reduce((sum, size) => sum + size, 0); + const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0; + const avgPeersPerRoom = roomSizes.length > 0 + ? Math.round((peers / roomSizes.length) * 100) / 100 + : 0; + const mem = memoryUsage(); + + return { + ...payload, + peers, + roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length, + avgPeersPerRoom, + maxPeersInRoom, + rateLimitEntries: { + connections: rateLimitSizes.connections || 0, + events: rateLimitSizes.events || 0, + health: rateLimitSizes.health || 0, + authFailures: rateLimitSizes.authFailures || 0, + roomList: rateLimitSizes.roomList || 0 + }, + memory: { + rss: mem.rss, + heapUsed: mem.heapUsed, + heapTotal: mem.heapTotal + } + }; +} diff --git a/server/package-lock.json b/server/package-lock.json index 89cc9c6..871b7ca 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "express": "^5.2.1", "socket.io": "^4.8.3" @@ -70,15 +69,6 @@ "node": "^4.5.0 || >= 5.9" } }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", diff --git a/server/package.json b/server/package.json index 40c4d8d..aff0288 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,6 @@ "license": "ISC", "type": "module", "dependencies": { - "bcryptjs": "^3.0.3", "dotenv": "^17.4.2", "express": "^5.2.1", "socket.io": "^4.8.3"