Release rolling / release (push) Successful in 8m38s
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.
181 lines
5.0 KiB
JavaScript
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;
|
|
|
|
|
|
|
|
|
|
|