Compare commits

..

7 Commits

Author SHA1 Message Date
KoalaDev b450845522 fix(extension): harden reconnect, ping, and offline-queue flush
Address client-side rate-limit edge cases that could trip the server's
per-socket event budget or per-IP connection limit:

- Pace the offline event-queue flush after (re)connect in batches
  (10 per 3s) instead of one synchronous burst, so a reconnect after a
  long outage no longer dumps the whole backlog and gets kicked.
- Wrap socket.send() in try/catch and re-queue on failure (race with a
  server-side disconnect) instead of losing the event.
- Ping liveness: tolerate one missed PONG; only force a reconnect after
  2 consecutive misses (~20s) instead of a single hard 5s timeout.
- Reconnect backoff: tune to ~8 attempts/60s (under the connection limit)
  and add +/-20% jitter to de-synchronize reconnect herds.

All magic numbers extracted into named constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:45:29 +02:00
KoalaDev 27023ea58e feat(server): raise event rate limit to 50 and name rate-limit constants
Raise the per-socket event budget from 30 to 50 per 10s to give legitimate
bursts (episode joins, force-sync, ACK flurries) more headroom. Extract the
previously inline connection/event/health/admin windows and limits into named
constants (CONNECTION_RATE_LIMIT, EVENT_RATE_LIMIT, *_WINDOW_MS). Tests now
derive their thresholds from the exported constants instead of hardcoding them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:45:19 +02:00
KoalaDev 1a7d23ce93 ci: add PR/push verification workflow and harden release
- Add .github/workflows/ci.yml running `npm run verify` (lint, tests,
  audits, builds) on every push to main and pull request, so regressions
  can't reach main or a release tag unchecked.
- release.yml: use `npm ci` instead of `npm install` for reproducible builds.
- package.json: add `test` script aliasing the verify suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:45:10 +02:00
KoalaDev d2c8ca1c1e Merge PR #11: extract tab management logic into independent module
3-way merge of Kaia-Alenia's tab-manager refactor (PR #11) onto current
main (v2.4.3). Only the tab-related service-worker listeners move out of
background.js into extension/modules/tab-manager.js via initTabManager({...})
dependency injection; all other recent main changes are preserved untouched.
Behavior-preserving; listeners still register synchronously at top level.

