@push.rocks/smartproxy 19.6.13 → 19.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/core/utils/log-deduplicator.d.ts +39 -0
- package/dist_ts/core/utils/log-deduplicator.js +297 -0
- package/dist_ts/core/utils/shared-security-manager.d.ts +2 -1
- package/dist_ts/core/utils/shared-security-manager.js +22 -2
- package/dist_ts/proxies/http-proxy/http-proxy.d.ts +1 -0
- package/dist_ts/proxies/http-proxy/http-proxy.js +94 -9
- package/dist_ts/proxies/http-proxy/models/types.d.ts +2 -0
- package/dist_ts/proxies/http-proxy/models/types.js +1 -1
- package/dist_ts/proxies/http-proxy/security-manager.d.ts +42 -1
- package/dist_ts/proxies/http-proxy/security-manager.js +121 -2
- package/dist_ts/proxies/smart-proxy/connection-manager.d.ts +14 -0
- package/dist_ts/proxies/smart-proxy/connection-manager.js +86 -32
- package/dist_ts/proxies/smart-proxy/http-proxy-bridge.js +5 -1
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +1 -0
- package/dist_ts/proxies/smart-proxy/route-connection-handler.js +25 -9
- package/dist_ts/proxies/smart-proxy/security-manager.d.ts +9 -0
- package/dist_ts/proxies/smart-proxy/security-manager.js +63 -1
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +4 -1
- package/package.json +1 -1
- package/readme.hints.md +113 -1
- package/readme.plan.md +34 -353
- package/ts/core/utils/log-deduplicator.ts +361 -0
- package/ts/core/utils/shared-security-manager.ts +24 -1
- package/ts/proxies/http-proxy/http-proxy.ts +129 -9
- package/ts/proxies/http-proxy/models/types.ts +4 -0
- package/ts/proxies/http-proxy/security-manager.ts +136 -1
- package/ts/proxies/smart-proxy/connection-manager.ts +113 -36
- package/ts/proxies/smart-proxy/http-proxy-bridge.ts +5 -0
- package/ts/proxies/smart-proxy/models/interfaces.ts +1 -0
- package/ts/proxies/smart-proxy/route-connection-handler.ts +52 -15
- package/ts/proxies/smart-proxy/security-manager.ts +76 -1
- package/ts/proxies/smart-proxy/smart-proxy.ts +4 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { logger } from './logger.js';
|
|
2
|
+
|
|
3
|
+
interface ILogEvent {
|
|
4
|
+
level: 'info' | 'warn' | 'error' | 'debug';
|
|
5
|
+
message: string;
|
|
6
|
+
data?: any;
|
|
7
|
+
count: number;
|
|
8
|
+
firstSeen: number;
|
|
9
|
+
lastSeen: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface IAggregatedEvent {
|
|
13
|
+
key: string;
|
|
14
|
+
events: Map<string, ILogEvent>;
|
|
15
|
+
flushTimer?: NodeJS.Timeout;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Log deduplication utility to reduce log spam for repetitive events
|
|
20
|
+
*/
|
|
21
|
+
export class LogDeduplicator {
|
|
22
|
+
private globalFlushTimer?: NodeJS.Timeout;
|
|
23
|
+
private aggregatedEvents: Map<string, IAggregatedEvent> = new Map();
|
|
24
|
+
private flushInterval: number = 5000; // 5 seconds
|
|
25
|
+
private maxBatchSize: number = 100;
|
|
26
|
+
private rapidEventThreshold: number = 50; // Flush early if this many events in 1 second
|
|
27
|
+
private lastRapidCheck: number = Date.now();
|
|
28
|
+
|
|
29
|
+
constructor(flushInterval?: number) {
|
|
30
|
+
if (flushInterval) {
|
|
31
|
+
this.flushInterval = flushInterval;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Set up global periodic flush to ensure logs are emitted regularly
|
|
35
|
+
this.globalFlushTimer = setInterval(() => {
|
|
36
|
+
this.flushAll();
|
|
37
|
+
}, this.flushInterval * 2); // Flush everything every 2x the normal interval
|
|
38
|
+
|
|
39
|
+
if (this.globalFlushTimer.unref) {
|
|
40
|
+
this.globalFlushTimer.unref();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Log a deduplicated event
|
|
46
|
+
* @param key - Aggregation key (e.g., 'connection-rejected', 'cleanup-batch')
|
|
47
|
+
* @param level - Log level
|
|
48
|
+
* @param message - Log message template
|
|
49
|
+
* @param data - Additional data
|
|
50
|
+
* @param dedupeKey - Deduplication key within the aggregation (e.g., IP address, reason)
|
|
51
|
+
*/
|
|
52
|
+
public log(
|
|
53
|
+
key: string,
|
|
54
|
+
level: 'info' | 'warn' | 'error' | 'debug',
|
|
55
|
+
message: string,
|
|
56
|
+
data?: any,
|
|
57
|
+
dedupeKey?: string
|
|
58
|
+
): void {
|
|
59
|
+
const eventKey = dedupeKey || message;
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
|
|
62
|
+
if (!this.aggregatedEvents.has(key)) {
|
|
63
|
+
this.aggregatedEvents.set(key, {
|
|
64
|
+
key,
|
|
65
|
+
events: new Map(),
|
|
66
|
+
flushTimer: undefined
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const aggregated = this.aggregatedEvents.get(key)!;
|
|
71
|
+
|
|
72
|
+
if (aggregated.events.has(eventKey)) {
|
|
73
|
+
const event = aggregated.events.get(eventKey)!;
|
|
74
|
+
event.count++;
|
|
75
|
+
event.lastSeen = now;
|
|
76
|
+
if (data) {
|
|
77
|
+
event.data = { ...event.data, ...data };
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
aggregated.events.set(eventKey, {
|
|
81
|
+
level,
|
|
82
|
+
message,
|
|
83
|
+
data,
|
|
84
|
+
count: 1,
|
|
85
|
+
firstSeen: now,
|
|
86
|
+
lastSeen: now
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check for rapid events (many events in short time)
|
|
91
|
+
const totalEvents = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
|
|
92
|
+
|
|
93
|
+
// If we're getting flooded with events, flush more frequently
|
|
94
|
+
if (now - this.lastRapidCheck < 1000 && totalEvents >= this.rapidEventThreshold) {
|
|
95
|
+
this.flush(key);
|
|
96
|
+
this.lastRapidCheck = now;
|
|
97
|
+
} else if (aggregated.events.size >= this.maxBatchSize) {
|
|
98
|
+
// Check if we should flush due to size
|
|
99
|
+
this.flush(key);
|
|
100
|
+
} else if (!aggregated.flushTimer) {
|
|
101
|
+
// Schedule flush
|
|
102
|
+
aggregated.flushTimer = setTimeout(() => {
|
|
103
|
+
this.flush(key);
|
|
104
|
+
}, this.flushInterval);
|
|
105
|
+
|
|
106
|
+
if (aggregated.flushTimer.unref) {
|
|
107
|
+
aggregated.flushTimer.unref();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Update rapid check time
|
|
112
|
+
if (now - this.lastRapidCheck >= 1000) {
|
|
113
|
+
this.lastRapidCheck = now;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Flush aggregated events for a specific key
|
|
119
|
+
*/
|
|
120
|
+
public flush(key: string): void {
|
|
121
|
+
const aggregated = this.aggregatedEvents.get(key);
|
|
122
|
+
if (!aggregated || aggregated.events.size === 0) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (aggregated.flushTimer) {
|
|
127
|
+
clearTimeout(aggregated.flushTimer);
|
|
128
|
+
aggregated.flushTimer = undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Emit aggregated log based on the key
|
|
132
|
+
switch (key) {
|
|
133
|
+
case 'connection-rejected':
|
|
134
|
+
this.flushConnectionRejections(aggregated);
|
|
135
|
+
break;
|
|
136
|
+
case 'connection-cleanup':
|
|
137
|
+
this.flushConnectionCleanups(aggregated);
|
|
138
|
+
break;
|
|
139
|
+
case 'connection-terminated':
|
|
140
|
+
this.flushConnectionTerminations(aggregated);
|
|
141
|
+
break;
|
|
142
|
+
case 'ip-rejected':
|
|
143
|
+
this.flushIPRejections(aggregated);
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
this.flushGeneric(aggregated);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clear events
|
|
150
|
+
aggregated.events.clear();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Flush all pending events
|
|
155
|
+
*/
|
|
156
|
+
public flushAll(): void {
|
|
157
|
+
for (const key of this.aggregatedEvents.keys()) {
|
|
158
|
+
this.flush(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private flushConnectionRejections(aggregated: IAggregatedEvent): void {
|
|
163
|
+
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
|
|
164
|
+
const byReason = new Map<string, number>();
|
|
165
|
+
|
|
166
|
+
for (const [, event] of aggregated.events) {
|
|
167
|
+
const reason = event.data?.reason || 'unknown';
|
|
168
|
+
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const reasonSummary = Array.from(byReason.entries())
|
|
172
|
+
.sort((a, b) => b[1] - a[1])
|
|
173
|
+
.map(([reason, count]) => `${reason}: ${count}`)
|
|
174
|
+
.join(', ');
|
|
175
|
+
|
|
176
|
+
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
|
|
177
|
+
logger.log('warn', `[SUMMARY] Rejected ${totalCount} connections in ${Math.round(duration/1000)}s`, {
|
|
178
|
+
reasons: reasonSummary,
|
|
179
|
+
uniqueIPs: aggregated.events.size,
|
|
180
|
+
component: 'connection-dedup'
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private flushConnectionCleanups(aggregated: IAggregatedEvent): void {
|
|
185
|
+
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
|
|
186
|
+
const byReason = new Map<string, number>();
|
|
187
|
+
|
|
188
|
+
for (const [, event] of aggregated.events) {
|
|
189
|
+
const reason = event.data?.reason || 'normal';
|
|
190
|
+
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const reasonSummary = Array.from(byReason.entries())
|
|
194
|
+
.sort((a, b) => b[1] - a[1])
|
|
195
|
+
.slice(0, 5) // Top 5 reasons
|
|
196
|
+
.map(([reason, count]) => `${reason}: ${count}`)
|
|
197
|
+
.join(', ');
|
|
198
|
+
|
|
199
|
+
logger.log('info', `Cleaned up ${totalCount} connections`, {
|
|
200
|
+
reasons: reasonSummary,
|
|
201
|
+
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
|
|
202
|
+
component: 'connection-dedup'
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private flushConnectionTerminations(aggregated: IAggregatedEvent): void {
|
|
207
|
+
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
|
|
208
|
+
const byReason = new Map<string, number>();
|
|
209
|
+
const byIP = new Map<string, number>();
|
|
210
|
+
let lastActiveCount = 0;
|
|
211
|
+
|
|
212
|
+
for (const [, event] of aggregated.events) {
|
|
213
|
+
const reason = event.data?.reason || 'unknown';
|
|
214
|
+
const ip = event.data?.remoteIP || 'unknown';
|
|
215
|
+
|
|
216
|
+
byReason.set(reason, (byReason.get(reason) || 0) + event.count);
|
|
217
|
+
|
|
218
|
+
// Track by IP
|
|
219
|
+
if (ip !== 'unknown') {
|
|
220
|
+
byIP.set(ip, (byIP.get(ip) || 0) + event.count);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Track the last active connection count
|
|
224
|
+
if (event.data?.activeConnections !== undefined) {
|
|
225
|
+
lastActiveCount = event.data.activeConnections;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const reasonSummary = Array.from(byReason.entries())
|
|
230
|
+
.sort((a, b) => b[1] - a[1])
|
|
231
|
+
.slice(0, 5) // Top 5 reasons
|
|
232
|
+
.map(([reason, count]) => `${reason}: ${count}`)
|
|
233
|
+
.join(', ');
|
|
234
|
+
|
|
235
|
+
// Show top IPs if there are many different ones
|
|
236
|
+
let ipInfo = '';
|
|
237
|
+
if (byIP.size > 3) {
|
|
238
|
+
const topIPs = Array.from(byIP.entries())
|
|
239
|
+
.sort((a, b) => b[1] - a[1])
|
|
240
|
+
.slice(0, 3)
|
|
241
|
+
.map(([ip, count]) => `${ip} (${count})`)
|
|
242
|
+
.join(', ');
|
|
243
|
+
ipInfo = `, from ${byIP.size} IPs (top: ${topIPs})`;
|
|
244
|
+
} else if (byIP.size > 0) {
|
|
245
|
+
ipInfo = `, IPs: ${Array.from(byIP.keys()).join(', ')}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
|
|
249
|
+
|
|
250
|
+
// Special handling for localhost connections (HttpProxy)
|
|
251
|
+
const localhostCount = byIP.get('::ffff:127.0.0.1') || 0;
|
|
252
|
+
if (localhostCount > 0 && byIP.size === 1) {
|
|
253
|
+
// All connections are from localhost (HttpProxy)
|
|
254
|
+
logger.log('info', `[SUMMARY] ${totalCount} HttpProxy connections terminated in ${Math.round(duration/1000)}s`, {
|
|
255
|
+
reasons: reasonSummary,
|
|
256
|
+
activeConnections: lastActiveCount,
|
|
257
|
+
component: 'connection-dedup'
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
logger.log('info', `[SUMMARY] ${totalCount} connections terminated in ${Math.round(duration/1000)}s`, {
|
|
261
|
+
reasons: reasonSummary,
|
|
262
|
+
activeConnections: lastActiveCount,
|
|
263
|
+
uniqueReasons: byReason.size,
|
|
264
|
+
...(ipInfo ? { ips: ipInfo } : {}),
|
|
265
|
+
component: 'connection-dedup'
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private flushIPRejections(aggregated: IAggregatedEvent): void {
|
|
271
|
+
const byIP = new Map<string, { count: number; reasons: Set<string> }>();
|
|
272
|
+
|
|
273
|
+
for (const [ip, event] of aggregated.events) {
|
|
274
|
+
if (!byIP.has(ip)) {
|
|
275
|
+
byIP.set(ip, { count: 0, reasons: new Set() });
|
|
276
|
+
}
|
|
277
|
+
const ipData = byIP.get(ip)!;
|
|
278
|
+
ipData.count += event.count;
|
|
279
|
+
if (event.data?.reason) {
|
|
280
|
+
ipData.reasons.add(event.data.reason);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Log top offenders
|
|
285
|
+
const topOffenders = Array.from(byIP.entries())
|
|
286
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
287
|
+
.slice(0, 10)
|
|
288
|
+
.map(([ip, data]) => `${ip} (${data.count}x, ${Array.from(data.reasons).join('/')})`)
|
|
289
|
+
.join(', ');
|
|
290
|
+
|
|
291
|
+
const totalRejections = Array.from(byIP.values()).reduce((sum, data) => sum + data.count, 0);
|
|
292
|
+
|
|
293
|
+
const duration = Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen));
|
|
294
|
+
logger.log('warn', `[SUMMARY] Rejected ${totalRejections} connections from ${byIP.size} IPs in ${Math.round(duration/1000)}s`, {
|
|
295
|
+
topOffenders,
|
|
296
|
+
component: 'ip-dedup'
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private flushGeneric(aggregated: IAggregatedEvent): void {
|
|
301
|
+
const totalCount = Array.from(aggregated.events.values()).reduce((sum, e) => sum + e.count, 0);
|
|
302
|
+
const level = aggregated.events.values().next().value?.level || 'info';
|
|
303
|
+
|
|
304
|
+
// Special handling for IP cleanup events
|
|
305
|
+
if (aggregated.key === 'ip-cleanup') {
|
|
306
|
+
const totalCleaned = Array.from(aggregated.events.values()).reduce((sum, e) => {
|
|
307
|
+
return sum + (e.data?.cleanedIPs || 0) + (e.data?.cleanedRateLimits || 0);
|
|
308
|
+
}, 0);
|
|
309
|
+
|
|
310
|
+
if (totalCleaned > 0) {
|
|
311
|
+
logger.log(level as any, `IP tracking cleanup: removed ${totalCleaned} entries across ${totalCount} cleanup cycles`, {
|
|
312
|
+
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
|
|
313
|
+
component: 'log-dedup'
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
logger.log(level as any, `${aggregated.key}: ${totalCount} events`, {
|
|
318
|
+
uniqueEvents: aggregated.events.size,
|
|
319
|
+
duration: Date.now() - Math.min(...Array.from(aggregated.events.values()).map(e => e.firstSeen)),
|
|
320
|
+
component: 'log-dedup'
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Cleanup and stop deduplication
|
|
327
|
+
*/
|
|
328
|
+
public cleanup(): void {
|
|
329
|
+
this.flushAll();
|
|
330
|
+
|
|
331
|
+
if (this.globalFlushTimer) {
|
|
332
|
+
clearInterval(this.globalFlushTimer);
|
|
333
|
+
this.globalFlushTimer = undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
for (const aggregated of this.aggregatedEvents.values()) {
|
|
337
|
+
if (aggregated.flushTimer) {
|
|
338
|
+
clearTimeout(aggregated.flushTimer);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
this.aggregatedEvents.clear();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Global instance for connection-related log deduplication
|
|
346
|
+
export const connectionLogDeduplicator = new LogDeduplicator(5000); // 5 second batches
|
|
347
|
+
|
|
348
|
+
// Ensure logs are flushed on process exit
|
|
349
|
+
process.on('beforeExit', () => {
|
|
350
|
+
connectionLogDeduplicator.flushAll();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
process.on('SIGINT', () => {
|
|
354
|
+
connectionLogDeduplicator.cleanup();
|
|
355
|
+
process.exit(0);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
process.on('SIGTERM', () => {
|
|
359
|
+
connectionLogDeduplicator.cleanup();
|
|
360
|
+
process.exit(0);
|
|
361
|
+
});
|
|
@@ -152,9 +152,10 @@ export class SharedSecurityManager {
|
|
|
152
152
|
*
|
|
153
153
|
* @param route - The route to check
|
|
154
154
|
* @param context - The request context
|
|
155
|
+
* @param routeConnectionCount - Current connection count for this route (optional)
|
|
155
156
|
* @returns Whether access is allowed
|
|
156
157
|
*/
|
|
157
|
-
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
|
|
158
|
+
public isAllowed(route: IRouteConfig, context: IRouteContext, routeConnectionCount?: number): boolean {
|
|
158
159
|
if (!route.security) {
|
|
159
160
|
return true; // No security restrictions
|
|
160
161
|
}
|
|
@@ -165,6 +166,14 @@ export class SharedSecurityManager {
|
|
|
165
166
|
return false;
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
// --- Route-level connection limit ---
|
|
170
|
+
if (route.security.maxConnections !== undefined && routeConnectionCount !== undefined) {
|
|
171
|
+
if (routeConnectionCount >= route.security.maxConnections) {
|
|
172
|
+
this.logger?.debug?.(`Route connection limit (${route.security.maxConnections}) exceeded for route ${route.name || 'unnamed'}`);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
168
177
|
// --- Rate limiting ---
|
|
169
178
|
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
|
170
179
|
this.logger?.debug?.(`Rate limit exceeded for route ${route.name || 'unnamed'}`);
|
|
@@ -304,6 +313,20 @@ export class SharedSecurityManager {
|
|
|
304
313
|
// Clean up rate limits
|
|
305
314
|
cleanupExpiredRateLimits(this.rateLimits, this.logger);
|
|
306
315
|
|
|
316
|
+
// Clean up IP connection tracking
|
|
317
|
+
let cleanedIPs = 0;
|
|
318
|
+
for (const [ip, info] of this.connectionsByIP.entries()) {
|
|
319
|
+
// Remove IPs with no active connections and no recent timestamps
|
|
320
|
+
if (info.connections.size === 0 && info.timestamps.length === 0) {
|
|
321
|
+
this.connectionsByIP.delete(ip);
|
|
322
|
+
cleanedIPs++;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (cleanedIPs > 0 && this.logger?.debug) {
|
|
327
|
+
this.logger.debug(`Cleaned up ${cleanedIPs} IPs with no active connections`);
|
|
328
|
+
}
|
|
329
|
+
|
|
307
330
|
// IP filter cache doesn't need cleanup (tied to routes)
|
|
308
331
|
}
|
|
309
332
|
|
|
@@ -17,6 +17,8 @@ import { WebSocketHandler } from './websocket-handler.js';
|
|
|
17
17
|
import { HttpRouter } from '../../routing/router/index.js';
|
|
18
18
|
import { cleanupSocket } from '../../core/utils/socket-utils.js';
|
|
19
19
|
import { FunctionCache } from './function-cache.js';
|
|
20
|
+
import { SecurityManager } from './security-manager.js';
|
|
21
|
+
import { connectionLogDeduplicator } from '../../core/utils/log-deduplicator.js';
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* HttpProxy provides a reverse proxy with TLS termination, WebSocket support,
|
|
@@ -43,6 +45,7 @@ export class HttpProxy implements IMetricsTracker {
|
|
|
43
45
|
private router = new HttpRouter(); // Unified HTTP router
|
|
44
46
|
private routeManager: RouteManager;
|
|
45
47
|
private functionCache: FunctionCache;
|
|
48
|
+
private securityManager: SecurityManager;
|
|
46
49
|
|
|
47
50
|
// State tracking
|
|
48
51
|
public socketMap = new plugins.lik.ObjectMap<plugins.net.Socket>();
|
|
@@ -113,6 +116,14 @@ export class HttpProxy implements IMetricsTracker {
|
|
|
113
116
|
maxCacheSize: this.options.functionCacheSize || 1000,
|
|
114
117
|
defaultTtl: this.options.functionCacheTtl || 5000
|
|
115
118
|
});
|
|
119
|
+
|
|
120
|
+
// Initialize security manager
|
|
121
|
+
this.securityManager = new SecurityManager(
|
|
122
|
+
this.logger,
|
|
123
|
+
[],
|
|
124
|
+
this.options.maxConnectionsPerIP || 100,
|
|
125
|
+
this.options.connectionRateLimitPerMinute || 300
|
|
126
|
+
);
|
|
116
127
|
|
|
117
128
|
// Initialize other components
|
|
118
129
|
this.certificateManager = new CertificateManager(this.options);
|
|
@@ -269,14 +280,113 @@ export class HttpProxy implements IMetricsTracker {
|
|
|
269
280
|
*/
|
|
270
281
|
private setupConnectionTracking(): void {
|
|
271
282
|
this.httpsServer.on('connection', (connection: plugins.net.Socket) => {
|
|
272
|
-
|
|
283
|
+
let remoteIP = connection.remoteAddress || '';
|
|
284
|
+
const connectionId = Math.random().toString(36).substring(2, 15);
|
|
285
|
+
const isFromSmartProxy = this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1');
|
|
286
|
+
|
|
287
|
+
// For SmartProxy connections, wait for CLIENT_IP header
|
|
288
|
+
if (isFromSmartProxy) {
|
|
289
|
+
let headerBuffer = Buffer.alloc(0);
|
|
290
|
+
let headerParsed = false;
|
|
291
|
+
|
|
292
|
+
const parseHeader = (data: Buffer) => {
|
|
293
|
+
if (headerParsed) return data;
|
|
294
|
+
|
|
295
|
+
headerBuffer = Buffer.concat([headerBuffer, data]);
|
|
296
|
+
const headerStr = headerBuffer.toString();
|
|
297
|
+
const headerEnd = headerStr.indexOf('\r\n');
|
|
298
|
+
|
|
299
|
+
if (headerEnd !== -1) {
|
|
300
|
+
const header = headerStr.substring(0, headerEnd);
|
|
301
|
+
if (header.startsWith('CLIENT_IP:')) {
|
|
302
|
+
remoteIP = header.substring(10); // Extract IP after "CLIENT_IP:"
|
|
303
|
+
this.logger.debug(`Extracted client IP from SmartProxy: ${remoteIP}`);
|
|
304
|
+
}
|
|
305
|
+
headerParsed = true;
|
|
306
|
+
|
|
307
|
+
// Store the real IP on the connection
|
|
308
|
+
(connection as any)._realRemoteIP = remoteIP;
|
|
309
|
+
|
|
310
|
+
// Validate the real IP
|
|
311
|
+
const ipValidation = this.securityManager.validateIP(remoteIP);
|
|
312
|
+
if (!ipValidation.allowed) {
|
|
313
|
+
connectionLogDeduplicator.log(
|
|
314
|
+
'ip-rejected',
|
|
315
|
+
'warn',
|
|
316
|
+
`HttpProxy connection rejected (via SmartProxy)`,
|
|
317
|
+
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
|
|
318
|
+
remoteIP
|
|
319
|
+
);
|
|
320
|
+
connection.destroy();
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Track connection by real IP
|
|
325
|
+
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
|
|
326
|
+
|
|
327
|
+
// Return remaining data after header
|
|
328
|
+
return headerBuffer.slice(headerEnd + 2);
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Override the first data handler to parse header
|
|
334
|
+
const originalEmit = connection.emit;
|
|
335
|
+
connection.emit = function(event: string, ...args: any[]) {
|
|
336
|
+
if (event === 'data' && !headerParsed) {
|
|
337
|
+
const remaining = parseHeader(args[0]);
|
|
338
|
+
if (remaining && remaining.length > 0) {
|
|
339
|
+
// Call original emit with remaining data
|
|
340
|
+
return originalEmit.apply(connection, ['data', remaining]);
|
|
341
|
+
} else if (headerParsed) {
|
|
342
|
+
// Header parsed but no remaining data
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
// Header not complete yet, suppress this data event
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
return originalEmit.apply(connection, [event, ...args]);
|
|
349
|
+
} as any;
|
|
350
|
+
} else {
|
|
351
|
+
// Direct connection - validate immediately
|
|
352
|
+
const ipValidation = this.securityManager.validateIP(remoteIP);
|
|
353
|
+
if (!ipValidation.allowed) {
|
|
354
|
+
connectionLogDeduplicator.log(
|
|
355
|
+
'ip-rejected',
|
|
356
|
+
'warn',
|
|
357
|
+
`HttpProxy connection rejected`,
|
|
358
|
+
{ remoteIP, reason: ipValidation.reason, component: 'http-proxy' },
|
|
359
|
+
remoteIP
|
|
360
|
+
);
|
|
361
|
+
connection.destroy();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Track connection by IP
|
|
366
|
+
this.securityManager.trackConnectionByIP(remoteIP, connectionId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Then check global max connections
|
|
273
370
|
if (this.socketMap.getArray().length >= this.options.maxConnections) {
|
|
274
|
-
|
|
371
|
+
connectionLogDeduplicator.log(
|
|
372
|
+
'connection-rejected',
|
|
373
|
+
'warn',
|
|
374
|
+
'HttpProxy max connections reached',
|
|
375
|
+
{
|
|
376
|
+
reason: 'global-limit',
|
|
377
|
+
currentConnections: this.socketMap.getArray().length,
|
|
378
|
+
maxConnections: this.options.maxConnections,
|
|
379
|
+
component: 'http-proxy'
|
|
380
|
+
},
|
|
381
|
+
'http-proxy-global-limit'
|
|
382
|
+
);
|
|
275
383
|
connection.destroy();
|
|
276
384
|
return;
|
|
277
385
|
}
|
|
278
|
-
|
|
279
|
-
// Add connection to tracking
|
|
386
|
+
|
|
387
|
+
// Add connection to tracking with metadata
|
|
388
|
+
(connection as any)._connectionId = connectionId;
|
|
389
|
+
(connection as any)._remoteIP = remoteIP;
|
|
280
390
|
this.socketMap.add(connection);
|
|
281
391
|
this.connectedClients = this.socketMap.getArray().length;
|
|
282
392
|
|
|
@@ -284,12 +394,12 @@ export class HttpProxy implements IMetricsTracker {
|
|
|
284
394
|
const localPort = connection.localPort || 0;
|
|
285
395
|
const remotePort = connection.remotePort || 0;
|
|
286
396
|
|
|
287
|
-
// If this connection is from a SmartProxy
|
|
288
|
-
if (
|
|
397
|
+
// If this connection is from a SmartProxy
|
|
398
|
+
if (isFromSmartProxy) {
|
|
289
399
|
this.portProxyConnections++;
|
|
290
|
-
this.logger.debug(`New connection from SmartProxy (local: ${localPort}, remote: ${remotePort})`);
|
|
400
|
+
this.logger.debug(`New connection from SmartProxy for client ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
|
|
291
401
|
} else {
|
|
292
|
-
this.logger.debug(`New direct connection (local: ${localPort}, remote: ${remotePort})`);
|
|
402
|
+
this.logger.debug(`New direct connection from ${remoteIP} (local: ${localPort}, remote: ${remotePort})`);
|
|
293
403
|
}
|
|
294
404
|
|
|
295
405
|
// Setup connection cleanup handlers
|
|
@@ -298,12 +408,19 @@ export class HttpProxy implements IMetricsTracker {
|
|
|
298
408
|
this.socketMap.remove(connection);
|
|
299
409
|
this.connectedClients = this.socketMap.getArray().length;
|
|
300
410
|
|
|
411
|
+
// Remove IP tracking
|
|
412
|
+
const connId = (connection as any)._connectionId;
|
|
413
|
+
const connIP = (connection as any)._realRemoteIP || (connection as any)._remoteIP;
|
|
414
|
+
if (connId && connIP) {
|
|
415
|
+
this.securityManager.removeConnectionByIP(connIP, connId);
|
|
416
|
+
}
|
|
417
|
+
|
|
301
418
|
// If this was a SmartProxy connection, decrement the counter
|
|
302
419
|
if (this.options.portProxyIntegration && connection.remoteAddress?.includes('127.0.0.1')) {
|
|
303
420
|
this.portProxyConnections--;
|
|
304
421
|
}
|
|
305
422
|
|
|
306
|
-
this.logger.debug(`Connection closed. ${this.connectedClients} connections remaining`);
|
|
423
|
+
this.logger.debug(`Connection closed from ${connIP || 'unknown'}. ${this.connectedClients} connections remaining`);
|
|
307
424
|
}
|
|
308
425
|
};
|
|
309
426
|
|
|
@@ -480,6 +597,9 @@ export class HttpProxy implements IMetricsTracker {
|
|
|
480
597
|
|
|
481
598
|
// Certificate management cleanup is handled by SmartCertManager
|
|
482
599
|
|
|
600
|
+
// Flush any pending deduplicated logs
|
|
601
|
+
connectionLogDeduplicator.flushAll();
|
|
602
|
+
|
|
483
603
|
// Close the HTTPS server
|
|
484
604
|
return new Promise((resolve) => {
|
|
485
605
|
this.httpsServer.close(() => {
|
|
@@ -45,6 +45,10 @@ export interface IHttpProxyOptions {
|
|
|
45
45
|
|
|
46
46
|
// Direct route configurations
|
|
47
47
|
routes?: IRouteConfig[];
|
|
48
|
+
|
|
49
|
+
// Rate limiting and security
|
|
50
|
+
maxConnectionsPerIP?: number; // Maximum simultaneous connections from a single IP
|
|
51
|
+
connectionRateLimitPerMinute?: number; // Max new connections per minute from a single IP
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/**
|