Files
peardock/server/utils/rateLimiter.js
T
Raven Scott 9680437fab
Release rolling / release (push) Successful in 8m38s
Improve container details UX, dashboard shortcuts, and RPC rate limits.
Tighten container detail tabs into a fit-to-viewport layout with a richer
overview, debounced process filters, and a readable process table. Make
dashboard KPI cards navigate to resource views (with status filters that
reset on leave), and raise per-peer RPC limits so normal UI polling no
longer trips rate limiting.
2026-07-13 17:25:14 -04:00

181 lines
5.0 KiB
JavaScript

/**
* Rate limiting utility to prevent abuse
*/
class RateLimiter {
constructor() {
// Map of peer ID to request timestamps
this.requests = new Map();
// Configuration — sized for interactive UI (auto-refresh, dashboard,
// multi-tab inspect) without tripping under normal operator use.
this.config = {
maxRequests: 3000, // Max non-stream RPCs per window (~50/s avg)
windowMs: 60000, // 1 minute window
commandLimits: {
deployContainer: { max: 30, windowMs: 60000 }, // deploys per minute
dockerCommand: { max: 200, windowMs: 10000 }, // host docker CLI / 10s
startContainer: { max: 120, windowMs: 60000 }, // starts per minute
stopContainer: { max: 120, windowMs: 60000 }, // stops per minute
},
};
/**
* High-frequency stream methods (keystrokes, resizes, chunks).
* Exempt from the general cap; optional soft ceiling.
*/
this.streamMethods = new Set([
'terminalInput',
'terminalResize',
'execInput',
'attachInput',
'loadImageChunk',
'binaryStreamChunk',
'dockerTerminalResize',
]);
this.streamLimit = { max: 18000, windowMs: 60000 }; // ~300/s sustained
}
/**
* Get peer identifier
* @param {Object} peer - Peer object
* @returns {string} - Peer identifier
*/
getPeerId(peer) {
if (peer?.id && typeof peer.id === 'string') return peer.id
if (peer?.remotePublicKey) {
return typeof peer.remotePublicKey === 'string'
? peer.remotePublicKey
: Buffer.from(peer.remotePublicKey).toString('hex')
}
return 'unknown'
}
/**
* Check if request is within rate limit
* @param {Object} peer - Peer object
* @param {string} command - Command name
* @returns {boolean} - True if allowed
*/
isAllowed(peer, command) {
const peerId = this.getPeerId(peer);
const now = Date.now();
// Clean up old entries
this.cleanup(now);
// Initialize peer entry if needed
if (!this.requests.has(peerId)) {
this.requests.set(peerId, {
general: [],
stream: [],
commands: {}
});
}
const peerData = this.requests.get(peerId);
if (!peerData.stream) peerData.stream = [];
// Stream methods: separate high ceiling, never consume general budget
if (this.streamMethods.has(command)) {
const streamWindow = now - this.streamLimit.windowMs;
peerData.stream = peerData.stream.filter((t) => t > streamWindow);
if (peerData.stream.length >= this.streamLimit.max) {
return false;
}
peerData.stream.push(now);
return true;
}
// Check general rate limit
const generalWindow = now - this.config.windowMs;
peerData.general = peerData.general.filter(timestamp => timestamp > generalWindow);
if (peerData.general.length >= this.config.maxRequests) {
return false;
}
// Check command-specific rate limit
if (this.config.commandLimits[command]) {
const limit = this.config.commandLimits[command];
const commandWindow = now - limit.windowMs;
if (!peerData.commands[command]) {
peerData.commands[command] = [];
}
peerData.commands[command] = peerData.commands[command].filter(
timestamp => timestamp > commandWindow
);
if (peerData.commands[command].length >= limit.max) {
return false;
}
// Record command request
peerData.commands[command].push(now);
}
// Record general request
peerData.general.push(now);
return true;
}
/** @param {string} method */
isStreamMethod(method) {
return this.streamMethods.has(method);
}
/**
* Clean up old entries
* @param {number} now - Current timestamp
*/
cleanup(now) {
const maxAge = Math.max(
this.config.windowMs,
...Object.values(this.config.commandLimits).map(l => l.windowMs)
);
for (const [peerId, peerData] of this.requests.entries()) {
// Clean general requests
peerData.general = peerData.general.filter(timestamp => timestamp > now - maxAge);
// Clean command requests
for (const [command, timestamps] of Object.entries(peerData.commands)) {
const limit = this.config.commandLimits[command];
if (limit) {
peerData.commands[command] = timestamps.filter(
timestamp => timestamp > now - limit.windowMs
);
}
}
// Remove peer if no active requests
if (peerData.general.length === 0 &&
Object.values(peerData.commands).every(arr => arr.length === 0)) {
this.requests.delete(peerId);
}
}
}
/**
* Reset rate limit for a peer (useful for testing or manual override)
* @param {Object} peer - Peer object
*/
reset(peer) {
const peerId = this.getPeerId(peer);
this.requests.delete(peerId);
}
}
// Singleton instance
const rateLimiter = new RateLimiter();
export default rateLimiter;