Cache health responses server-side

This commit is contained in:
Koala
2026-06-03 11:04:45 +02:00
parent 602be3a724
commit 4f4cdcd8c0
6 changed files with 57 additions and 20 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ ADMIN_METRICS_TOKEN=
```
### Health & Metrics
`GET /` and `GET /health` are IP-rate-limited to 20 requests per minute per client IP. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. 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. Wrong admin bearer attempts are separately limited to 5 requests per minute per client IP. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
@@ -48,7 +48,7 @@ npm start
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 20 requests per minute per IP, with stricter throttling for wrong admin bearer attempts.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
- **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.
+32 -16
View File
@@ -7,6 +7,7 @@ import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/const
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
@@ -25,8 +26,9 @@ 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 = 20;
const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
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.');
@@ -35,13 +37,21 @@ if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
const healthResponseCache = new Map();
// Health Check with Rate Limiting
app.get('/', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
res.json({ status: 'online', service: 'KoalaSync Relay' });
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
'root',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => ({ status: 'online', service: 'KoalaSync Relay' })
));
});
app.get('/health', (req, res) => {
@@ -54,20 +64,26 @@ app.get('/health', (req, res) => {
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.json(buildHealthPayload({
rooms,
connections: io.engine?.clientsCount ?? 0,
includeMetrics,
uptime: process.uptime(),
rateLimitSizes: {
connections: connectionCounts.size,
events: eventCounts.size,
health: healthCounts.size,
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
}));
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
includeMetrics ? 'health-admin' : 'health-basic',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => buildHealthPayload({
rooms,
connections: io.engine?.clientsCount ?? 0,
includeMetrics,
uptime: process.uptime(),
rateLimitSizes: {
connections: connectionCounts.size,
events: eventCounts.size,
health: healthCounts.size,
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
})
));
});
const httpServer = createServer(app);
+11
View File
@@ -9,6 +9,17 @@ export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
return true;
}
export function getCachedPayload(cache, key, ttlMs, buildPayload, now = Date.now()) {
const cached = cache.get(key);
if (cached && now - cached.createdAt < ttlMs) {
return cached.payload;
}
const payload = buildPayload();
cache.set(key, { createdAt: now, payload });
return payload;
}
export function isAdminMetricsAuthorized(authHeader, adminToken) {
if (!adminToken || typeof adminToken !== 'string') return false;
if (!authHeader || typeof authHeader !== 'string') return false;