mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
feat: Add protocol spec, rate limiting, vitest framework, and documentation
## Added - **WebSocket Protocol Specification** (docs/PROTOCOL.md) - Complete reference for all 20+ events with payload schemas, rate limits, and edge cases - **LEAVE_ROOM Rate Limiting** - 10 requests/socket/minute to prevent abuse - **Vitest Testing Framework** - Modern test setup with coverage reporting - **Host Control Mode Documentation** - Consolidated EN documentation (docs/host-control-mode.md) with references to internal implementation ## Changed - **Graceful Shutdown** - Socket.IO clients properly disconnected during server shutdown (server/index.js) - **CI Workflow** - Added test step with continue-on-error for gradual rollout - **README** - Added protocol documentation link - **CHANGELOG** - Updated with all new features and improvements ## Moved - Internal documentation moved to docs/internal/ for better organization - Host Control Mode docs consolidated from 4 files to 1 comprehensive guide ## Technical Details - Protocol spec: 495 lines, covers all events from shared/constants.js - Rate limiter: Follows existing pattern (checkAuthRate, checkEventRate) - Vitest: 6 tests for rate limiter, all passing - Documentation: Host Control Mode doc includes edge cases, testing checklist, architecture decisions
This commit is contained in:
+8
-2
@@ -661,6 +661,10 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
socket.on(EVENTS.LEAVE_ROOM, () => {
|
||||
if (!checkLeaveRoomRate(socket.id)) {
|
||||
log('SECURITY', `LEAVE_ROOM rate limit exceeded for socket: ${socket.id}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (mapping) {
|
||||
@@ -919,12 +923,14 @@ function gracefulShutdown(signal) {
|
||||
log('SERVER', `${signal} received — starting graceful shutdown...`);
|
||||
// 1. Notify all connected clients so they can display a meaningful message
|
||||
io.emit(EVENTS.ERROR, { message: 'Server is restarting. Reconnecting automatically...' });
|
||||
// 2. Stop accepting new HTTP connections
|
||||
// 2. Gracefully disconnect all Socket.IO clients
|
||||
io.disconnectSockets(true);
|
||||
// 3. Stop accepting new HTTP connections
|
||||
httpServer.close(() => {
|
||||
log('SERVER', 'HTTP server closed. Exiting.');
|
||||
process.exit(0);
|
||||
});
|
||||
// 3. Safety net: force-exit after 5s if connections don't drain
|
||||
// 4. Safety net: force-exit after 5s if connections don't drain
|
||||
setTimeout(() => {
|
||||
log('SERVER', 'Force-exit after timeout.');
|
||||
process.exit(1);
|
||||
|
||||
+26
-1
@@ -14,6 +14,8 @@ export const EVENT_RATE_LIMIT = 50; // max relayed events per soc
|
||||
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 LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||
export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
@@ -21,13 +23,15 @@ export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
||||
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0
|
||||
roomList: 0,
|
||||
leaveRoom: 0
|
||||
};
|
||||
|
||||
let authCleanupId = null;
|
||||
@@ -122,6 +126,20 @@ export function checkAdminMetricsAuthRate(ip) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkLeaveRoomRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = leaveRoomCounts.get(socketId) || { count: 0, resetTime: now + LEAVE_ROOM_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) {
|
||||
entry.count = 0;
|
||||
entry.resetTime = now + LEAVE_ROOM_RATE_WINDOW_MS;
|
||||
}
|
||||
entry.count++;
|
||||
leaveRoomCounts.set(socketId, entry);
|
||||
if (entry.count <= LEAVE_ROOM_RATE_LIMIT) return true;
|
||||
rateLimitDenied.leaveRoom++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startRateLimitCleanup(io) {
|
||||
if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start
|
||||
// Clean up old auth failure records (every 15 minutes)
|
||||
@@ -145,6 +163,11 @@ export function startRateLimitCleanup(io) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
leaveRoomCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) healthCounts.delete(ip);
|
||||
}
|
||||
@@ -166,7 +189,9 @@ export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
leaveRoomCounts.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
checkLeaveRoomRate,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
leaveRoomCounts
|
||||
} from './rate-limiter.js';
|
||||
|
||||
describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
const testSocketId = 'test-socket-123';
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset state before each test
|
||||
leaveRoomCounts.clear();
|
||||
rateLimitDenied.leaveRoom = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
leaveRoomCounts.clear();
|
||||
});
|
||||
|
||||
it('should allow LEAVE_ROOM within limit', () => {
|
||||
// Test within the rate limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
const result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
expect(rateLimitDenied.leaveRoom).toBe(0);
|
||||
});
|
||||
|
||||
it('should block LEAVE_ROOM when exceeding limit', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Next request should be blocked
|
||||
const result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(false);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(1);
|
||||
});
|
||||
|
||||
it('should reset count after window expires', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Verify we're at the limit
|
||||
let result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Fast-forward time beyond the rate limit window
|
||||
const entry = leaveRoomCounts.get(testSocketId);
|
||||
entry.resetTime = Date.now() - LEAVE_ROOM_RATE_WINDOW_MS - 1000;
|
||||
leaveRoomCounts.set(testSocketId, entry);
|
||||
|
||||
// Next request should be allowed again
|
||||
result = checkLeaveRoomRate(testSocketId);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple sockets independently', () => {
|
||||
const socketId2 = 'test-socket-456';
|
||||
|
||||
// Fill up first socket
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// Second socket should still be allowed
|
||||
const result = checkLeaveRoomRate(socketId2);
|
||||
expect(result).toBe(true);
|
||||
|
||||
// First socket should be blocked
|
||||
const result2 = checkLeaveRoomRate(testSocketId);
|
||||
expect(result2).toBe(false);
|
||||
});
|
||||
|
||||
it('should increment rateLimitDenied counter on block', () => {
|
||||
// Fill up to the limit
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT; i++) {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
}
|
||||
|
||||
// First block
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(1);
|
||||
|
||||
// Second block
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(rateLimitDenied.leaveRoom).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
expect(LEAVE_ROOM_RATE_WINDOW_MS).toBe(60000); // 1 minute
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user