The unrelated website/assets/NewLogoIcon_64.webp change from the PR was
excluded to keep main clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 02:06:38 +02:00
Kaia-Alenia 1c404a7f11 refactor(extension): extract tab management logic into independent module 2026-06-19 14:11:47 -06:00
Timo e2bba04efd docs: update README language count to 15 (add zh, uk)
The localization section still listed 13 languages; Ukrainian (uk) and
Chinese Simplified (zh) were added in v2.4.3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:49:34 +02:00
GitHub Action c7b29c277c chore(release): update versions to v2.4.3 [skip ci] 2026-06-19 14:39:06 +00:00
12 changed files with 293 additions and 117 deletions
+43
View File
@@ -0,0 +1,43 @@
name: CI
# Runs the full verification suite (lint, unit/integration tests, production
# audits, extension + website build) on every push to main and every PR, so a
# regression can never reach main or a release tag unchecked.
on:
push:
branches: [main]
pull_request:
# Cancel superseded runs on the same ref to save CI minutes. Unlike the release
# workflow, an interrupted CI run has no side effects.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Install root dependencies
run: npm ci
# The server test suite (test-server-ws/routes/ops) imports express,
# socket.io, and dotenv from server/node_modules, so install them too.
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Run verification suite
run: npm run verify
+1 -1
View File
@@ -132,7 +132,7 @@ jobs:
- name: Build Extensions
run: |
npm install
npm ci
npm run build:extension
- name: Generate artifact attestation for extensions
+3 -3
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.4.2-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.4.3-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<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>
@@ -14,7 +14,7 @@
<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>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.4.2 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.4.3 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
@@ -59,7 +59,7 @@ The easiest and safest way to install KoalaSync is directly through the official
### 🌐 Localization & Translations
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`).
- **Available Languages**: Support is included for 15 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), Chinese (Simplified) (`zh`), Ukrainian (`uk`), and European Portuguese (`pt`).
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
+118 -92
View File
@@ -2,6 +2,7 @@ import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, E
import { generateUsername } from './shared/names.js';
import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
import { sameEpisode } from './episode-utils.js';
import { initTabManager } from './modules/tab-manager.js';
// --- Uninstall URL Initialization ---
let uninstallURLInitPromise = null;
@@ -62,6 +63,7 @@ let storageInitialized = false;
let pendingLogs = [];
let pendingHistory = [];
let eventQueue = [];
let flushTimer = null; // paces draining of eventQueue after (re)connect
let isNamespaceJoined = false;
let lastActionState = { action: null, senderId: null, timestamp: 0, acks: [] };
let localSeq = 0; // Monotonically increasing command sequence for this peer
@@ -74,6 +76,7 @@ let pingInterval = null;
let pingTimeout = null;
let pendingPingT = null;
let currentPingMs = null;
let missedPongs = 0;
// --- Keep-Alive Port Listener ---
chrome.runtime.onConnect.addListener((port) => {
@@ -197,8 +200,28 @@ let roomIdleSince = null;
let lastContentHeartbeatAt = null;
let connectIntent = false;
const MAX_RECONNECT_ATTEMPTS = 20;
const _RECONNECT_BASE_DELAY = 500;
const _RECONNECT_MAX_DELAY = 5000;
// Backoff tuned so that at most ~8 connection attempts land in any 60s window,
// keeping a single client comfortably under the server's per-IP connection
// budget (10/min) even before jitter. Cumulative (no jitter): 1, 2.8, 6, 11.9,
// 22.4, 34.4, 46.4, 58.4s → 8th attempt at ~58s.
const _RECONNECT_BASE_DELAY = 1000;
const _RECONNECT_MAX_DELAY = 12000;
const _RECONNECT_FACTOR = 1.8;
const _RECONNECT_GIVEUP_MS = 300000; // switch to slow mode after 5 min of fast retries
const _RECONNECT_SLOW_DELAY = 300000; // slow-mode interval: every 5 min
const _RECONNECT_JITTER = 0.2; // ±20% randomization to de-synchronize reconnect herds
// Paced queue flush: after a (re)connect we drain the offline event backlog in
// small batches instead of one synchronous burst, so we stay well under the
// server's per-socket event budget (50 / 10s) and leave headroom for the
// heartbeats/pings/commands that also count toward it. 10 per 3s ≈ 33/10s.
const FLUSH_BATCH_SIZE = 10;
const FLUSH_BATCH_INTERVAL_MS = 3000;
// Ping liveness: a single unanswered ping is tolerated (transient network
// blip); only MAX_MISSED_PONGS consecutive misses force a reconnect. With a
// 15s interval and 5s timeout that means ~20s to detect a genuinely dead link.
const PING_INTERVAL_MS = 15000;
const PING_TIMEOUT_MS = 5000;
const MAX_MISSED_PONGS = 2;
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
// Force Sync Coordination
@@ -349,6 +372,7 @@ function forceDisconnect() {
roomIdleSince = null;
lastContentHeartbeatAt = null;
forceSyncAcks.clear();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
eventQueue = [];
chrome.storage.session.set({
isForceSyncInitiator: false,
@@ -546,12 +570,7 @@ async function connect() {
protocolVersion: PROTOCOL_VERSION
});
}
while (eventQueue.length > 0) {
const queuedMsg = eventQueue.shift();
emit(queuedMsg.event, queuedMsg.data);
}
eventQueue = [];
chrome.storage.session.set({ eventQueue: [] });
flushEventQueue();
} else if (msg.startsWith('42')) {
try {
const payload = JSON.parse(msg.substring(2));
@@ -570,6 +589,7 @@ async function connect() {
isConnecting = false;
isNamespaceJoined = false;
stopPing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
if (!connectIntent && !currentRoom) {
isForceSyncInitiator = false;
@@ -688,17 +708,22 @@ function scheduleReconnect() {
const elapsed = Date.now() - reconnectStartTime;
reconnectAttempts++;
if (!reconnectFailed && (elapsed > 300000 || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
if (!reconnectFailed && (elapsed > _RECONNECT_GIVEUP_MS || reconnectAttempts > MAX_RECONNECT_ATTEMPTS)) {
reconnectFailed = true;
addLog('Switching to slow reconnect mode (every 5 minutes)', 'warn');
}
const delay = reconnectFailed
? 300000
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(1.5, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
const baseDelay = reconnectFailed
? _RECONNECT_SLOW_DELAY
: Math.min(_RECONNECT_BASE_DELAY * Math.pow(_RECONNECT_FACTOR, reconnectAttempts - 1), _RECONNECT_MAX_DELAY);
// Jitter de-synchronizes herds: many clients dropped by the same server
// blip won't all reconnect on the same tick and exhaust the connection
// budget in lockstep. Applied in both fast and slow mode.
const jitterFactor = 1 - _RECONNECT_JITTER + Math.random() * 2 * _RECONNECT_JITTER;
const delay = Math.round(baseDelay * jitterFactor);
if (reconnectFailed) {
addLog(`Slow reconnect in 5min (attempt ${reconnectAttempts})`, 'info');
addLog(`Slow reconnect in ~5min (attempt ${reconnectAttempts})`, 'info');
} else {
addLog(`Reconnect in ${Math.round(delay)}ms (attempt ${reconnectAttempts})`, 'warn');
}
@@ -716,17 +741,58 @@ function scheduleReconnect() {
function emit(event, data) {
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
const msg = `42${JSON.stringify([event, data])}`;
socket.send(msg);
} else {
eventQueue.push({ event, data });
if (eventQueue.length > 50) {
eventQueue.shift();
addLog('Event queue cap reached, dropping oldest event', 'warn');
try {
socket.send(msg);
} catch (e) {
// The socket can close between the readyState check and send()
// (race with a server-side disconnect). Re-queue so the event is
// retried on the next successful (re)connect instead of being lost.
addLog(`Send failed, re-queueing ${event}: ${e.message}`, 'warn');
queueEvent(event, data);
}
chrome.storage.session.set({ eventQueue });
} else {
queueEvent(event, data);
}
}
function queueEvent(event, data) {
eventQueue.push({ event, data });
if (eventQueue.length > 50) {
eventQueue.shift();
addLog('Event queue cap reached, dropping oldest event', 'warn');
}
chrome.storage.session.set({ eventQueue });
}
/**
* Drain the offline event queue in paced batches. A reconnect after a long
* outage can leave up to 50 queued events; dumping them in one tick would
* exceed the server's per-socket event budget and get us disconnected right
* after rejoining. We send FLUSH_BATCH_SIZE events, then wait
* FLUSH_BATCH_INTERVAL_MS before the next batch. Remaining events drain across
* subsequent batches; if the connection drops mid-drain, the rest stay queued.
*/
function flushEventQueue() {
if (flushTimer) return; // a drain is already in progress
const drainBatch = () => {
flushTimer = null;
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
return; // lost the connection — leave the rest queued for next connect
}
let sent = 0;
while (eventQueue.length > 0 && sent < FLUSH_BATCH_SIZE) {
const queuedMsg = eventQueue.shift();
emit(queuedMsg.event, queuedMsg.data);
sent++;
}
chrome.storage.session.set({ eventQueue }).catch(() => {});
if (eventQueue.length > 0) {
flushTimer = setTimeout(drainBatch, FLUSH_BATCH_INTERVAL_MS);
}
};
drainBatch();
}
function addToHistory(action, senderId) {
const historyEntry = {
action,
@@ -750,16 +816,23 @@ function sendPing() {
emit(EVENTS.PING, { t });
if (pingTimeout) clearTimeout(pingTimeout);
pingTimeout = setTimeout(() => {
if (pendingPingT === t) {
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
pendingPingT = null;
pingTimeout = null;
if (pendingPingT !== t) return; // a PONG arrived in time
// This ping went unanswered. Tolerate transient blips: only force a
// reconnect after MAX_MISSED_PONGS consecutive misses, not the first.
pendingPingT = null;
missedPongs++;
if (missedPongs >= MAX_MISSED_PONGS) {
addLog(`${missedPongs} consecutive pings unanswered — force disconnecting to trigger reconnect`, 'warn');
missedPongs = 0;
forceDisconnect();
if (currentRoom || connectIntent) {
scheduleReconnect();
}
} else {
addLog(`Ping unanswered (${missedPongs}/${MAX_MISSED_PONGS}) — retrying next interval`, 'warn');
}
pingTimeout = null;
}, 5000);
}, PING_TIMEOUT_MS);
}
function startPing() {
@@ -767,7 +840,8 @@ function startPing() {
if (pingTimeout) { clearTimeout(pingTimeout); pingTimeout = null; }
currentPingMs = null;
pendingPingT = null;
pingInterval = setInterval(sendPing, 15000);
missedPongs = 0;
pingInterval = setInterval(sendPing, PING_INTERVAL_MS);
sendPing();
}
@@ -782,6 +856,7 @@ function stopPing() {
}
currentPingMs = null;
pendingPingT = null;
missedPongs = 0;
}
// --- Event Handlers ---
@@ -1122,6 +1197,7 @@ function handleServerEvent(event, data) {
if (data && typeof data.t === 'number' && Number.isFinite(data.t)) {
if (pendingPingT === data.t) {
pendingPingT = null;
missedPongs = 0;
if (pingTimeout) {
clearTimeout(pingTimeout);
pingTimeout = null;
@@ -1922,72 +1998,22 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
}
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
await ensureState();
if (!currentTabId) return;
chrome.tabs.sendMessage(currentTabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
});
// Tab removal listener
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === currentTabId) {
const wasInRoom = !!currentRoom;
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');
if (wasInRoom) {
const roomAtClose = currentRoom;
getSettings().then(settings => {
if (currentRoom !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId,
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
if (currentRoom && Array.isArray(currentRoom.peers)) {
const me = currentRoom.peers.find(p => (p.peerId || p) === peerId);
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (storageInitialized) chrome.storage.session.set({ currentRoom });
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: currentRoom.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
// Re-inject on full page refresh
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
if (currentTabId && tabId === parseInt(currentTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
})
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
initTabManager({
getCurrentTabId: () => currentTabId,
setCurrentTabId: (val) => { currentTabId = val; },
setCurrentTabTitle: (val) => { currentTabTitle = val; },
setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; },
setRoomIdleSince: (val) => { roomIdleSince = val; },
getCurrentRoom: () => currentRoom,
getPeerId: () => peerId,
getStorageInitialized: () => storageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
ensureState,
EVENTS
});
// Initial Connect — only if user has an active room configuration
+1 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.4.2",
"version": "2.4.3",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+94
View File
@@ -0,0 +1,94 @@
export function initTabManager({
getCurrentTabId,
setCurrentTabId,
setCurrentTabTitle,
setLastContentHeartbeatAt,
setRoomIdleSince,
getCurrentRoom,
getPeerId,
getStorageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
ensureState,
EVENTS
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
await ensureState();
const tabId = getCurrentTabId();
if (!tabId) return;
chrome.tabs.sendMessage(tabId, {
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === getCurrentTabId()) {
const wasInRoom = !!getCurrentRoom();
setCurrentTabId(null);
setCurrentTabTitle(null);
setLastContentHeartbeatAt(null);
const now = Date.now();
setRoomIdleSince(now);
chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince: now,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = getCurrentRoom();
getSettings().then(settings => {
if (getCurrentRoom() !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId: getPeerId(),
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const room = getCurrentRoom();
if (room && Array.isArray(room.peers)) {
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (getStorageInitialized()) {
chrome.storage.session.set({ currentRoom: room });
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
chrome.scripting.executeScript({
target: { tabId },
files: ['content.js']
})
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
}
});
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.4.2",
"version": "2.4.3",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
@@ -8,6 +8,7 @@
"build:extension": "node scripts/build-extension.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node scripts/verify-release.mjs",
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
+9 -5
View File
@@ -15,7 +15,9 @@ import {
roomListCooldowns,
rateLimitDenied,
startRateLimitCleanup,
stopRateLimitCleanup
stopRateLimitCleanup,
CONNECTION_RATE_LIMIT,
EVENT_RATE_LIMIT
} from '../server/rate-limiter.js';
// Helper: mock io for cleanup
@@ -31,8 +33,9 @@ function reset() {
// --- checkConnectionRate ---
reset();
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
for (let i = 0; i < 9; i++) checkConnectionRate('1.1.1.1');
assert.equal(checkConnectionRate('1.1.1.1'), false, '11th connection blocked');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
reset();
@@ -41,8 +44,9 @@ assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
// --- checkEventRate ---
reset();
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
for (let i = 0; i < 29; i++) checkEventRate('sock1');
assert.equal(checkEventRate('sock1'), false, '31st event blocked');
// Exhaust the rest of the budget (first call above counted as 1).
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
assert.equal(rateLimitDenied.events, 1);
reset();
+18 -10
View File
@@ -7,6 +7,14 @@ export const ROOM_LIST_COOLDOWN_MS = 10000;
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
// --- Connection & event budgets (formerly inline magic numbers) ---
export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP per window
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
export const connectionCounts = new Map(); // ip -> { count, resetTime }
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
export const eventCounts = new Map(); // socketId -> { count, resetTime }
@@ -72,30 +80,30 @@ export function recordAuthFailure(ip, roomId) {
export function checkConnectionRate(ip) {
const now = Date.now();
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + CONNECTION_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CONNECTION_RATE_WINDOW_MS; }
entry.count++;
connectionCounts.set(ip, entry);
if (entry.count <= 10) return true;
if (entry.count <= CONNECTION_RATE_LIMIT) return true;
rateLimitDenied.connections++;
return false;
}
export function checkEventRate(socketId) {
const now = Date.now();
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + EVENT_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + EVENT_RATE_WINDOW_MS; }
entry.count++;
eventCounts.set(socketId, entry);
if (entry.count <= 30) return true;
if (entry.count <= EVENT_RATE_LIMIT) return true;
rateLimitDenied.events++;
return false;
}
export function checkHealthRate(ip) {
const now = Date.now();
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + HEALTH_RATE_WINDOW_MS; }
entry.count++;
healthCounts.set(ip, entry);
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
@@ -105,8 +113,8 @@ export function checkHealthRate(ip) {
export function checkAdminMetricsAuthRate(ip) {
const now = Date.now();
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + ADMIN_METRICS_AUTH_WINDOW_MS };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + ADMIN_METRICS_AUTH_WINDOW_MS; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
+1 -1
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.4.2";
export const APP_VERSION = "2.4.3";
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
+1 -1
View File
@@ -106,7 +106,7 @@
"priceCurrency": "EUR"
},
"description": "{{SCHEMA_APP_DESC}}",
"softwareVersion": "2.4.2",
"softwareVersion": "2.4.3",
"license": "https://opensource.org/licenses/MIT",
"sameAs": "https://github.com/Shik3i/KoalaSync",
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.4.2",
"date": "2026-06-19T09:54:42Z"
"version": "2.4.3",
"date": "2026-06-19T14:39:05Z"
}