diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f3a65..358d8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.0.5] — 2026-06-03 + +### Security & Hardening +- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure). +- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks. + +### Added +- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory. +- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control. + +--- + ## [v2.0.4] — 2026-06-03 ### Security & Hardening diff --git a/eslint.config.mjs b/eslint.config.mjs index 1af676a..da21dc6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,7 +36,8 @@ export default [ URL: "readonly", URLSearchParams: "readonly", WebSocket: "readonly", - self: "readonly" + self: "readonly", + process: "readonly" } }, rules: { diff --git a/extension/background.js b/extension/background.js index de853bd..72a4606 100644 --- a/extension/background.js +++ b/extension/background.js @@ -56,7 +56,7 @@ function ensureState() { 'logs', 'history', 'currentRoom', 'lastActionState', 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', - 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount' + 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt' ], (data) => { clearTimeout(storageTimeout); if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; @@ -80,6 +80,8 @@ function ensureState() { if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed; if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime; if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts; + if (data.roomIdleSince !== undefined) roomIdleSince = data.roomIdleSince; + if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt; // Recover Force Sync Timeout if (data.forceSyncDeadline) { @@ -139,9 +141,12 @@ let reconnectStartTime = null; let reconnectFailed = false; let reconnectAttempts = 0; let currentServerUrl = null; +let roomIdleSince = null; +let lastContentHeartbeatAt = null; const MAX_RECONNECT_ATTEMPTS = 20; const _RECONNECT_BASE_DELAY = 500; const _RECONNECT_MAX_DELAY = 5000; +const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000; // Force Sync Coordination let isForceSyncInitiator = false; @@ -304,6 +309,8 @@ function forceDisconnect() { isNamespaceJoined = false; isForceSyncInitiator = false; expectedAcksCount = 0; + roomIdleSince = null; + lastContentHeartbeatAt = null; forceSyncAcks.clear(); eventQueue = []; chrome.storage.session.set({ @@ -312,7 +319,9 @@ function forceDisconnect() { forceSyncDeadline: null, expectedAcksCount: 0, eventQueue: [], - episodeLobby: null + episodeLobby: null, + roomIdleSince: null, + lastContentHeartbeatAt: null }).catch(() => {}); if (currentRoom) { currentRoom.peers = []; @@ -322,6 +331,63 @@ function forceDisconnect() { broadcastConnectionStatus('disconnected'); } +function persistRoomIdleState() { + chrome.storage.session.set({ roomIdleSince, lastContentHeartbeatAt }).catch(() => {}); +} + +function markRoomUseful() { + roomIdleSince = null; + lastContentHeartbeatAt = Date.now(); + persistRoomIdleState(); +} + +function markRoomPotentiallyIdle() { + if (!currentRoom) { + roomIdleSince = null; + lastContentHeartbeatAt = null; + persistRoomIdleState(); + return; + } + if (!roomIdleSince) { + roomIdleSince = Date.now(); + persistRoomIdleState(); + } +} + +function clearTargetTabForIdle() { + currentTabId = null; + currentTabTitle = null; + lastContentHeartbeatAt = null; + if (currentRoom) { + roomIdleSince = Date.now(); + } + chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {}); + updateBadgeStatus(); +} + +async function leaveRoomAfterIdleGrace(reason) { + if (!currentRoom) return; + emit(EVENTS.LEAVE_ROOM, { peerId }); + currentRoom = null; + currentTabId = null; + currentTabTitle = null; + roomIdleSince = null; + lastContentHeartbeatAt = null; + clearEpisodeLobbyState(); + await chrome.storage.session.set({ + currentRoom: null, + currentTabId: null, + currentTabTitle: null, + roomIdleSince: null, + lastContentHeartbeatAt: null, + episodeLobby: null + }).catch(() => {}); + await chrome.storage.sync.set({ roomId: '', password: '' }).catch(() => {}); + addLog(reason, 'info'); + chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); + updateBadgeStatus(); +} + async function connect() { if (isConnecting) return; isConnecting = true; @@ -631,6 +697,7 @@ function handleServerEvent(event, data) { switch (event) { case EVENTS.ROOM_DATA: currentRoom = data; + markRoomPotentiallyIdle(); if (currentRoom && Array.isArray(currentRoom.peers)) { currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() }); @@ -1152,8 +1219,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman }).catch(err => { if (retries >= 3) { addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn'); - currentTabId = null; - updateBadgeStatus(); + clearTargetTabForIdle(); return; } if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { @@ -1167,8 +1233,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman }); } else { addLog(`Content Script not responding in tab ${tabId}`, 'warn'); - currentTabId = null; - updateBadgeStatus(); + clearTargetTabForIdle(); } }); } @@ -1184,6 +1249,15 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { connect(); } } else if (currentRoom) { + const now = Date.now(); + const heartbeatAge = lastContentHeartbeatAt ? (now - lastContentHeartbeatAt) : Infinity; + if (!currentTabId || heartbeatAge > 45000) { + markRoomPotentiallyIdle(); + } + if (roomIdleSince && Date.now() - roomIdleSince >= ROOM_IDLE_AUTO_LEAVE_MS) { + await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.'); + return; + } // Heartbeat Logic: Always include identity metadata const settings = await getSettings(); emit(EVENTS.PEER_STATUS, { @@ -1287,6 +1361,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { emit(EVENTS.LEAVE_ROOM, { peerId }); currentRoom = null; currentTabId = null; + currentTabTitle = null; + roomIdleSince = null; + lastContentHeartbeatAt = null; updateBadgeStatus(); @@ -1300,6 +1377,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { chrome.storage.session.set({ currentRoom: null, + currentTabId: null, + currentTabTitle: null, + roomIdleSince: null, + lastContentHeartbeatAt: null, isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null, @@ -1492,6 +1573,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { updateBadgeStatus(); } + markRoomUseful(); getSettings().then(settings => { const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle }; emit(EVENTS.PEER_STATUS, statusPayload); @@ -1519,7 +1601,11 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'SET_TARGET_TAB') { currentTabId = message.tabId; currentTabTitle = message.tabTitle; - chrome.storage.session.set({ currentTabId, currentTabTitle }); + lastContentHeartbeatAt = null; + if (currentRoom) { + roomIdleSince = Date.now(); + } + chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }); updateBadgeStatus(); if (currentTabId) { @@ -1645,7 +1731,9 @@ chrome.tabs.onRemoved.addListener(async (tabId) => { const wasInRoom = !!currentRoom; currentTabId = null; currentTabTitle = null; - chrome.storage.session.set({ currentTabId: null, currentTabTitle: null }); + lastContentHeartbeatAt = null; + roomIdleSince = Date.now(); + chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, roomIdleSince, lastContentHeartbeatAt }); updateBadgeStatus(); addLog('Target tab closed.', 'warn'); diff --git a/extension/locales/de.json b/extension/locales/de.json index fe6e820..743b2b5 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -33,6 +33,9 @@ "BTN_REFRESH": "AKTUALISIEREN", "BTN_REFRESH_TOOLTIP": "Die Liste der öffentlichen Räume aktualisieren", "PUBLIC_ROOMS_REFRESHING": "Aktualisiere...", + "BTN_REFRESH_COOLDOWN": "WARTE {seconds}s", + "BTN_REFRESH_COOLDOWN_TOOLTIP": "Die Raumliste kühlt ab. Versuche es in {seconds}s erneut.", + "PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aktualisiere öffentliche Räume. Nächste Aktualisierung in {seconds}s verfügbar.", "LABEL_ACTIVE_ROOM": "Aktiver Raum", "LABEL_ACTIVE_ROOM_TOOLTIP": "Der Raum, mit dem du gerade verbunden bist", "ACTIVE_ROOM_NONE": "KEINER", diff --git a/extension/locales/en.json b/extension/locales/en.json index 4bff23a..9b6a0a0 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -33,6 +33,9 @@ "BTN_REFRESH": "REFRESH", "BTN_REFRESH_TOOLTIP": "Refresh the list of public rooms", "PUBLIC_ROOMS_REFRESHING": "Refreshing...", + "BTN_REFRESH_COOLDOWN": "WAIT {seconds}s", + "BTN_REFRESH_COOLDOWN_TOOLTIP": "Room list refresh is cooling down. Try again in {seconds}s.", + "PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Refreshing public rooms. Next refresh available in {seconds}s.", "LABEL_ACTIVE_ROOM": "Active Room", "LABEL_ACTIVE_ROOM_TOOLTIP": "The room you are currently connected to", "ACTIVE_ROOM_NONE": "NONE", diff --git a/extension/locales/es.json b/extension/locales/es.json index 6587de4..8739806 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -33,6 +33,9 @@ "BTN_REFRESH": "ACTUALIZAR", "BTN_REFRESH_TOOLTIP": "Actualizar la lista de salas públicas", "PUBLIC_ROOMS_REFRESHING": "Actualizando...", + "BTN_REFRESH_COOLDOWN": "ESPERA {seconds}s", + "BTN_REFRESH_COOLDOWN_TOOLTIP": "La lista de salas está en espera. Inténtalo de nuevo en {seconds}s.", + "PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualizando salas públicas. Próxima actualización en {seconds}s.", "LABEL_ACTIVE_ROOM": "Sala activa", "LABEL_ACTIVE_ROOM_TOOLTIP": "La sala a la que estás conectado actualmente", "ACTIVE_ROOM_NONE": "NINGUNA", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 8fe7b04..48482a7 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -33,6 +33,9 @@ "BTN_REFRESH": "ACTUALISER", "BTN_REFRESH_TOOLTIP": "Actualiser la liste des salons publics", "PUBLIC_ROOMS_REFRESHING": "Actualisation...", + "BTN_REFRESH_COOLDOWN": "ATTENDRE {seconds}s", + "BTN_REFRESH_COOLDOWN_TOOLTIP": "La liste des salons est en pause. Réessayez dans {seconds}s.", + "PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualisation des salons publics. Prochaine actualisation dans {seconds}s.", "LABEL_ACTIVE_ROOM": "Salon actif", "LABEL_ACTIVE_ROOM_TOOLTIP": "Le salon auquel vous êtes actuellement connecté", "ACTIVE_ROOM_NONE": "AUCUN", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index 1b97389..20f9130 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -33,6 +33,9 @@ "BTN_REFRESH": "ATUALIZAR", "BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas", "PUBLIC_ROOMS_REFRESHING": "Atualizando...", + "BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s", + "BTN_REFRESH_COOLDOWN_TOOLTIP": "A lista de salas está em espera. Tente novamente em {seconds}s.", + "PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Atualizando salas públicas. Próxima atualização em {seconds}s.", "LABEL_ACTIVE_ROOM": "Sala ativa", "LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual você está conectado no momento", "ACTIVE_ROOM_NONE": "NENHUMA", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 17d79a8..74afe41 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -33,6 +33,9 @@ "BTN_REFRESH": "ОБНОВИТЬ", "BTN_REFRESH_TOOLTIP": "Обновить список публичных комнат", "PUBLIC_ROOMS_REFRESHING": "Обновление...", + "BTN_REFRESH_COOLDOWN": "ЖДИТЕ {seconds}с", + "BTN_REFRESH_COOLDOWN_TOOLTIP": "Список комнат временно ограничен. Повторите через {seconds}с.", + "PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Обновление публичных комнат. Следующее обновление через {seconds}с.", "LABEL_ACTIVE_ROOM": "Активная комната", "LABEL_ACTIVE_ROOM_TOOLTIP": "Комната, к которой вы сейчас подключены", "ACTIVE_ROOM_NONE": "НЕТ", diff --git a/extension/manifest.base.json b/extension/manifest.base.json index 68d02a1..651c3f9 100644 --- a/extension/manifest.base.json +++ b/extension/manifest.base.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "KoalaSync", - "version": "2.0.4", + "version": "2.0.5", "description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.", "permissions": [ "storage", diff --git a/extension/popup.js b/extension/popup.js index edd3207..18cb0e3 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -69,6 +69,7 @@ let forceSyncDone = false; let connectionErrorTimer = null; let pendingConnectionErrorMsg = null; let roomListRefreshTimer = null; +let roomListRefreshInterval = null; const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000; // --- Helpers --- @@ -80,6 +81,31 @@ function clearConnectionErrorTimer() { pendingConnectionErrorMsg = null; } +function setRoomRefreshCooldown() { + if (roomListRefreshTimer) clearTimeout(roomListRefreshTimer); + if (roomListRefreshInterval) clearInterval(roomListRefreshInterval); + const originalLabel = getMessage('BTN_REFRESH'); + const updateLabel = () => { + const secondsLeft = Math.max(1, Math.ceil((cooldownEndsAt - Date.now()) / 1000)); + elements.refreshRooms.textContent = getMessage('BTN_REFRESH_COOLDOWN', { seconds: secondsLeft }); + elements.refreshRooms.title = getMessage('BTN_REFRESH_COOLDOWN_TOOLTIP', { seconds: secondsLeft }); + }; + + const cooldownEndsAt = Date.now() + ROOM_LIST_REFRESH_COOLDOWN_MS; + elements.refreshRooms.disabled = true; + updateLabel(); + + roomListRefreshInterval = setInterval(updateLabel, 250); + roomListRefreshTimer = setTimeout(() => { + elements.refreshRooms.disabled = false; + elements.refreshRooms.textContent = originalLabel; + elements.refreshRooms.title = getMessage('BTN_REFRESH_TOOLTIP'); + clearInterval(roomListRefreshInterval); + roomListRefreshInterval = null; + roomListRefreshTimer = null; + }, ROOM_LIST_REFRESH_COOLDOWN_MS); +} + // --- Initialization --- async function init() { // Load Settings @@ -1099,16 +1125,12 @@ 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); + setRoomRefreshCooldown(); elements.publicRooms.replaceChildren(); const el = document.createElement('div'); el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;'; - el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING'); + el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING_COOLDOWN', { seconds: Math.ceil(ROOM_LIST_REFRESH_COOLDOWN_MS / 1000) }); elements.publicRooms.appendChild(el); chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' }); }); @@ -1832,6 +1854,10 @@ window.addEventListener('unload', () => { clearTimeout(roomListRefreshTimer); roomListRefreshTimer = null; } + if (roomListRefreshInterval) { + clearInterval(roomListRefreshInterval); + roomListRefreshInterval = null; + } }); // --- Episode Lobby UI --- diff --git a/package-lock.json b/package-lock.json index f95d44a..e5f0ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "koalasync", - "version": "1.9.3", + "version": "2.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "koalasync", - "version": "1.9.3", + "version": "2.0.5", "devDependencies": { "archiver": "^7.0.1", "esbuild": "^0.28.0", @@ -1240,7 +1240,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1374,7 +1373,6 @@ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -1897,7 +1895,6 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", diff --git a/package.json b/package.json index bc765a5..83007ff 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "koalasync", - "version": "2.0.4", + "version": "2.0.5", "description": "KoalaSync Build Scripts", "private": true, "scripts": { "build:extension": "node scripts/build-extension.js", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "verify": "node scripts/verify-release.mjs" }, "devDependencies": { "archiver": "^7.0.1", diff --git a/scripts/test-locales.js b/scripts/test-locales.js index 89d1261..00317f1 100644 --- a/scripts/test-locales.js +++ b/scripts/test-locales.js @@ -9,13 +9,47 @@ if (!fs.existsSync(enPath)) { process.exit(1); } +let hasError = false; + +// Verify SUPPORTED_LANGUAGES in extension/i18n.js matches JSON files +const i18nPath = path.join(__dirname, '..', 'extension', 'i18n.js'); +try { + if (fs.existsSync(i18nPath)) { + const i18nContent = fs.readFileSync(i18nPath, 'utf8'); + const langMatch = i18nContent.match(/export const SUPPORTED_LANGUAGES = \[(.*?)\];/); + if (!langMatch) { + hasError = true; + console.error('❌ Could not parse SUPPORTED_LANGUAGES from extension/i18n.js'); + } else { + const supportedLangs = langMatch[1].split(',').map(s => s.trim().replace(/['"]/g, '')); + const fileLangs = fs.readdirSync(localesDir) + .filter(file => file.endsWith('.json')) + .map(file => file.replace('.json', '')); + + for (const lang of fileLangs) { + if (!supportedLangs.includes(lang)) { + hasError = true; + console.error(`❌ ${lang}.json exists in extension/locales but is missing from SUPPORTED_LANGUAGES in extension/i18n.js`); + } + } + for (const lang of supportedLangs) { + if (!fileLangs.includes(lang)) { + hasError = true; + console.error(`❌ ${lang} is in SUPPORTED_LANGUAGES in extension/i18n.js but ${lang}.json is missing from extension/locales`); + } + } + } + } +} catch (err) { + hasError = true; + console.error('❌ Failed to verify SUPPORTED_LANGUAGES synchronization:', err.message); +} + const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8')); const enKeys = Object.keys(enDict); const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json'); -let hasError = false; - console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`); for (const file of localeFiles) { diff --git a/scripts/test-server-routes.mjs b/scripts/test-server-routes.mjs new file mode 100644 index 0000000..510e530 --- /dev/null +++ b/scripts/test-server-routes.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { + ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE, + HEALTH_RATE_LIMIT_PER_MINUTE, + healthCounts, + adminMetricsAuthCounts, + healthResponseCache, + httpServer, + rooms, + startServer, + stopServerForTests +} from '../server/index.js'; + +const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars'; +const baseHeaders = { 'x-forwarded-for': '203.0.113.10' }; + +function url(path) { + const address = httpServer.address(); + return `http://127.0.0.1:${address.port}${path}`; +} + +async function request(path, options = {}) { + return fetch(url(path), { + ...options, + headers: { + ...baseHeaders, + ...(options.headers || {}) + } + }); +} + +try { + await startServer(0, '127.0.0.1'); + + let res = await request('/'); + assert.equal(res.status, 200, 'root health endpoint should respond'); + assert.equal(res.headers.get('cache-control'), 'no-store', 'root response should disable HTTP caching'); + assert.deepEqual(await res.json(), { status: 'online', service: 'KoalaSync Relay' }); + + res = await request('/health'); + assert.equal(res.status, 200, 'basic health endpoint should respond'); + assert.equal(res.headers.get('cache-control'), 'no-store', 'basic health response should disable HTTP caching'); + const basicHealth = await res.json(); + assert.equal(basicHealth.status, 'ok', 'basic health should report ok'); + assert.equal(basicHealth.rooms, 0, 'basic health should include room count'); + assert.equal('peers' in basicHealth, false, 'basic health should not expose admin metrics'); + assert.equal('memory' in basicHealth, false, 'basic health should not expose memory metrics'); + + rooms.set('route-test-room', { + peers: new Set(['socket-a', 'socket-b']), + peerData: new Map(), + peerIds: new Map(), + activeLobby: { expectedTitle: 'Episode 1', initiatorPeerId: 'peer-a', readyPeers: ['peer-a'] } + }); + healthResponseCache.clear(); + healthCounts.clear(); + + res = await request('/health', { + headers: { authorization: `Bearer ${adminToken}`, 'x-forwarded-for': '203.0.113.20' } + }); + assert.equal(res.status, 200, 'authorized admin health endpoint should respond'); + const adminHealth = await res.json(); + assert.equal(adminHealth.rooms, 1, 'admin health should include room count'); + assert.equal(adminHealth.peers, 2, 'admin health should include aggregate peer count'); + assert.equal(adminHealth.roomsWithLobby, 1, 'admin health should include aggregate lobby count'); + assert.equal(typeof adminHealth.memory?.rss, 'number', 'admin health should include aggregate memory metrics'); + assert.equal('route-test-room' in adminHealth, false, 'admin health should not expose room identifiers'); + + healthCounts.clear(); + adminMetricsAuthCounts.clear(); + for (let i = 0; i < ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE; i++) { + res = await request('/health', { + headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' } + }); + assert.equal(res.status, 200, `wrong admin bearer attempt ${i + 1} should still return basic health`); + } + res = await request('/health', { + headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' } + }); + assert.equal(res.status, 429, 'wrong admin bearer attempts should be throttled after the limit'); + + healthCounts.clear(); + for (let i = 0; i < HEALTH_RATE_LIMIT_PER_MINUTE; i++) { + const path = i % 2 === 0 ? '/' : '/health'; + res = await request(path, { headers: { 'x-forwarded-for': '203.0.113.40' } }); + assert.equal(res.status, 200, `shared health request ${i + 1} should be allowed`); + } + res = await request('/', { headers: { 'x-forwarded-for': '203.0.113.40' } }); + assert.equal(res.status, 429, 'root and health should share the public health rate limit'); + + console.log('server route tests passed'); +} finally { + await stopServerForTests(); +} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..2892d2a --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const checks = [ + ['server ops', 'node', ['scripts/test-server-ops.mjs']], + ['server routes', 'node', ['scripts/test-server-routes.mjs'], { + env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' } + }], + ['content video finder', 'node', ['scripts/test-content-video-finder.js']], + ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], + ['server syntax index', 'node', ['-c', 'server/index.js']], + ['server syntax ops', 'node', ['-c', 'server/ops.js']], + ['content syntax', 'node', ['-c', 'extension/content.js']], + ['popup syntax', 'node', ['-c', 'extension/popup.js']], + ['background syntax', 'node', ['-c', 'extension/background.js']], + ['locale coverage', 'node', ['scripts/test-locales.js']], + ['lint', 'npm', ['run', 'lint']], + ['root production audit', 'npm', ['audit', '--omit=dev']], + ['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }], + ['extension build', 'npm', ['run', 'build:extension']], + ['website build', 'node', ['website/build.js']] +]; + +function runCheck([label, command, args, options = {}]) { + return new Promise((resolve, reject) => { + console.log(`\n==> ${label}`); + const child = spawn(command, args, { + cwd: options.cwd || repoRoot, + env: { ...process.env, ...(options.env || {}) }, + stdio: 'inherit' + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error(`${label} failed with exit code ${code}`)); + }); + }); +} + +for (const check of checks) { + await runCheck(check); +} + +console.log('\nRelease verification passed'); diff --git a/server/README.md b/server/README.md index c7f4a20..42bc0aa 100644 --- a/server/README.md +++ b/server/README.md @@ -28,6 +28,20 @@ If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer ` r Use a long random `ADMIN_METRICS_TOKEN` of at least 32 characters. Shorter configured tokens still work, but the server logs a startup warning. +Generate a token with one of these commands: +```bash +openssl rand -base64 32 +# or +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +For Docker Compose deployments, set the generated value in `server/.env`: +```bash +ADMIN_METRICS_TOKEN=replace-with-a-long-random-token +``` + +When polling `/health` from Prometheus, Uptime Kuma, cron, or a load balancer, keep the interval comfortably below the public limit of 10 requests per minute per client IP. A 30-60 second interval is recommended for routine monitoring. Use the admin bearer token only from trusted monitoring hosts, and keep the Node server private behind Caddy or another trusted reverse proxy because IP-based limits depend on the configured proxy boundary. + ### Docker (Recommended) The server is available as a pre-built image on GHCR. ```bash diff --git a/server/index.js b/server/index.js index 5884385..8f22450 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,6 @@ 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'; @@ -26,18 +27,18 @@ 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 HEALTH_RATE_LIMIT_PER_MINUTE = 10; -const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5; +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.'); } -const app = express(); +export const app = express(); app.set('trust proxy', 1); // For real client IP through reverse proxy -const healthResponseCache = new Map(); +export const healthResponseCache = new Map(); // Health Check with Rate Limiting app.get('/', (req, res) => { @@ -86,10 +87,10 @@ app.get('/health', (req, res) => { )); }); -const httpServer = createServer(app); +export const httpServer = createServer(app); // Socket.IO setup with security constraints -const io = new Server(httpServer, { +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://')) { @@ -109,7 +110,7 @@ const io = new Server(httpServer, { /** * In-memory storage */ -const rooms = new Map(); +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) @@ -120,7 +121,7 @@ function log(type, message, details = '') { } // Rate Limiting & Security -const connectionCounts = new Map(); // ip -> { count, resetTime } +export const connectionCounts = new Map(); // ip -> { count, resetTime } const failedAuthAttempts = new Map(); // Map function checkAuthRate(ip, roomId) { @@ -170,7 +171,7 @@ function recordAuthFailure(ip, roomId) { } // Periodically clean up old auth failure records (every 15 minutes) -setInterval(() => { +const authFailureCleanupInterval = setInterval(() => { const now = Date.now(); for (const [key, record] of failedAuthAttempts.entries()) { if (now - record.lastAttempt > 15 * 60 * 1000) { @@ -179,13 +180,13 @@ setInterval(() => { } }, 15 * 60 * 1000); -const eventCounts = new Map(); // socketId -> { count, resetTime } -const healthCounts = new Map(); // ip -> { count, resetTime } -const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime } +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 // Clean up connection counts and event counts to prevent memory leak -setInterval(() => { +const rateLimitCleanupInterval = setInterval(() => { const now = Date.now(); for (const [ip, entry] of connectionCounts.entries()) { if (now > entry.resetTime) { @@ -663,7 +664,7 @@ io.on('connection', (socket) => { }); // Active Room & Dead Peer Cleanup (Every 2m) -setInterval(() => { +const roomCleanupInterval = setInterval(() => { const now = Date.now(); const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours const peerCutoff = now - (5 * 60 * 1000); // 5 minutes @@ -700,9 +701,29 @@ setInterval(() => { } }, 2 * 60 * 1000); -httpServer.listen(PORT, () => { - log('SERVER', `KoalaSync Relay running on port ${PORT}`); -}); +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) { @@ -721,15 +742,44 @@ function gracefulShutdown(signal) { }, 5000); } -process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); -process.on('SIGINT', () => gracefulShutdown('SIGINT')); +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()); + }); +} -process.on('uncaughtException', (err) => { - log('ERROR', `Uncaught exception: ${err.message}`, err.stack); - process.exit(1); -}); +const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; -process.on('unhandledRejection', (reason) => { - log('ERROR', `Unhandled rejection: ${reason}`); - process.exit(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); + }); +} diff --git a/website/version.json b/website/version.json index 388b07f..b1053ac 100644 --- a/website/version.json +++ b/website/version.json @@ -1,4 +1,4 @@ { - "version": "2.0.4", - "date": "2026-06-03T09:07:20Z" + "version": "2.0.5", + "date": "2026-06-03T09:32:00Z" } diff --git a/website/www/de/index.html b/website/www/de/index.html index 3cae2cf..f276d19 100644 --- a/website/www/de/index.html +++ b/website/www/de/index.html @@ -256,7 +256,7 @@
KoalaSync LogoKoalaSync
- 2.0.4 + 2.0.5
@@ -519,7 +519,7 @@
GitHub Repository -
2.0.4
+
2.0.5
@@ -887,7 +887,7 @@ KoalaSync Logo KoalaSync -
2.0.4
+
2.0.5
diff --git a/website/www/es/index.html b/website/www/es/index.html index 02cbd1a..56f6314 100644 --- a/website/www/es/index.html +++ b/website/www/es/index.html @@ -256,7 +256,7 @@
KoalaSync LogoKoalaSync
- 2.0.4 + 2.0.5
@@ -519,7 +519,7 @@
GitHub Repository -
2.0.4
+
2.0.5
@@ -887,7 +887,7 @@ KoalaSync Logo KoalaSync -
2.0.4
+
2.0.5
diff --git a/website/www/fr/index.html b/website/www/fr/index.html index b3d37ac..9911618 100644 --- a/website/www/fr/index.html +++ b/website/www/fr/index.html @@ -256,7 +256,7 @@
KoalaSync LogoKoalaSync
- 2.0.4 + 2.0.5
@@ -519,7 +519,7 @@
GitHub Repository -
2.0.4
+
2.0.5
@@ -887,7 +887,7 @@ KoalaSync Logo KoalaSync -
2.0.4
+
2.0.5
diff --git a/website/www/index.html b/website/www/index.html index a8c1cfa..a8a33e7 100644 --- a/website/www/index.html +++ b/website/www/index.html @@ -256,7 +256,7 @@
KoalaSync LogoKoalaSync
- 2.0.4 + 2.0.5
@@ -519,7 +519,7 @@
GitHub Repository -
2.0.4
+
2.0.5
@@ -887,7 +887,7 @@ KoalaSync Logo KoalaSync -
2.0.4
+
2.0.5
diff --git a/website/www/pt-BR/index.html b/website/www/pt-BR/index.html index 8910380..c52148b 100644 --- a/website/www/pt-BR/index.html +++ b/website/www/pt-BR/index.html @@ -256,7 +256,7 @@
KoalaSync LogoKoalaSync
- 2.0.4 + 2.0.5
@@ -519,7 +519,7 @@
GitHub Repository -
2.0.4
+
2.0.5
@@ -887,7 +887,7 @@ KoalaSync Logo KoalaSync -
2.0.4
+
2.0.5
diff --git a/website/www/ru/index.html b/website/www/ru/index.html index d833d31..aaf5dfe 100644 --- a/website/www/ru/index.html +++ b/website/www/ru/index.html @@ -256,7 +256,7 @@
KoalaSync LogoKoalaSync
- 2.0.4 + 2.0.5
@@ -519,7 +519,7 @@
GitHub Repository -
2.0.4
+
2.0.5
@@ -887,7 +887,7 @@ KoalaSync Logo KoalaSync -
2.0.4
+
2.0.5
diff --git a/website/www/version.json b/website/www/version.json index 406ca78..b1053ac 100644 --- a/website/www/version.json +++ b/website/www/version.json @@ -1,4 +1,4 @@ { - "version": "2.0.4", - "date": "2026-06-03T09:05:38Z" + "version": "2.0.5", + "date": "2026-06-03T09:32:00Z" }