v1.0.21
This commit is contained in:
1 parent
790ce092ee
commit
55f3101a19
51 files changed
+763
-599
No files matched your search
+81
-18
@@ -44,6 +44,7 @@ export interface EdgeConnection {
|
||||
lastHeartbeat: number;
|
||||
pendingRequests: Map<string, PendingRequest>;
|
||||
pendingStreamRequests: Map<string, PendingStreamRequest>;
|
||||
pingInterval?: ReturnType<typeof setInterval>;
|
||||
lastMetrics?: {
|
||||
uptime?: number;
|
||||
cpuUsage?: number;
|
||||
@@ -77,7 +78,7 @@ declare global {
|
||||
var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined;
|
||||
var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventMessage['event']) => Promise<void>) | undefined;
|
||||
var __hawserHandleMetrics: ((envId: number, metrics: MetricsMessage['metrics']) => Promise<void>) | undefined;
|
||||
var __hawserHandleMessage: ((ws: any, msg: any, connId: string) => Promise<void>) | undefined;
|
||||
var __hawserHandleMessage: ((ws: any, msg: any, connId: string, remoteIp?: string) => Promise<void>) | undefined;
|
||||
var __hawserHandleDisconnect: ((ws: any, connId: string) => void) | undefined;
|
||||
var __terminalHandleExecMessage: ((msg: any) => void) | undefined;
|
||||
}
|
||||
@@ -119,6 +120,11 @@ export function initializeEdgeManager(): void {
|
||||
conn.pendingRequests.clear();
|
||||
conn.pendingStreamRequests.clear();
|
||||
|
||||
if (conn.pingInterval) {
|
||||
clearInterval(conn.pingInterval);
|
||||
conn.pingInterval = undefined;
|
||||
}
|
||||
|
||||
conn.ws.close(1001, 'Connection timeout');
|
||||
edgeConnections.delete(envId);
|
||||
updateEnvironmentStatus(envId, null);
|
||||
@@ -255,11 +261,15 @@ globalThis.__hawserHandleMetrics = handleEdgeMetrics;
|
||||
export async function validateHawserToken(
|
||||
token: string
|
||||
): Promise<{ valid: boolean; environmentId?: number; tokenId?: number }> {
|
||||
// Get all active tokens
|
||||
const tokens = await db.select().from(hawserTokens).where(eq(hawserTokens.isActive, true));
|
||||
// Fast path: lookup by token prefix (first 8 chars) instead of iterating all tokens.
|
||||
// This reduces O(N) Argon2id verifications to O(1) DB lookup + 1 verify.
|
||||
const prefix = token.substring(0, 8);
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(hawserTokens)
|
||||
.where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true)));
|
||||
|
||||
// Check each token (tokens are hashed)
|
||||
for (const t of tokens) {
|
||||
for (const t of candidates) {
|
||||
try {
|
||||
const isValid = await verifyPassword(token, t.token);
|
||||
if (isValid) {
|
||||
@@ -276,7 +286,7 @@ export async function validateHawserToken(
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Invalid hash, continue checking
|
||||
// Invalid hash format, skip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +381,12 @@ export function closeEdgeConnection(environmentId: number): void {
|
||||
`Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.`
|
||||
);
|
||||
|
||||
// Clear ping interval
|
||||
if (connection.pingInterval) {
|
||||
clearInterval(connection.pingInterval);
|
||||
connection.pingInterval = undefined;
|
||||
}
|
||||
|
||||
// Reject all pending requests
|
||||
for (const [requestId, pending] of connection.pendingRequests) {
|
||||
console.log(`[Hawser] Rejecting pending request ${requestId} due to environment deletion`);
|
||||
@@ -427,6 +443,12 @@ export function handleEdgeConnection(
|
||||
existing.pendingRequests.clear();
|
||||
existing.pendingStreamRequests.clear();
|
||||
|
||||
// Clear ping interval before closing
|
||||
if (existing.pingInterval) {
|
||||
clearInterval(existing.pingInterval);
|
||||
existing.pingInterval = undefined;
|
||||
}
|
||||
|
||||
// Immediately destroy TCP socket — no graceful close needed for replaced connections
|
||||
if (typeof existing.ws.terminate === 'function') {
|
||||
existing.ws.terminate();
|
||||
@@ -452,6 +474,17 @@ export function handleEdgeConnection(
|
||||
|
||||
edgeConnections.set(environmentId, connection);
|
||||
|
||||
// Start server-side ping interval to keep connection alive.
|
||||
// 5s is conservative against reverse proxies with aggressive idle timeouts.
|
||||
connection.pingInterval = setInterval(() => {
|
||||
try {
|
||||
connection.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
||||
} catch {
|
||||
clearInterval(connection.pingInterval!);
|
||||
connection.pingInterval = undefined;
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Update environment record
|
||||
updateEnvironmentStatus(environmentId, connection);
|
||||
|
||||
@@ -499,7 +532,8 @@ export async function sendEdgeRequest(
|
||||
headers?: Record<string, string>,
|
||||
streaming = false,
|
||||
timeout = 30000,
|
||||
isBinary = false
|
||||
isBinary = false,
|
||||
signal?: AbortSignal
|
||||
): Promise<EdgeResponse> {
|
||||
const connection = edgeConnections.get(environmentId);
|
||||
if (!connection) {
|
||||
@@ -517,6 +551,27 @@ export async function sendEdgeRequest(
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeout);
|
||||
|
||||
// Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for dockerPing)
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(new Error('Request aborted'));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
connection.pendingRequests.delete(requestId);
|
||||
if (streaming) {
|
||||
connection.pendingStreamRequests.delete(requestId);
|
||||
}
|
||||
clearTimeout(timeoutHandle);
|
||||
reject(new Error('Request aborted'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
// For streaming requests, the Go agent sends 'stream' messages instead of a single 'response'.
|
||||
// We need to register a stream handler that collects all data and resolves when complete.
|
||||
if (streaming) {
|
||||
@@ -792,6 +847,12 @@ export function handleHeartbeat(environmentId: number): void {
|
||||
export function handleDisconnect(environmentId: number): void {
|
||||
const connection = edgeConnections.get(environmentId);
|
||||
if (connection) {
|
||||
// Clear ping interval
|
||||
if (connection.pingInterval) {
|
||||
clearInterval(connection.pingInterval);
|
||||
connection.pingInterval = undefined;
|
||||
}
|
||||
|
||||
// Reject all pending requests
|
||||
for (const [, pending] of connection.pendingRequests) {
|
||||
clearTimeout(pending.timeout);
|
||||
@@ -983,11 +1044,12 @@ export type HawserMessage =
|
||||
const wsToEnvId = new Map<any, number>();
|
||||
|
||||
// Auth fail cache to prevent brute-force token validation.
|
||||
// Entries are periodically cleaned up to prevent unbounded growth.
|
||||
// 5 min cooldown — hawser agents use exponential backoff (30-60s),
|
||||
// so a short cooldown lets every retry through.
|
||||
const hawserAuthFailCache = new Map<string, number>();
|
||||
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 30_000;
|
||||
const HAWSER_AUTH_FAIL_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Periodic cleanup of expired auth fail entries (every 60s)
|
||||
// Periodic cleanup of expired auth fail entries
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of hawserAuthFailCache) {
|
||||
@@ -995,7 +1057,7 @@ setInterval(() => {
|
||||
hawserAuthFailCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
}, HAWSER_AUTH_FAIL_COOLDOWN_MS);
|
||||
|
||||
// ─── Reconnection storm throttle ───
|
||||
// Tracks per-environment reconnection frequency to detect storms
|
||||
@@ -1007,7 +1069,7 @@ interface ReconnectTrackerEntry {
|
||||
}
|
||||
const reconnectTracker = new Map<number, ReconnectTrackerEntry>();
|
||||
const RECONNECT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window
|
||||
const RECONNECT_BURST = 3; // allow 3 reconnections per window
|
||||
const RECONNECT_BURST = 10; // allow 10 reconnections per window
|
||||
const COOLDOWN_LEVELS_SECS = [30, 60, 120, 300]; // escalating cooldown in seconds
|
||||
const STABLE_THRESHOLD_MS = 5 * 60 * 1000; // stable connection resets tracker
|
||||
const STALE_TRACKER_MS = 10 * 60 * 1000; // clean up stale tracker entries
|
||||
@@ -1062,13 +1124,14 @@ function recordReconnection(envId: number): { allowed: true } | { allowed: false
|
||||
*
|
||||
* Registered as globalThis.__hawserHandleMessage for server.js to call.
|
||||
*/
|
||||
async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise<void> {
|
||||
async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp?: string): Promise<void> {
|
||||
if (msg.type === 'hello') {
|
||||
const remoteAddr = connId;
|
||||
const rateLimitKey = remoteIp || connId;
|
||||
|
||||
// Rate limit auth failures
|
||||
const lastFail = hawserAuthFailCache.get(remoteAddr);
|
||||
// Rate limit auth failures by remote IP (not connId which is unique per connection)
|
||||
const lastFail = hawserAuthFailCache.get(rateLimitKey);
|
||||
if (lastFail && Date.now() - lastFail < HAWSER_AUTH_FAIL_COOLDOWN_MS) {
|
||||
console.log(`[Hawser WS] Rate limited ${connId} (IP: ${rateLimitKey}) — ${Math.round((Date.now() - lastFail) / 1000)}s since last fail`);
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Too many failed attempts' }));
|
||||
ws.close(1008, 'Rate limited');
|
||||
return;
|
||||
@@ -1083,8 +1146,8 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise
|
||||
try {
|
||||
const result = await validateHawserToken(msg.token);
|
||||
if (!result.valid || !result.environmentId) {
|
||||
console.log(`[Hawser WS] Authentication failed for connection ${connId}`);
|
||||
hawserAuthFailCache.set(remoteAddr, Date.now());
|
||||
console.log(`[Hawser WS] Authentication failed for connection ${connId} (IP: ${rateLimitKey})`);
|
||||
hawserAuthFailCache.set(rateLimitKey, Date.now());
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
|
||||
ws.close(1008, 'Invalid token');
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user