@push.rocks/smartproxy 16.0.2 → 16.0.4
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/00_commitinfo_data.js +1 -1
- package/dist_ts/core/models/index.d.ts +2 -0
- package/dist_ts/core/models/index.js +3 -1
- package/dist_ts/core/models/route-context.d.ts +62 -0
- package/dist_ts/core/models/route-context.js +43 -0
- package/dist_ts/core/models/socket-augmentation.d.ts +12 -0
- package/dist_ts/core/models/socket-augmentation.js +18 -0
- package/dist_ts/core/utils/event-system.d.ts +200 -0
- package/dist_ts/core/utils/event-system.js +224 -0
- package/dist_ts/core/utils/index.d.ts +7 -0
- package/dist_ts/core/utils/index.js +8 -1
- package/dist_ts/core/utils/route-manager.d.ts +118 -0
- package/dist_ts/core/utils/route-manager.js +383 -0
- package/dist_ts/core/utils/route-utils.d.ts +94 -0
- package/dist_ts/core/utils/route-utils.js +264 -0
- package/dist_ts/core/utils/security-utils.d.ts +111 -0
- package/dist_ts/core/utils/security-utils.js +212 -0
- package/dist_ts/core/utils/shared-security-manager.d.ts +110 -0
- package/dist_ts/core/utils/shared-security-manager.js +252 -0
- package/dist_ts/core/utils/template-utils.d.ts +37 -0
- package/dist_ts/core/utils/template-utils.js +104 -0
- package/dist_ts/core/utils/websocket-utils.d.ts +23 -0
- package/dist_ts/core/utils/websocket-utils.js +86 -0
- package/dist_ts/http/router/index.d.ts +5 -1
- package/dist_ts/http/router/index.js +4 -2
- package/dist_ts/http/router/route-router.d.ts +108 -0
- package/dist_ts/http/router/route-router.js +393 -0
- package/dist_ts/index.d.ts +8 -2
- package/dist_ts/index.js +10 -3
- package/dist_ts/proxies/index.d.ts +7 -2
- package/dist_ts/proxies/index.js +10 -4
- package/dist_ts/proxies/network-proxy/certificate-manager.d.ts +21 -0
- package/dist_ts/proxies/network-proxy/certificate-manager.js +92 -1
- package/dist_ts/proxies/network-proxy/context-creator.d.ts +34 -0
- package/dist_ts/proxies/network-proxy/context-creator.js +108 -0
- package/dist_ts/proxies/network-proxy/function-cache.d.ts +90 -0
- package/dist_ts/proxies/network-proxy/function-cache.js +198 -0
- package/dist_ts/proxies/network-proxy/http-request-handler.d.ts +40 -0
- package/dist_ts/proxies/network-proxy/http-request-handler.js +256 -0
- package/dist_ts/proxies/network-proxy/http2-request-handler.d.ts +24 -0
- package/dist_ts/proxies/network-proxy/http2-request-handler.js +201 -0
- package/dist_ts/proxies/network-proxy/models/types.d.ts +73 -1
- package/dist_ts/proxies/network-proxy/models/types.js +242 -1
- package/dist_ts/proxies/network-proxy/network-proxy.d.ts +23 -20
- package/dist_ts/proxies/network-proxy/network-proxy.js +149 -60
- package/dist_ts/proxies/network-proxy/request-handler.d.ts +38 -5
- package/dist_ts/proxies/network-proxy/request-handler.js +584 -198
- package/dist_ts/proxies/network-proxy/security-manager.d.ts +65 -0
- package/dist_ts/proxies/network-proxy/security-manager.js +255 -0
- package/dist_ts/proxies/network-proxy/websocket-handler.d.ts +13 -2
- package/dist_ts/proxies/network-proxy/websocket-handler.js +238 -20
- package/dist_ts/proxies/smart-proxy/index.d.ts +1 -1
- package/dist_ts/proxies/smart-proxy/index.js +3 -3
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +3 -5
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +56 -4
- package/dist_ts/proxies/smart-proxy/network-proxy-bridge.d.ts +4 -57
- package/dist_ts/proxies/smart-proxy/network-proxy-bridge.js +19 -228
- package/dist_ts/proxies/smart-proxy/port-manager.d.ts +81 -0
- package/dist_ts/proxies/smart-proxy/port-manager.js +166 -0
- package/dist_ts/proxies/smart-proxy/route-connection-handler.d.ts +5 -0
- package/dist_ts/proxies/smart-proxy/route-connection-handler.js +131 -15
- package/dist_ts/proxies/smart-proxy/route-helpers/index.d.ts +3 -1
- package/dist_ts/proxies/smart-proxy/route-helpers/index.js +5 -3
- package/dist_ts/proxies/smart-proxy/route-helpers.d.ts +5 -178
- package/dist_ts/proxies/smart-proxy/route-helpers.js +8 -296
- package/dist_ts/proxies/smart-proxy/route-manager.d.ts +11 -2
- package/dist_ts/proxies/smart-proxy/route-manager.js +79 -10
- package/dist_ts/proxies/smart-proxy/smart-proxy.d.ts +29 -2
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +48 -43
- package/dist_ts/proxies/smart-proxy/utils/route-helpers.d.ts +67 -1
- package/dist_ts/proxies/smart-proxy/utils/route-helpers.js +120 -1
- package/dist_ts/proxies/smart-proxy/utils/route-validators.d.ts +3 -3
- package/dist_ts/proxies/smart-proxy/utils/route-validators.js +27 -5
- package/package.json +1 -1
- package/readme.md +102 -14
- package/readme.plan.md +103 -168
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/models/index.ts +2 -0
- package/ts/core/models/route-context.ts +113 -0
- package/ts/core/models/socket-augmentation.ts +33 -0
- package/ts/core/utils/event-system.ts +376 -0
- package/ts/core/utils/index.ts +7 -0
- package/ts/core/utils/route-manager.ts +489 -0
- package/ts/core/utils/route-utils.ts +312 -0
- package/ts/core/utils/security-utils.ts +309 -0
- package/ts/core/utils/shared-security-manager.ts +333 -0
- package/ts/core/utils/template-utils.ts +124 -0
- package/ts/core/utils/websocket-utils.ts +81 -0
- package/ts/http/router/index.ts +8 -1
- package/ts/http/router/route-router.ts +482 -0
- package/ts/index.ts +14 -2
- package/ts/proxies/index.ts +12 -3
- package/ts/proxies/network-proxy/certificate-manager.ts +114 -10
- package/ts/proxies/network-proxy/context-creator.ts +145 -0
- package/ts/proxies/network-proxy/function-cache.ts +259 -0
- package/ts/proxies/network-proxy/http-request-handler.ts +330 -0
- package/ts/proxies/network-proxy/http2-request-handler.ts +255 -0
- package/ts/proxies/network-proxy/models/types.ts +312 -1
- package/ts/proxies/network-proxy/network-proxy.ts +197 -85
- package/ts/proxies/network-proxy/request-handler.ts +698 -246
- package/ts/proxies/network-proxy/security-manager.ts +298 -0
- package/ts/proxies/network-proxy/websocket-handler.ts +276 -33
- package/ts/proxies/smart-proxy/index.ts +2 -12
- package/ts/proxies/smart-proxy/models/interfaces.ts +7 -4
- package/ts/proxies/smart-proxy/models/route-types.ts +77 -10
- package/ts/proxies/smart-proxy/network-proxy-bridge.ts +20 -257
- package/ts/proxies/smart-proxy/port-manager.ts +195 -0
- package/ts/proxies/smart-proxy/route-connection-handler.ts +156 -21
- package/ts/proxies/smart-proxy/route-manager.ts +98 -14
- package/ts/proxies/smart-proxy/smart-proxy.ts +56 -55
- package/ts/proxies/smart-proxy/utils/route-helpers.ts +167 -1
- package/ts/proxies/smart-proxy/utils/route-validators.ts +24 -5
- package/ts/proxies/smart-proxy/domain-config-manager.ts.bak +0 -441
- package/ts/proxies/smart-proxy/route-helpers/index.ts +0 -9
- package/ts/proxies/smart-proxy/route-helpers.ts +0 -498
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type { ILogger } from './models/types.js';
|
|
3
|
+
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
|
4
|
+
import type { IRouteContext } from '../../core/models/route-context.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Manages security features for the NetworkProxy
|
|
8
|
+
* Implements Phase 5.4: Security features like IP filtering and rate limiting
|
|
9
|
+
*/
|
|
10
|
+
export class SecurityManager {
|
|
11
|
+
// Cache IP filtering results to avoid constant regex matching
|
|
12
|
+
private ipFilterCache: Map<string, Map<string, boolean>> = new Map();
|
|
13
|
+
|
|
14
|
+
// Store rate limits per route and key
|
|
15
|
+
private rateLimits: Map<string, Map<string, { count: number, expiry: number }>> = new Map();
|
|
16
|
+
|
|
17
|
+
constructor(private logger: ILogger, private routes: IRouteConfig[] = []) {}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Update the routes configuration
|
|
21
|
+
*/
|
|
22
|
+
public setRoutes(routes: IRouteConfig[]): void {
|
|
23
|
+
this.routes = routes;
|
|
24
|
+
// Reset caches when routes change
|
|
25
|
+
this.ipFilterCache.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a client is allowed to access a specific route
|
|
30
|
+
*
|
|
31
|
+
* @param route The route to check access for
|
|
32
|
+
* @param context The route context with client information
|
|
33
|
+
* @returns True if access is allowed, false otherwise
|
|
34
|
+
*/
|
|
35
|
+
public isAllowed(route: IRouteConfig, context: IRouteContext): boolean {
|
|
36
|
+
if (!route.security) {
|
|
37
|
+
return true; // No security restrictions
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- IP filtering ---
|
|
41
|
+
if (!this.isIpAllowed(route, context.clientIp)) {
|
|
42
|
+
this.logger.debug(`IP ${context.clientIp} is blocked for route ${route.name || route.id || 'unnamed'}`);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- Rate limiting ---
|
|
47
|
+
if (route.security.rateLimit?.enabled && !this.isWithinRateLimit(route, context)) {
|
|
48
|
+
this.logger.debug(`Rate limit exceeded for route ${route.name || route.id || 'unnamed'}`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- Basic Auth (handled at HTTP level) ---
|
|
53
|
+
// Basic auth is not checked here as it requires HTTP headers
|
|
54
|
+
// and is handled in the RequestHandler
|
|
55
|
+
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if an IP is allowed based on route security settings
|
|
61
|
+
*/
|
|
62
|
+
private isIpAllowed(route: IRouteConfig, clientIp: string): boolean {
|
|
63
|
+
if (!route.security) {
|
|
64
|
+
return true; // No security restrictions
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const routeId = route.id || route.name || 'unnamed';
|
|
68
|
+
|
|
69
|
+
// Check cache first
|
|
70
|
+
if (!this.ipFilterCache.has(routeId)) {
|
|
71
|
+
this.ipFilterCache.set(routeId, new Map());
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const routeCache = this.ipFilterCache.get(routeId)!;
|
|
75
|
+
if (routeCache.has(clientIp)) {
|
|
76
|
+
return routeCache.get(clientIp)!;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let allowed = true;
|
|
80
|
+
|
|
81
|
+
// Check block list first (deny has priority over allow)
|
|
82
|
+
if (route.security.ipBlockList && route.security.ipBlockList.length > 0) {
|
|
83
|
+
if (this.ipMatchesPattern(clientIp, route.security.ipBlockList)) {
|
|
84
|
+
allowed = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Then check allow list (overrides block list if specified)
|
|
89
|
+
if (route.security.ipAllowList && route.security.ipAllowList.length > 0) {
|
|
90
|
+
// If allow list is specified, IP must match an entry to be allowed
|
|
91
|
+
allowed = this.ipMatchesPattern(clientIp, route.security.ipAllowList);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cache the result
|
|
95
|
+
routeCache.set(clientIp, allowed);
|
|
96
|
+
|
|
97
|
+
return allowed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if IP matches any pattern in the list
|
|
102
|
+
*/
|
|
103
|
+
private ipMatchesPattern(ip: string, patterns: string[]): boolean {
|
|
104
|
+
for (const pattern of patterns) {
|
|
105
|
+
// CIDR notation
|
|
106
|
+
if (pattern.includes('/')) {
|
|
107
|
+
if (this.ipMatchesCidr(ip, pattern)) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Wildcard notation
|
|
112
|
+
else if (pattern.includes('*')) {
|
|
113
|
+
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
114
|
+
if (regex.test(ip)) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Exact match
|
|
119
|
+
else if (pattern === ip) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if IP matches CIDR notation
|
|
128
|
+
* Very basic implementation - for production use, consider a dedicated IP library
|
|
129
|
+
*/
|
|
130
|
+
private ipMatchesCidr(ip: string, cidr: string): boolean {
|
|
131
|
+
try {
|
|
132
|
+
const [subnet, bits] = cidr.split('/');
|
|
133
|
+
const mask = parseInt(bits, 10);
|
|
134
|
+
|
|
135
|
+
// Convert IP to numeric format
|
|
136
|
+
const ipParts = ip.split('.').map(part => parseInt(part, 10));
|
|
137
|
+
const subnetParts = subnet.split('.').map(part => parseInt(part, 10));
|
|
138
|
+
|
|
139
|
+
// Calculate the numeric IP and subnet
|
|
140
|
+
const ipNum = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
|
|
141
|
+
const subnetNum = (subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
|
|
142
|
+
|
|
143
|
+
// Calculate the mask
|
|
144
|
+
const maskNum = ~((1 << (32 - mask)) - 1);
|
|
145
|
+
|
|
146
|
+
// Check if IP is in subnet
|
|
147
|
+
return (ipNum & maskNum) === (subnetNum & maskNum);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
this.logger.error(`Invalid CIDR notation: ${cidr}`);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if request is within rate limit
|
|
156
|
+
*/
|
|
157
|
+
private isWithinRateLimit(route: IRouteConfig, context: IRouteContext): boolean {
|
|
158
|
+
if (!route.security?.rateLimit?.enabled) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rateLimit = route.security.rateLimit;
|
|
163
|
+
const routeId = route.id || route.name || 'unnamed';
|
|
164
|
+
|
|
165
|
+
// Determine rate limit key (by IP, path, or header)
|
|
166
|
+
let key = context.clientIp; // Default to IP
|
|
167
|
+
|
|
168
|
+
if (rateLimit.keyBy === 'path' && context.path) {
|
|
169
|
+
key = `${context.clientIp}:${context.path}`;
|
|
170
|
+
} else if (rateLimit.keyBy === 'header' && rateLimit.headerName && context.headers) {
|
|
171
|
+
const headerValue = context.headers[rateLimit.headerName.toLowerCase()];
|
|
172
|
+
if (headerValue) {
|
|
173
|
+
key = `${context.clientIp}:${headerValue}`;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Get or create rate limit tracking for this route
|
|
178
|
+
if (!this.rateLimits.has(routeId)) {
|
|
179
|
+
this.rateLimits.set(routeId, new Map());
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const routeLimits = this.rateLimits.get(routeId)!;
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
|
|
185
|
+
// Get or create rate limit tracking for this key
|
|
186
|
+
let limit = routeLimits.get(key);
|
|
187
|
+
if (!limit || limit.expiry < now) {
|
|
188
|
+
// Create new rate limit or reset expired one
|
|
189
|
+
limit = {
|
|
190
|
+
count: 1,
|
|
191
|
+
expiry: now + (rateLimit.window * 1000)
|
|
192
|
+
};
|
|
193
|
+
routeLimits.set(key, limit);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Increment the counter
|
|
198
|
+
limit.count++;
|
|
199
|
+
|
|
200
|
+
// Check if rate limit is exceeded
|
|
201
|
+
return limit.count <= rateLimit.maxRequests;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Clean up expired rate limits
|
|
206
|
+
* Should be called periodically to prevent memory leaks
|
|
207
|
+
*/
|
|
208
|
+
public cleanupExpiredRateLimits(): void {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
for (const [routeId, routeLimits] of this.rateLimits.entries()) {
|
|
211
|
+
let removed = 0;
|
|
212
|
+
for (const [key, limit] of routeLimits.entries()) {
|
|
213
|
+
if (limit.expiry < now) {
|
|
214
|
+
routeLimits.delete(key);
|
|
215
|
+
removed++;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (removed > 0) {
|
|
219
|
+
this.logger.debug(`Cleaned up ${removed} expired rate limits for route ${routeId}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check basic auth credentials
|
|
226
|
+
*
|
|
227
|
+
* @param route The route to check auth for
|
|
228
|
+
* @param username The provided username
|
|
229
|
+
* @param password The provided password
|
|
230
|
+
* @returns True if credentials are valid, false otherwise
|
|
231
|
+
*/
|
|
232
|
+
public checkBasicAuth(route: IRouteConfig, username: string, password: string): boolean {
|
|
233
|
+
if (!route.security?.basicAuth?.enabled) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const basicAuth = route.security.basicAuth;
|
|
238
|
+
|
|
239
|
+
// Check credentials against configured users
|
|
240
|
+
for (const user of basicAuth.users) {
|
|
241
|
+
if (user.username === username && user.password === password) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Verify a JWT token
|
|
251
|
+
*
|
|
252
|
+
* @param route The route to verify the token for
|
|
253
|
+
* @param token The JWT token to verify
|
|
254
|
+
* @returns True if the token is valid, false otherwise
|
|
255
|
+
*/
|
|
256
|
+
public verifyJwtToken(route: IRouteConfig, token: string): boolean {
|
|
257
|
+
if (!route.security?.jwtAuth?.enabled) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// This is a simplified version - in production you'd use a proper JWT library
|
|
263
|
+
const jwtAuth = route.security.jwtAuth;
|
|
264
|
+
|
|
265
|
+
// Verify structure
|
|
266
|
+
const parts = token.split('.');
|
|
267
|
+
if (parts.length !== 3) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Decode payload
|
|
272
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
|
273
|
+
|
|
274
|
+
// Check expiration
|
|
275
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Check issuer
|
|
280
|
+
if (jwtAuth.issuer && payload.iss !== jwtAuth.issuer) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Check audience
|
|
285
|
+
if (jwtAuth.audience && payload.aud !== jwtAuth.audience) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// In a real implementation, you'd also verify the signature
|
|
290
|
+
// using the secret and algorithm specified in jwtAuth
|
|
291
|
+
|
|
292
|
+
return true;
|
|
293
|
+
} catch (err) {
|
|
294
|
+
this.logger.error(`Error verifying JWT: ${err}`);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
+
import '../../core/models/socket-augmentation.js';
|
|
2
3
|
import { type INetworkProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger, type IReverseProxyConfig } from './models/types.js';
|
|
3
4
|
import { ConnectionPool } from './connection-pool.js';
|
|
4
|
-
import { ProxyRouter } from '../../http/router/index.js';
|
|
5
|
+
import { ProxyRouter, RouteRouter } from '../../http/router/index.js';
|
|
6
|
+
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
|
7
|
+
import type { IRouteContext } from '../../core/models/route-context.js';
|
|
8
|
+
import { toBaseContext } from '../../core/models/route-context.js';
|
|
9
|
+
import { ContextCreator } from './context-creator.js';
|
|
10
|
+
import { SecurityManager } from './security-manager.js';
|
|
11
|
+
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
|
12
|
+
import { getMessageSize, toBuffer } from '../../core/utils/websocket-utils.js';
|
|
5
13
|
|
|
6
14
|
/**
|
|
7
15
|
* Handles WebSocket connections and proxying
|
|
@@ -10,13 +18,40 @@ export class WebSocketHandler {
|
|
|
10
18
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
|
11
19
|
private wsServer: plugins.ws.WebSocketServer | null = null;
|
|
12
20
|
private logger: ILogger;
|
|
21
|
+
private contextCreator: ContextCreator = new ContextCreator();
|
|
22
|
+
private routeRouter: RouteRouter | null = null;
|
|
23
|
+
private securityManager: SecurityManager;
|
|
13
24
|
|
|
14
25
|
constructor(
|
|
15
26
|
private options: INetworkProxyOptions,
|
|
16
27
|
private connectionPool: ConnectionPool,
|
|
17
|
-
private
|
|
28
|
+
private legacyRouter: ProxyRouter, // Legacy router for backward compatibility
|
|
29
|
+
private routes: IRouteConfig[] = [] // Routes for modern router
|
|
18
30
|
) {
|
|
19
31
|
this.logger = createLogger(options.logLevel || 'info');
|
|
32
|
+
this.securityManager = new SecurityManager(this.logger, routes);
|
|
33
|
+
|
|
34
|
+
// Initialize modern router if we have routes
|
|
35
|
+
if (routes.length > 0) {
|
|
36
|
+
this.routeRouter = new RouteRouter(routes, this.logger);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set the route configurations
|
|
42
|
+
*/
|
|
43
|
+
public setRoutes(routes: IRouteConfig[]): void {
|
|
44
|
+
this.routes = routes;
|
|
45
|
+
|
|
46
|
+
// Initialize or update the route router
|
|
47
|
+
if (!this.routeRouter) {
|
|
48
|
+
this.routeRouter = new RouteRouter(routes, this.logger);
|
|
49
|
+
} else {
|
|
50
|
+
this.routeRouter.setRoutes(routes);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Update the security manager
|
|
54
|
+
this.securityManager.setRoutes(routes);
|
|
20
55
|
}
|
|
21
56
|
|
|
22
57
|
/**
|
|
@@ -91,51 +126,200 @@ export class WebSocketHandler {
|
|
|
91
126
|
wsIncoming.lastPong = Date.now();
|
|
92
127
|
});
|
|
93
128
|
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
// Create a context for routing
|
|
130
|
+
const connectionId = `ws-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
131
|
+
const routeContext = this.contextCreator.createHttpRouteContext(req, {
|
|
132
|
+
connectionId,
|
|
133
|
+
clientIp: req.socket.remoteAddress?.replace('::ffff:', '') || '0.0.0.0',
|
|
134
|
+
serverIp: req.socket.localAddress?.replace('::ffff:', '') || '0.0.0.0',
|
|
135
|
+
tlsVersion: req.socket.getTLSVersion?.() || undefined
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Try modern router first if available
|
|
139
|
+
let route: IRouteConfig | undefined;
|
|
140
|
+
if (this.routeRouter) {
|
|
141
|
+
route = this.routeRouter.routeReq(req);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Define destination variables
|
|
145
|
+
let destination: { host: string; port: number };
|
|
146
|
+
|
|
147
|
+
// If we found a route with the modern router, use it
|
|
148
|
+
if (route && route.action.type === 'forward' && route.action.target) {
|
|
149
|
+
this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`);
|
|
150
|
+
|
|
151
|
+
// Check if WebSockets are enabled for this route
|
|
152
|
+
if (route.action.websocket?.enabled === false) {
|
|
153
|
+
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
|
|
154
|
+
wsIncoming.close(1003, 'WebSockets not supported for this route');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check security restrictions if configured to authenticate WebSocket requests
|
|
159
|
+
if (route.action.websocket?.authenticateRequest !== false && route.security) {
|
|
160
|
+
if (!this.securityManager.isAllowed(route, toBaseContext(routeContext))) {
|
|
161
|
+
this.logger.warn(`WebSocket connection denied by security policy for ${routeContext.clientIp}`);
|
|
162
|
+
wsIncoming.close(1008, 'Access denied by security policy');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Check origin restrictions if configured
|
|
167
|
+
const origin = req.headers.origin;
|
|
168
|
+
if (origin && route.action.websocket?.allowedOrigins && route.action.websocket.allowedOrigins.length > 0) {
|
|
169
|
+
const isAllowed = route.action.websocket.allowedOrigins.some(allowedOrigin => {
|
|
170
|
+
// Handle wildcards and template variables
|
|
171
|
+
if (allowedOrigin.includes('*') || allowedOrigin.includes('{')) {
|
|
172
|
+
const pattern = allowedOrigin.replace(/\*/g, '.*');
|
|
173
|
+
const resolvedPattern = TemplateUtils.resolveTemplateVariables(pattern, routeContext);
|
|
174
|
+
const regex = new RegExp(`^${resolvedPattern}$`);
|
|
175
|
+
return regex.test(origin);
|
|
176
|
+
}
|
|
177
|
+
return allowedOrigin === origin;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (!isAllowed) {
|
|
181
|
+
this.logger.warn(`WebSocket origin ${origin} not allowed for route: ${route.name || 'unnamed'}`);
|
|
182
|
+
wsIncoming.close(1008, 'Origin not allowed');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Extract target information, resolving functions if needed
|
|
189
|
+
let targetHost: string | string[];
|
|
190
|
+
let targetPort: number;
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// Resolve host if it's a function
|
|
194
|
+
if (typeof route.action.target.host === 'function') {
|
|
195
|
+
const resolvedHost = route.action.target.host(toBaseContext(routeContext));
|
|
196
|
+
targetHost = resolvedHost;
|
|
197
|
+
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
198
|
+
} else {
|
|
199
|
+
targetHost = route.action.target.host;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Resolve port if it's a function
|
|
203
|
+
if (typeof route.action.target.port === 'function') {
|
|
204
|
+
targetPort = route.action.target.port(toBaseContext(routeContext));
|
|
205
|
+
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
|
|
206
|
+
} else {
|
|
207
|
+
targetPort = route.action.target.port === 'preserve' ? routeContext.port : route.action.target.port as number;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Select a single host if an array was provided
|
|
211
|
+
const selectedHost = Array.isArray(targetHost)
|
|
212
|
+
? targetHost[Math.floor(Math.random() * targetHost.length)]
|
|
213
|
+
: targetHost;
|
|
214
|
+
|
|
215
|
+
// Create a destination for the WebSocket connection
|
|
216
|
+
destination = {
|
|
217
|
+
host: selectedHost,
|
|
218
|
+
port: targetPort
|
|
219
|
+
};
|
|
220
|
+
} catch (err) {
|
|
221
|
+
this.logger.error(`Error evaluating function-based target for WebSocket: ${err}`);
|
|
222
|
+
wsIncoming.close(1011, 'Internal server error');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
// Fall back to legacy routing if no matching route found via modern router
|
|
227
|
+
const proxyConfig = this.legacyRouter.routeReq(req);
|
|
228
|
+
|
|
229
|
+
if (!proxyConfig) {
|
|
230
|
+
this.logger.warn(`No proxy configuration for WebSocket host: ${req.headers.host}`);
|
|
231
|
+
wsIncoming.close(1008, 'No proxy configuration for this host');
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Get destination target using round-robin if multiple targets
|
|
236
|
+
destination = this.connectionPool.getNextTarget(
|
|
237
|
+
proxyConfig.destinationIps,
|
|
238
|
+
proxyConfig.destinationPorts[0]
|
|
239
|
+
);
|
|
101
240
|
}
|
|
102
241
|
|
|
103
|
-
//
|
|
104
|
-
const destination = this.connectionPool.getNextTarget(
|
|
105
|
-
proxyConfig.destinationIps,
|
|
106
|
-
proxyConfig.destinationPorts[0]
|
|
107
|
-
);
|
|
108
|
-
|
|
109
|
-
// Build target URL
|
|
242
|
+
// Build target URL with potential path rewriting
|
|
110
243
|
const protocol = (req.socket as any).encrypted ? 'wss' : 'ws';
|
|
111
|
-
|
|
112
|
-
|
|
244
|
+
let targetPath = req.url || '/';
|
|
245
|
+
|
|
246
|
+
// Apply path rewriting if configured
|
|
247
|
+
if (route?.action.websocket?.rewritePath) {
|
|
248
|
+
const originalPath = targetPath;
|
|
249
|
+
targetPath = TemplateUtils.resolveTemplateVariables(
|
|
250
|
+
route.action.websocket.rewritePath,
|
|
251
|
+
{...routeContext, path: targetPath}
|
|
252
|
+
);
|
|
253
|
+
this.logger.debug(`WebSocket path rewritten: ${originalPath} -> ${targetPath}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const targetUrl = `${protocol}://${destination.host}:${destination.port}${targetPath}`;
|
|
257
|
+
|
|
113
258
|
this.logger.debug(`WebSocket connection from ${req.socket.remoteAddress} to ${targetUrl}`);
|
|
114
|
-
|
|
259
|
+
|
|
115
260
|
// Create headers for outgoing WebSocket connection
|
|
116
261
|
const headers: { [key: string]: string } = {};
|
|
117
|
-
|
|
262
|
+
|
|
118
263
|
// Copy relevant headers from incoming request
|
|
119
264
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
120
|
-
if (value && typeof value === 'string' &&
|
|
121
|
-
key.toLowerCase() !== 'connection' &&
|
|
265
|
+
if (value && typeof value === 'string' &&
|
|
266
|
+
key.toLowerCase() !== 'connection' &&
|
|
122
267
|
key.toLowerCase() !== 'upgrade' &&
|
|
123
268
|
key.toLowerCase() !== 'sec-websocket-key' &&
|
|
124
269
|
key.toLowerCase() !== 'sec-websocket-version') {
|
|
125
270
|
headers[key] = value;
|
|
126
271
|
}
|
|
127
272
|
}
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
273
|
+
|
|
274
|
+
// Always rewrite host header for WebSockets for consistency
|
|
275
|
+
headers['host'] = `${destination.host}:${destination.port}`;
|
|
276
|
+
|
|
277
|
+
// Add custom headers from route configuration
|
|
278
|
+
if (route?.action.websocket?.customHeaders) {
|
|
279
|
+
for (const [key, value] of Object.entries(route.action.websocket.customHeaders)) {
|
|
280
|
+
// Skip if header already exists and we're not overriding
|
|
281
|
+
if (headers[key.toLowerCase()] && !value.startsWith('!')) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Handle special delete directive (!delete)
|
|
286
|
+
if (value === '!delete') {
|
|
287
|
+
delete headers[key.toLowerCase()];
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Handle forced override (!value)
|
|
292
|
+
let finalValue: string;
|
|
293
|
+
if (value.startsWith('!') && value !== '!delete') {
|
|
294
|
+
// Keep the ! but resolve any templates in the rest
|
|
295
|
+
const templateValue = value.substring(1);
|
|
296
|
+
finalValue = '!' + TemplateUtils.resolveTemplateVariables(templateValue, routeContext);
|
|
297
|
+
} else {
|
|
298
|
+
// Resolve templates in the entire value
|
|
299
|
+
finalValue = TemplateUtils.resolveTemplateVariables(value, routeContext);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Set the header
|
|
303
|
+
headers[key.toLowerCase()] = finalValue;
|
|
304
|
+
}
|
|
132
305
|
}
|
|
133
306
|
|
|
134
|
-
// Create
|
|
135
|
-
const
|
|
307
|
+
// Create WebSocket connection options
|
|
308
|
+
const wsOptions: any = {
|
|
136
309
|
headers: headers,
|
|
137
310
|
followRedirects: true
|
|
138
|
-
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Add subprotocols if configured
|
|
314
|
+
if (route?.action.websocket?.subprotocols && route.action.websocket.subprotocols.length > 0) {
|
|
315
|
+
wsOptions.protocols = route.action.websocket.subprotocols;
|
|
316
|
+
} else if (req.headers['sec-websocket-protocol']) {
|
|
317
|
+
// Pass through client requested protocols
|
|
318
|
+
wsOptions.protocols = req.headers['sec-websocket-protocol'].split(',').map(p => p.trim());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Create outgoing WebSocket connection
|
|
322
|
+
const wsOutgoing = new plugins.wsDefault(targetUrl, wsOptions);
|
|
139
323
|
|
|
140
324
|
// Handle connection errors
|
|
141
325
|
wsOutgoing.on('error', (err) => {
|
|
@@ -147,35 +331,94 @@ export class WebSocketHandler {
|
|
|
147
331
|
|
|
148
332
|
// Handle outgoing connection open
|
|
149
333
|
wsOutgoing.on('open', () => {
|
|
334
|
+
// Set up custom ping interval if configured
|
|
335
|
+
let pingInterval: NodeJS.Timeout | null = null;
|
|
336
|
+
if (route?.action.websocket?.pingInterval && route.action.websocket.pingInterval > 0) {
|
|
337
|
+
pingInterval = setInterval(() => {
|
|
338
|
+
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
|
339
|
+
wsIncoming.ping();
|
|
340
|
+
this.logger.debug(`Sent WebSocket ping to client for route: ${route.name || 'unnamed'}`);
|
|
341
|
+
}
|
|
342
|
+
}, route.action.websocket.pingInterval);
|
|
343
|
+
|
|
344
|
+
// Don't keep process alive just for pings
|
|
345
|
+
if (pingInterval.unref) pingInterval.unref();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Set up custom ping timeout if configured
|
|
349
|
+
let pingTimeout: NodeJS.Timeout | null = null;
|
|
350
|
+
const pingTimeoutMs = route?.action.websocket?.pingTimeout || 60000; // Default 60s
|
|
351
|
+
|
|
352
|
+
// Define timeout function for cleaner code
|
|
353
|
+
const resetPingTimeout = () => {
|
|
354
|
+
if (pingTimeout) clearTimeout(pingTimeout);
|
|
355
|
+
pingTimeout = setTimeout(() => {
|
|
356
|
+
this.logger.debug(`WebSocket ping timeout for client connection on route: ${route?.name || 'unnamed'}`);
|
|
357
|
+
wsIncoming.terminate();
|
|
358
|
+
}, pingTimeoutMs);
|
|
359
|
+
|
|
360
|
+
// Don't keep process alive just for timeouts
|
|
361
|
+
if (pingTimeout.unref) pingTimeout.unref();
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// Reset timeout on pong
|
|
365
|
+
wsIncoming.on('pong', () => {
|
|
366
|
+
wsIncoming.isAlive = true;
|
|
367
|
+
wsIncoming.lastPong = Date.now();
|
|
368
|
+
resetPingTimeout();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Initial ping timeout
|
|
372
|
+
resetPingTimeout();
|
|
373
|
+
|
|
374
|
+
// Handle potential message size limits
|
|
375
|
+
const maxSize = route?.action.websocket?.maxPayloadSize || 0;
|
|
376
|
+
|
|
150
377
|
// Forward incoming messages to outgoing connection
|
|
151
378
|
wsIncoming.on('message', (data, isBinary) => {
|
|
152
379
|
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
|
380
|
+
// Check message size if limit is set
|
|
381
|
+
const messageSize = getMessageSize(data);
|
|
382
|
+
if (maxSize > 0 && messageSize > maxSize) {
|
|
383
|
+
this.logger.warn(`WebSocket message exceeds max size (${messageSize} > ${maxSize})`);
|
|
384
|
+
wsIncoming.close(1009, 'Message too big');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
153
388
|
wsOutgoing.send(data, { binary: isBinary });
|
|
154
389
|
}
|
|
155
390
|
});
|
|
156
|
-
|
|
391
|
+
|
|
157
392
|
// Forward outgoing messages to incoming connection
|
|
158
393
|
wsOutgoing.on('message', (data, isBinary) => {
|
|
159
394
|
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
|
160
395
|
wsIncoming.send(data, { binary: isBinary });
|
|
161
396
|
}
|
|
162
397
|
});
|
|
163
|
-
|
|
398
|
+
|
|
164
399
|
// Handle closing of connections
|
|
165
400
|
wsIncoming.on('close', (code, reason) => {
|
|
166
401
|
this.logger.debug(`WebSocket client connection closed: ${code} ${reason}`);
|
|
167
402
|
if (wsOutgoing.readyState === wsOutgoing.OPEN) {
|
|
168
403
|
wsOutgoing.close(code, reason);
|
|
169
404
|
}
|
|
405
|
+
|
|
406
|
+
// Clean up timers
|
|
407
|
+
if (pingInterval) clearInterval(pingInterval);
|
|
408
|
+
if (pingTimeout) clearTimeout(pingTimeout);
|
|
170
409
|
});
|
|
171
|
-
|
|
410
|
+
|
|
172
411
|
wsOutgoing.on('close', (code, reason) => {
|
|
173
412
|
this.logger.debug(`WebSocket target connection closed: ${code} ${reason}`);
|
|
174
413
|
if (wsIncoming.readyState === wsIncoming.OPEN) {
|
|
175
414
|
wsIncoming.close(code, reason);
|
|
176
415
|
}
|
|
416
|
+
|
|
417
|
+
// Clean up timers
|
|
418
|
+
if (pingInterval) clearInterval(pingInterval);
|
|
419
|
+
if (pingTimeout) clearTimeout(pingTimeout);
|
|
177
420
|
});
|
|
178
|
-
|
|
421
|
+
|
|
179
422
|
this.logger.debug(`WebSocket connection established: ${req.headers.host} -> ${destination.host}:${destination.port}`);
|
|
180
423
|
});
|
|
181
424
|
|