mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 04:20:25 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81c50eff16 | |||
| bf0fb5741f | |||
| 6d2355f404 | |||
| 732d585273 | |||
| 666808f876 |
@@ -4,6 +4,14 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.2] — 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
|
||||
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.1] — 2026-06-01
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.1 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.2 Release!</b> — See what's changed</a></p>
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
|
||||
+1
-1
@@ -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'`.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 889 KiB |
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!".
|
||||
|
||||
|
||||
+13
-14
@@ -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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
+85
-6
@@ -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 ---
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.0.1",
|
||||
"version": "2.0.2",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
+3
-1
@@ -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 <token>
|
||||
ADMIN_METRICS_TOKEN=
|
||||
|
||||
+9
-1
@@ -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 <token>
|
||||
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 <token>` 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.
|
||||
|
||||
+28
-7
@@ -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().
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -310,7 +310,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const updateDynamicVersion = async () => {
|
||||
try {
|
||||
const versionPath = document.documentElement.lang === 'de' ? '../version.json' : 'version.json';
|
||||
const versionPath = document.documentElement.lang === 'en' ? 'version.json' : '../version.json';
|
||||
const response = await fetch(versionPath);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.0.1",
|
||||
"date": "2026-06-01T13:59:31Z"
|
||||
"version": "2.0.2",
|
||||
"date": "2026-06-02T06:17:21Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user