mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 12:29:27 +00:00
merge main and harden canonical media recovery
This commit is contained in:
+3
-1
@@ -87,7 +87,9 @@ The server is covered by the root verification suite. From the repository root,
|
||||
npm run verify
|
||||
```
|
||||
|
||||
For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-server-routes.mjs`, `scripts/test-server-ws.mjs`, and `scripts/test-rate-limiter.mjs`.
|
||||
For focused server checks, run `npm run test:unit` for `server/ops.test.mjs`
|
||||
and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and
|
||||
`scripts/test-server-ws.mjs` for process-level integration coverage.
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
|
||||
+38
-12
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
||||
import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import { EVENTS, ERROR_CODES, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES, FORCE_SYNC_TIMEOUT, MAX_MEDIA_TIME } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
commitForceSyncMediaState,
|
||||
@@ -651,10 +651,18 @@ io.on('connection', (socket) => {
|
||||
// Strip undefined keys for clean wire format
|
||||
Object.keys(relayPayload).forEach(k => relayPayload[k] === undefined && delete relayPayload[k]);
|
||||
|
||||
if (eventName === EVENTS.FORCE_SYNC_EXECUTE && !room.forceSyncTarget) {
|
||||
log('ROOM', `Dropped force_sync_execute without a prepared target from ${mapping.peerId}`);
|
||||
room.forceSyncInitiator = null;
|
||||
return;
|
||||
const mediaStateNow = Date.now();
|
||||
if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
const forceSyncTarget = room.forceSyncTarget;
|
||||
const targetExpired = forceSyncTarget
|
||||
&& (!Number.isFinite(forceSyncTarget.preparedAt)
|
||||
|| mediaStateNow - forceSyncTarget.preparedAt > FORCE_SYNC_TIMEOUT);
|
||||
if (!forceSyncTarget || targetExpired) {
|
||||
log('ROOM', `Dropped force_sync_execute ${targetExpired ? 'with an expired target' : 'without a prepared target'} from ${mapping.peerId}`);
|
||||
room.forceSyncInitiator = null;
|
||||
room.forceSyncTarget = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (eventName === EVENTS.PLAY
|
||||
|| eventName === EVENTS.PAUSE
|
||||
@@ -671,7 +679,6 @@ io.on('connection', (socket) => {
|
||||
// Canonical Media State v1: mutate only after rate limiting,
|
||||
// room mapping, Host Control authorization and sanitization.
|
||||
// Heartbeats remain observational and never enter this path.
|
||||
const mediaStateNow = Date.now();
|
||||
updateMediaStateFromControl(room, eventName, relayPayload, mapping.peerId, {
|
||||
now: mediaStateNow,
|
||||
senderPlaybackState: existing.playbackState
|
||||
@@ -691,7 +698,8 @@ io.on('connection', (socket) => {
|
||||
room.forceSyncInitiator = mapping.peerId;
|
||||
room.forceSyncTarget = {
|
||||
initiatorPeerId: mapping.peerId,
|
||||
targetTime: relayPayload.targetTime
|
||||
targetTime: relayPayload.targetTime,
|
||||
preparedAt: mediaStateNow
|
||||
};
|
||||
} else if (eventName === EVENTS.FORCE_SYNC_EXECUTE) {
|
||||
const forceSyncTarget = room.forceSyncTarget;
|
||||
@@ -984,8 +992,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||
const roomCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
export function cleanupInactiveRooms(now = Date.now()) {
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
|
||||
@@ -1004,7 +1011,13 @@ const roomCleanupInterval = setInterval(() => {
|
||||
}
|
||||
for (const sid of staleSids) {
|
||||
const deadSocket = io.sockets?.sockets?.get(sid);
|
||||
if (deadSocket) deadSocket.leave(roomId);
|
||||
if (deadSocket) {
|
||||
deadSocket.emit(EVENTS.ERROR, {
|
||||
code: ERROR_CODES.PEER_TIMED_OUT,
|
||||
message: 'Removed from room after inactivity'
|
||||
});
|
||||
deadSocket.leave(roomId);
|
||||
}
|
||||
log('CLEANUP', `Pruning dead peer from room ${roomId.substring(0, 3)}***`);
|
||||
try {
|
||||
removePeerFromRoom(sid, roomId, 'reaper');
|
||||
@@ -1016,12 +1029,25 @@ const roomCleanupInterval = setInterval(() => {
|
||||
// 2. Prune empty or inactive rooms
|
||||
const currentRoom = rooms.get(roomId);
|
||||
if (currentRoom && (currentRoom.peers.size === 0 || currentRoom.lastActivity < roomCutoff)) {
|
||||
io.to(roomId).emit(EVENTS.ERROR, { message: 'Room closed' });
|
||||
io.to(roomId).emit(EVENTS.ERROR, {
|
||||
code: ERROR_CODES.ROOM_CLOSED,
|
||||
message: 'Room closed'
|
||||
});
|
||||
// A terminal room timeout is a real leave for every member. Clear
|
||||
// the same socket/peer indexes as an explicit leave so a later join
|
||||
// cannot be mistaken for the stale membership.
|
||||
for (const sid of Array.from(currentRoom.peers)) {
|
||||
const memberSocket = io.sockets?.sockets?.get(sid);
|
||||
if (memberSocket) memberSocket.leave(roomId);
|
||||
removePeerFromRoom(sid, roomId, 'room-timeout');
|
||||
}
|
||||
rooms.delete(roomId);
|
||||
log('CLEANUP', `Deleted room ${roomId.substring(0, 3)}*** (Empty/Inactive)`);
|
||||
}
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
}
|
||||
|
||||
const roomCleanupInterval = setInterval(cleanupInactiveRooms, 2 * 60 * 1000);
|
||||
|
||||
export function startServer(port = PORT, host) {
|
||||
if (httpServer.listening) return Promise.resolve(httpServer);
|
||||
|
||||
@@ -72,9 +72,7 @@ export function updateMediaStateFromControl(room, eventName, payload, senderPeer
|
||||
|
||||
if (eventName === EVENTS.SEEK) {
|
||||
const targetTime = clampMediaTime(payload.targetTime) ?? clampMediaTime(payload.currentTime);
|
||||
const playbackState = payload.playbackState === 'playing' || payload.playbackState === 'paused'
|
||||
? payload.playbackState
|
||||
: (room.mediaState?.playbackState || senderPlaybackState);
|
||||
const playbackState = room.mediaState?.playbackState || senderPlaybackState;
|
||||
if (targetTime === null) return false;
|
||||
return commitMediaState(room, playbackState, targetTime, senderPeerId, now);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,18 @@ describe('canonical media state', () => {
|
||||
expect(target.mediaState).toMatchObject({ revision: 5, currentTime: 200, updatedBy: 'b' });
|
||||
});
|
||||
|
||||
it('ignores client-supplied playback state while seeking', () => {
|
||||
const target = room({ revision: 3, playbackState: 'paused', currentTime: 10, updatedAt: 1000, updatedBy: 'a' });
|
||||
expect(updateMediaStateFromControl(
|
||||
target,
|
||||
EVENTS.SEEK,
|
||||
{ targetTime: 100, playbackState: 'playing' },
|
||||
'b',
|
||||
{ now: 2000, senderPlaybackState: 'playing' }
|
||||
)).toBe(true);
|
||||
expect(target.mediaState).toMatchObject({ revision: 4, playbackState: 'paused', currentTime: 100 });
|
||||
});
|
||||
|
||||
it('uses an observed sender state only to establish an otherwise ambiguous first SEEK', () => {
|
||||
const target = room();
|
||||
expect(updateMediaStateFromControl(target, EVENTS.SEEK, { targetTime: 50 }, 'a', { now: 1000 })).toBe(false);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
|
||||
describe('server operational helpers', () => {
|
||||
it('authorizes only an exact configured bearer token', () => {
|
||||
expect(isAdminMetricsAuthorized(undefined, 'secret-token')).toBe(false);
|
||||
expect(isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token')).toBe(false);
|
||||
expect(isAdminMetricsAuthorized('Bearer secret-token', 'secret-token')).toBe(true);
|
||||
expect(isAdminMetricsAuthorized('Bearer secret-token', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows disabled metrics or strong admin tokens', () => {
|
||||
expect(isAdminMetricsTokenStrong('')).toBe(true);
|
||||
expect(isAdminMetricsTokenStrong('short-token')).toBe(false);
|
||||
expect(isAdminMetricsTokenStrong('a'.repeat(32))).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks cooldowns and expires cached payloads deterministically', () => {
|
||||
const cooldowns = new Map();
|
||||
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000)).toBe(true);
|
||||
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000)).toBe(false);
|
||||
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000)).toBe(true);
|
||||
|
||||
const cache = new Map();
|
||||
let buildCalls = 0;
|
||||
const first = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 1_000);
|
||||
const cached = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 30_000);
|
||||
const expired = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 61_001);
|
||||
expect(cached).toBe(first);
|
||||
expect(expired).toEqual({ value: 2 });
|
||||
});
|
||||
|
||||
it('keeps public health minimal and exposes aggregate admin metrics', () => {
|
||||
const rooms = new Map([
|
||||
['room-a', { peers: new Set(['a', 'b']), activeLobby: null }],
|
||||
['room-b', { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } }]
|
||||
]);
|
||||
const input = {
|
||||
rooms,
|
||||
connections: 5,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: {
|
||||
connections: 1,
|
||||
events: 2,
|
||||
health: 3,
|
||||
adminMetricsAuth: 4,
|
||||
authFailures: 5,
|
||||
roomList: 6,
|
||||
leaveRoom: 7
|
||||
}
|
||||
};
|
||||
|
||||
expect(Object.keys(buildHealthPayload({ ...input, includeMetrics: false })).sort()).toEqual(
|
||||
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort()
|
||||
);
|
||||
expect(buildHealthPayload({
|
||||
...input,
|
||||
includeMetrics: true,
|
||||
rateLimitDenied: { leaveRoom: 8 }
|
||||
})).toMatchObject({
|
||||
peers: 5,
|
||||
roomsWithLobby: 1,
|
||||
avgPeersPerRoom: 2.5,
|
||||
maxPeersInRoom: 3,
|
||||
memory: { rss: 10, heapUsed: 5, heapTotal: 8 },
|
||||
rateLimits: {
|
||||
trackedClients: input.rateLimitSizes,
|
||||
denied: {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
leaveRoom: 8
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
+121
-13
@@ -1,28 +1,55 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
checkAdminMetricsAuthRate,
|
||||
checkAuthRate,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkLeaveRoomRate,
|
||||
checkChatMessageRate,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||
chatMessageCounts,
|
||||
connectionCounts,
|
||||
eventCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
failedAuthAttempts,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
leaveRoomCounts,
|
||||
clearRateLimitMaps
|
||||
clearRateLimitMaps,
|
||||
recordAuthFailure,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup
|
||||
} from './rate-limiter.js';
|
||||
|
||||
function resetRateLimits() {
|
||||
stopRateLimitCleanup();
|
||||
clearRateLimitMaps();
|
||||
Object.assign(rateLimitDenied, {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
leaveRoom: 0,
|
||||
chatMessages: 0
|
||||
});
|
||||
}
|
||||
|
||||
describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
const testSocketId = 'test-socket-123';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.leaveRoom = 0;
|
||||
resetRateLimits();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRateLimitMaps();
|
||||
});
|
||||
afterEach(resetRateLimits);
|
||||
|
||||
it('should allow LEAVE_ROOM within limit', () => {
|
||||
// Test within the rate limit
|
||||
@@ -98,7 +125,7 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(leaveRoomCounts.size).toBe(1);
|
||||
|
||||
clearRateLimitMaps();
|
||||
resetRateLimits();
|
||||
expect(leaveRoomCounts.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -106,12 +133,9 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
const socketId = 'chat-socket';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.chatMessages = 0;
|
||||
});
|
||||
beforeEach(resetRateLimits);
|
||||
|
||||
afterEach(() => clearRateLimitMaps());
|
||||
afterEach(resetRateLimits);
|
||||
|
||||
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
||||
@@ -129,6 +153,90 @@ describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remaining relay rate limits', () => {
|
||||
beforeEach(resetRateLimits);
|
||||
afterEach(resetRateLimits);
|
||||
|
||||
it.each([
|
||||
['connection', checkConnectionRate, CONNECTION_RATE_LIMIT, 'ip-1', 'connections'],
|
||||
['event', checkEventRate, EVENT_RATE_LIMIT, 'socket-1', 'events'],
|
||||
['health', checkHealthRate, 10, 'ip-2', 'health'],
|
||||
['admin metrics auth', checkAdminMetricsAuthRate, 5, 'ip-3', 'adminMetricsAuth']
|
||||
])('enforces the %s window and increments its denial counter', (_label, check, limit, key, counter) => {
|
||||
for (let attempt = 0; attempt < limit; attempt++) expect(check(key)).toBe(true);
|
||||
expect(check(key)).toBe(false);
|
||||
expect(rateLimitDenied[counter]).toBe(1);
|
||||
expect(check(`${key}-other`)).toBe(true);
|
||||
});
|
||||
|
||||
it('scopes failed authentication attempts to IP and room', () => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) recordAuthFailure('10.0.0.1', 'room-a');
|
||||
expect(checkAuthRate('10.0.0.1', 'room-a')).toBe(false);
|
||||
expect(checkAuthRate('10.0.0.1', 'room-b')).toBe(true);
|
||||
expect(failedAuthAttempts.get('10.0.0.1:room-a')).toMatchObject({ count: 5 });
|
||||
});
|
||||
|
||||
it('starts cleanup only once and can stop safely', () => {
|
||||
const io = { sockets: { sockets: new Map() } };
|
||||
expect(() => {
|
||||
startRateLimitCleanup(io);
|
||||
startRateLimitCleanup(io);
|
||||
stopRateLimitCleanup();
|
||||
stopRateLimitCleanup();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('clears every rate-limit map', () => {
|
||||
const maps = [
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
chatMessageCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts
|
||||
];
|
||||
maps.forEach((map, index) => map.set(`key-${index}`, { count: 1 }));
|
||||
clearRateLimitMaps();
|
||||
maps.forEach(map => expect(map.size).toBe(0));
|
||||
});
|
||||
|
||||
it('removes expired and disconnected entries in both cleanup intervals', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-08-21T10:00:00Z'));
|
||||
const now = Date.now();
|
||||
const sockets = new Map([['connected', {}]]);
|
||||
connectionCounts.set('expired-ip', { count: 1, resetTime: now - 1 });
|
||||
connectionCounts.set('live-ip', { count: 1, resetTime: now + 120000 });
|
||||
eventCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
|
||||
eventCounts.set('connected', { count: 1, resetTime: now + 120000 });
|
||||
chatMessageCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
|
||||
leaveRoomCounts.set('disconnected', { count: 1, resetTime: now + 120000 });
|
||||
healthCounts.set('expired-health', { count: 1, resetTime: now - 1 });
|
||||
adminMetricsAuthCounts.set('expired-admin', { count: 1, resetTime: now - 1 });
|
||||
roomListCooldowns.set('disconnected', now);
|
||||
roomListCooldowns.set('connected', now);
|
||||
failedAuthAttempts.set('expired-auth', { count: 1, lastAttempt: now - (16 * 60 * 1000) });
|
||||
failedAuthAttempts.set('live-auth', { count: 1, lastAttempt: now });
|
||||
|
||||
startRateLimitCleanup({ sockets: { sockets } });
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
expect([...connectionCounts.keys()]).toEqual(['live-ip']);
|
||||
expect([...eventCounts.keys()]).toEqual(['connected']);
|
||||
expect(chatMessageCounts.size).toBe(0);
|
||||
expect(leaveRoomCounts.size).toBe(0);
|
||||
expect(healthCounts.size).toBe(0);
|
||||
expect(adminMetricsAuthCounts.size).toBe(0);
|
||||
expect([...roomListCooldowns.keys()]).toEqual(['connected']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(14 * 60 * 1000);
|
||||
expect([...failedAuthAttempts.keys()]).toEqual(['live-auth']);
|
||||
stopRateLimitCleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
|
||||
Reference in New Issue
Block a user