@push.rocks/smartproxy 19.6.16 → 20.0.0
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/shared-security-manager.js +30 -5
- package/dist_ts/proxies/http-proxy/request-handler.d.ts +4 -0
- package/dist_ts/proxies/http-proxy/request-handler.js +104 -21
- package/dist_ts/proxies/http-proxy/websocket-handler.d.ts +4 -0
- package/dist_ts/proxies/http-proxy/websocket-handler.js +78 -8
- package/dist_ts/proxies/smart-proxy/certificate-manager.d.ts +17 -1
- package/dist_ts/proxies/smart-proxy/certificate-manager.js +84 -10
- package/dist_ts/proxies/smart-proxy/models/interfaces.d.ts +5 -0
- package/dist_ts/proxies/smart-proxy/models/route-types.d.ts +19 -2
- package/dist_ts/proxies/smart-proxy/models/route-types.js +1 -1
- package/dist_ts/proxies/smart-proxy/nftables-manager.js +14 -11
- package/dist_ts/proxies/smart-proxy/route-connection-handler.d.ts +4 -0
- package/dist_ts/proxies/smart-proxy/route-connection-handler.js +112 -28
- package/dist_ts/proxies/smart-proxy/smart-proxy.js +9 -1
- package/dist_ts/proxies/smart-proxy/utils/route-helpers.js +23 -23
- package/dist_ts/proxies/smart-proxy/utils/route-patterns.js +13 -13
- package/dist_ts/proxies/smart-proxy/utils/route-utils.js +4 -7
- package/dist_ts/proxies/smart-proxy/utils/route-validators.js +41 -25
- package/package.json +1 -1
- package/readme.hints.md +51 -1
- package/readme.md +105 -2
- package/readme.plan.md +154 -53
- package/ts/core/utils/shared-security-manager.ts +33 -4
- package/ts/proxies/http-proxy/request-handler.ts +124 -21
- package/ts/proxies/http-proxy/websocket-handler.ts +96 -8
- package/ts/proxies/smart-proxy/certificate-manager.ts +98 -13
- package/ts/proxies/smart-proxy/models/interfaces.ts +6 -0
- package/ts/proxies/smart-proxy/models/route-types.ts +34 -8
- package/ts/proxies/smart-proxy/nftables-manager.ts +14 -10
- package/ts/proxies/smart-proxy/route-connection-handler.ts +132 -28
- package/ts/proxies/smart-proxy/smart-proxy.ts +10 -0
- package/ts/proxies/smart-proxy/utils/route-helpers.ts +14 -14
- package/ts/proxies/smart-proxy/utils/route-patterns.ts +6 -6
- package/ts/proxies/smart-proxy/utils/route-utils.ts +3 -6
- package/ts/proxies/smart-proxy/utils/route-validators.ts +38 -21
|
@@ -10,7 +10,7 @@ import { ConnectionPool } from './connection-pool.js';
|
|
|
10
10
|
import { ContextCreator } from './context-creator.js';
|
|
11
11
|
import { HttpRequestHandler } from './http-request-handler.js';
|
|
12
12
|
import { Http2RequestHandler } from './http2-request-handler.js';
|
|
13
|
-
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
|
13
|
+
import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
|
|
14
14
|
import type { IRouteContext, IHttpRouteContext } from '../../core/models/route-context.js';
|
|
15
15
|
import { toBaseContext } from '../../core/models/route-context.js';
|
|
16
16
|
import { TemplateUtils } from '../../core/utils/template-utils.js';
|
|
@@ -99,6 +99,80 @@ export class RequestHandler {
|
|
|
99
99
|
return { ...this.defaultHeaders };
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Select the appropriate target from the targets array based on sub-matching criteria
|
|
104
|
+
*/
|
|
105
|
+
private selectTarget(
|
|
106
|
+
targets: IRouteTarget[],
|
|
107
|
+
context: {
|
|
108
|
+
port: number;
|
|
109
|
+
path?: string;
|
|
110
|
+
headers?: Record<string, string>;
|
|
111
|
+
method?: string;
|
|
112
|
+
}
|
|
113
|
+
): IRouteTarget | null {
|
|
114
|
+
// Sort targets by priority (higher first)
|
|
115
|
+
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
116
|
+
|
|
117
|
+
// Find the first matching target
|
|
118
|
+
for (const target of sortedTargets) {
|
|
119
|
+
if (!target.match) {
|
|
120
|
+
// No match criteria means this is a default/fallback target
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check port match
|
|
125
|
+
if (target.match.ports && !target.match.ports.includes(context.port)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Check path match (supports wildcards)
|
|
130
|
+
if (target.match.path && context.path) {
|
|
131
|
+
const pathPattern = target.match.path.replace(/\*/g, '.*');
|
|
132
|
+
const pathRegex = new RegExp(`^${pathPattern}$`);
|
|
133
|
+
if (!pathRegex.test(context.path)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check method match
|
|
139
|
+
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check headers match
|
|
144
|
+
if (target.match.headers && context.headers) {
|
|
145
|
+
let headersMatch = true;
|
|
146
|
+
for (const [key, pattern] of Object.entries(target.match.headers)) {
|
|
147
|
+
const headerValue = context.headers[key.toLowerCase()];
|
|
148
|
+
if (!headerValue) {
|
|
149
|
+
headersMatch = false;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (pattern instanceof RegExp) {
|
|
154
|
+
if (!pattern.test(headerValue)) {
|
|
155
|
+
headersMatch = false;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
} else if (headerValue !== pattern) {
|
|
159
|
+
headersMatch = false;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!headersMatch) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// All criteria matched
|
|
169
|
+
return target;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// No matching target found, return the first target without match criteria (default)
|
|
173
|
+
return sortedTargets.find(t => !t.match) || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
102
176
|
/**
|
|
103
177
|
* Apply CORS headers to response if configured
|
|
104
178
|
* Implements Phase 5.5: Context-aware CORS handling
|
|
@@ -480,17 +554,31 @@ export class RequestHandler {
|
|
|
480
554
|
}
|
|
481
555
|
}
|
|
482
556
|
|
|
483
|
-
// If we found a matching route with
|
|
484
|
-
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.
|
|
557
|
+
// If we found a matching route with forward action, select appropriate target
|
|
558
|
+
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
|
|
485
559
|
this.logger.debug(`Found matching route: ${matchingRoute.name || 'unnamed'}`);
|
|
486
560
|
|
|
561
|
+
// Select the appropriate target from the targets array
|
|
562
|
+
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
|
|
563
|
+
port: routeContext.port,
|
|
564
|
+
path: routeContext.path,
|
|
565
|
+
headers: routeContext.headers,
|
|
566
|
+
method: routeContext.method
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
if (!selectedTarget) {
|
|
570
|
+
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
|
|
571
|
+
req.socket.end();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
487
575
|
// Extract target information, resolving functions if needed
|
|
488
576
|
let targetHost: string | string[];
|
|
489
577
|
let targetPort: number;
|
|
490
578
|
|
|
491
579
|
try {
|
|
492
580
|
// Check function cache for host and resolve or use cached value
|
|
493
|
-
if (typeof
|
|
581
|
+
if (typeof selectedTarget.host === 'function') {
|
|
494
582
|
// Generate a function ID for caching (use route name or ID if available)
|
|
495
583
|
const functionId = `host-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
496
584
|
|
|
@@ -502,7 +590,7 @@ export class RequestHandler {
|
|
|
502
590
|
this.logger.debug(`Using cached host value for ${functionId}`);
|
|
503
591
|
} else {
|
|
504
592
|
// Resolve the function and cache the result
|
|
505
|
-
const resolvedHost =
|
|
593
|
+
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
|
506
594
|
targetHost = resolvedHost;
|
|
507
595
|
|
|
508
596
|
// Cache the result
|
|
@@ -511,16 +599,16 @@ export class RequestHandler {
|
|
|
511
599
|
}
|
|
512
600
|
} else {
|
|
513
601
|
// No cache available, just resolve
|
|
514
|
-
const resolvedHost =
|
|
602
|
+
const resolvedHost = selectedTarget.host(routeContext);
|
|
515
603
|
targetHost = resolvedHost;
|
|
516
604
|
this.logger.debug(`Resolved function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
517
605
|
}
|
|
518
606
|
} else {
|
|
519
|
-
targetHost =
|
|
607
|
+
targetHost = selectedTarget.host;
|
|
520
608
|
}
|
|
521
609
|
|
|
522
610
|
// Check function cache for port and resolve or use cached value
|
|
523
|
-
if (typeof
|
|
611
|
+
if (typeof selectedTarget.port === 'function') {
|
|
524
612
|
// Generate a function ID for caching
|
|
525
613
|
const functionId = `port-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
526
614
|
|
|
@@ -532,7 +620,7 @@ export class RequestHandler {
|
|
|
532
620
|
this.logger.debug(`Using cached port value for ${functionId}`);
|
|
533
621
|
} else {
|
|
534
622
|
// Resolve the function and cache the result
|
|
535
|
-
const resolvedPort =
|
|
623
|
+
const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
|
|
536
624
|
targetPort = resolvedPort;
|
|
537
625
|
|
|
538
626
|
// Cache the result
|
|
@@ -541,12 +629,12 @@ export class RequestHandler {
|
|
|
541
629
|
}
|
|
542
630
|
} else {
|
|
543
631
|
// No cache available, just resolve
|
|
544
|
-
const resolvedPort =
|
|
632
|
+
const resolvedPort = selectedTarget.port(routeContext);
|
|
545
633
|
targetPort = resolvedPort;
|
|
546
634
|
this.logger.debug(`Resolved function-based port to: ${resolvedPort}`);
|
|
547
635
|
}
|
|
548
636
|
} else {
|
|
549
|
-
targetPort =
|
|
637
|
+
targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
|
|
550
638
|
}
|
|
551
639
|
|
|
552
640
|
// Select a single host if an array was provided
|
|
@@ -626,17 +714,32 @@ export class RequestHandler {
|
|
|
626
714
|
}
|
|
627
715
|
}
|
|
628
716
|
|
|
629
|
-
// If we found a matching route with
|
|
630
|
-
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.
|
|
717
|
+
// If we found a matching route with forward action, select appropriate target
|
|
718
|
+
if (matchingRoute && matchingRoute.action.type === 'forward' && matchingRoute.action.targets && matchingRoute.action.targets.length > 0) {
|
|
631
719
|
this.logger.debug(`Found matching route for HTTP/2 request: ${matchingRoute.name || 'unnamed'}`);
|
|
632
720
|
|
|
721
|
+
// Select the appropriate target from the targets array
|
|
722
|
+
const selectedTarget = this.selectTarget(matchingRoute.action.targets, {
|
|
723
|
+
port: routeContext.port,
|
|
724
|
+
path: routeContext.path,
|
|
725
|
+
headers: routeContext.headers,
|
|
726
|
+
method: routeContext.method
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
if (!selectedTarget) {
|
|
730
|
+
this.logger.error(`No matching target found for route ${matchingRoute.name}`);
|
|
731
|
+
stream.respond({ ':status': 502 });
|
|
732
|
+
stream.end();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
633
736
|
// Extract target information, resolving functions if needed
|
|
634
737
|
let targetHost: string | string[];
|
|
635
738
|
let targetPort: number;
|
|
636
739
|
|
|
637
740
|
try {
|
|
638
741
|
// Check function cache for host and resolve or use cached value
|
|
639
|
-
if (typeof
|
|
742
|
+
if (typeof selectedTarget.host === 'function') {
|
|
640
743
|
// Generate a function ID for caching (use route name or ID if available)
|
|
641
744
|
const functionId = `host-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
642
745
|
|
|
@@ -648,7 +751,7 @@ export class RequestHandler {
|
|
|
648
751
|
this.logger.debug(`Using cached host value for HTTP/2: ${functionId}`);
|
|
649
752
|
} else {
|
|
650
753
|
// Resolve the function and cache the result
|
|
651
|
-
const resolvedHost =
|
|
754
|
+
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
|
652
755
|
targetHost = resolvedHost;
|
|
653
756
|
|
|
654
757
|
// Cache the result
|
|
@@ -657,16 +760,16 @@ export class RequestHandler {
|
|
|
657
760
|
}
|
|
658
761
|
} else {
|
|
659
762
|
// No cache available, just resolve
|
|
660
|
-
const resolvedHost =
|
|
763
|
+
const resolvedHost = selectedTarget.host(routeContext);
|
|
661
764
|
targetHost = resolvedHost;
|
|
662
765
|
this.logger.debug(`Resolved HTTP/2 function-based host to: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
663
766
|
}
|
|
664
767
|
} else {
|
|
665
|
-
targetHost =
|
|
768
|
+
targetHost = selectedTarget.host;
|
|
666
769
|
}
|
|
667
770
|
|
|
668
771
|
// Check function cache for port and resolve or use cached value
|
|
669
|
-
if (typeof
|
|
772
|
+
if (typeof selectedTarget.port === 'function') {
|
|
670
773
|
// Generate a function ID for caching
|
|
671
774
|
const functionId = `port-http2-${matchingRoute.id || matchingRoute.name || 'unnamed'}`;
|
|
672
775
|
|
|
@@ -678,7 +781,7 @@ export class RequestHandler {
|
|
|
678
781
|
this.logger.debug(`Using cached port value for HTTP/2: ${functionId}`);
|
|
679
782
|
} else {
|
|
680
783
|
// Resolve the function and cache the result
|
|
681
|
-
const resolvedPort =
|
|
784
|
+
const resolvedPort = selectedTarget.port(toBaseContext(routeContext));
|
|
682
785
|
targetPort = resolvedPort;
|
|
683
786
|
|
|
684
787
|
// Cache the result
|
|
@@ -687,12 +790,12 @@ export class RequestHandler {
|
|
|
687
790
|
}
|
|
688
791
|
} else {
|
|
689
792
|
// No cache available, just resolve
|
|
690
|
-
const resolvedPort =
|
|
793
|
+
const resolvedPort = selectedTarget.port(routeContext);
|
|
691
794
|
targetPort = resolvedPort;
|
|
692
795
|
this.logger.debug(`Resolved HTTP/2 function-based port to: ${resolvedPort}`);
|
|
693
796
|
}
|
|
694
797
|
} else {
|
|
695
|
-
targetPort =
|
|
798
|
+
targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
|
|
696
799
|
}
|
|
697
800
|
|
|
698
801
|
// Select a single host if an array was provided
|
|
@@ -3,7 +3,7 @@ import '../../core/models/socket-augmentation.js';
|
|
|
3
3
|
import { type IHttpProxyOptions, type IWebSocketWithHeartbeat, type ILogger, createLogger } from './models/types.js';
|
|
4
4
|
import { ConnectionPool } from './connection-pool.js';
|
|
5
5
|
import { HttpRouter } from '../../routing/router/index.js';
|
|
6
|
-
import type { IRouteConfig } from '../smart-proxy/models/route-types.js';
|
|
6
|
+
import type { IRouteConfig, IRouteTarget } from '../smart-proxy/models/route-types.js';
|
|
7
7
|
import type { IRouteContext } from '../../core/models/route-context.js';
|
|
8
8
|
import { toBaseContext } from '../../core/models/route-context.js';
|
|
9
9
|
import { ContextCreator } from './context-creator.js';
|
|
@@ -53,6 +53,80 @@ export class WebSocketHandler {
|
|
|
53
53
|
this.securityManager.setRoutes(routes);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Select the appropriate target from the targets array based on sub-matching criteria
|
|
58
|
+
*/
|
|
59
|
+
private selectTarget(
|
|
60
|
+
targets: IRouteTarget[],
|
|
61
|
+
context: {
|
|
62
|
+
port: number;
|
|
63
|
+
path?: string;
|
|
64
|
+
headers?: Record<string, string>;
|
|
65
|
+
method?: string;
|
|
66
|
+
}
|
|
67
|
+
): IRouteTarget | null {
|
|
68
|
+
// Sort targets by priority (higher first)
|
|
69
|
+
const sortedTargets = [...targets].sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
70
|
+
|
|
71
|
+
// Find the first matching target
|
|
72
|
+
for (const target of sortedTargets) {
|
|
73
|
+
if (!target.match) {
|
|
74
|
+
// No match criteria means this is a default/fallback target
|
|
75
|
+
return target;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check port match
|
|
79
|
+
if (target.match.ports && !target.match.ports.includes(context.port)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check path match (supports wildcards)
|
|
84
|
+
if (target.match.path && context.path) {
|
|
85
|
+
const pathPattern = target.match.path.replace(/\*/g, '.*');
|
|
86
|
+
const pathRegex = new RegExp(`^${pathPattern}$`);
|
|
87
|
+
if (!pathRegex.test(context.path)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check method match
|
|
93
|
+
if (target.match.method && context.method && !target.match.method.includes(context.method)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Check headers match
|
|
98
|
+
if (target.match.headers && context.headers) {
|
|
99
|
+
let headersMatch = true;
|
|
100
|
+
for (const [key, pattern] of Object.entries(target.match.headers)) {
|
|
101
|
+
const headerValue = context.headers[key.toLowerCase()];
|
|
102
|
+
if (!headerValue) {
|
|
103
|
+
headersMatch = false;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (pattern instanceof RegExp) {
|
|
108
|
+
if (!pattern.test(headerValue)) {
|
|
109
|
+
headersMatch = false;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
} else if (headerValue !== pattern) {
|
|
113
|
+
headersMatch = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!headersMatch) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// All criteria matched
|
|
123
|
+
return target;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// No matching target found, return the first target without match criteria (default)
|
|
127
|
+
return sortedTargets.find(t => !t.match) || null;
|
|
128
|
+
}
|
|
129
|
+
|
|
56
130
|
/**
|
|
57
131
|
* Initialize WebSocket server on an existing HTTPS server
|
|
58
132
|
*/
|
|
@@ -146,9 +220,23 @@ export class WebSocketHandler {
|
|
|
146
220
|
let destination: { host: string; port: number };
|
|
147
221
|
|
|
148
222
|
// If we found a route with the modern router, use it
|
|
149
|
-
if (route && route.action.type === 'forward' && route.action.
|
|
223
|
+
if (route && route.action.type === 'forward' && route.action.targets && route.action.targets.length > 0) {
|
|
150
224
|
this.logger.debug(`Found matching WebSocket route: ${route.name || 'unnamed'}`);
|
|
151
225
|
|
|
226
|
+
// Select the appropriate target from the targets array
|
|
227
|
+
const selectedTarget = this.selectTarget(route.action.targets, {
|
|
228
|
+
port: routeContext.port,
|
|
229
|
+
path: routeContext.path,
|
|
230
|
+
headers: routeContext.headers,
|
|
231
|
+
method: routeContext.method
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (!selectedTarget) {
|
|
235
|
+
this.logger.error(`No matching target found for route ${route.name}`);
|
|
236
|
+
wsIncoming.close(1003, 'No matching target');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
152
240
|
// Check if WebSockets are enabled for this route
|
|
153
241
|
if (route.action.websocket?.enabled === false) {
|
|
154
242
|
this.logger.debug(`WebSockets are disabled for route: ${route.name || 'unnamed'}`);
|
|
@@ -192,20 +280,20 @@ export class WebSocketHandler {
|
|
|
192
280
|
|
|
193
281
|
try {
|
|
194
282
|
// Resolve host if it's a function
|
|
195
|
-
if (typeof
|
|
196
|
-
const resolvedHost =
|
|
283
|
+
if (typeof selectedTarget.host === 'function') {
|
|
284
|
+
const resolvedHost = selectedTarget.host(toBaseContext(routeContext));
|
|
197
285
|
targetHost = resolvedHost;
|
|
198
286
|
this.logger.debug(`Resolved function-based host for WebSocket: ${Array.isArray(resolvedHost) ? resolvedHost.join(', ') : resolvedHost}`);
|
|
199
287
|
} else {
|
|
200
|
-
targetHost =
|
|
288
|
+
targetHost = selectedTarget.host;
|
|
201
289
|
}
|
|
202
290
|
|
|
203
291
|
// Resolve port if it's a function
|
|
204
|
-
if (typeof
|
|
205
|
-
targetPort =
|
|
292
|
+
if (typeof selectedTarget.port === 'function') {
|
|
293
|
+
targetPort = selectedTarget.port(toBaseContext(routeContext));
|
|
206
294
|
this.logger.debug(`Resolved function-based port for WebSocket: ${targetPort}`);
|
|
207
295
|
} else {
|
|
208
|
-
targetPort =
|
|
296
|
+
targetPort = selectedTarget.port === 'preserve' ? routeContext.port : selectedTarget.port as number;
|
|
209
297
|
}
|
|
210
298
|
|
|
211
299
|
// Select a single host if an array was provided
|
|
@@ -12,7 +12,7 @@ export interface ICertStatus {
|
|
|
12
12
|
status: 'valid' | 'pending' | 'expired' | 'error';
|
|
13
13
|
expiryDate?: Date;
|
|
14
14
|
issueDate?: Date;
|
|
15
|
-
source: 'static' | 'acme';
|
|
15
|
+
source: 'static' | 'acme' | 'custom';
|
|
16
16
|
error?: string;
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -22,6 +22,7 @@ export interface ICertificateData {
|
|
|
22
22
|
ca?: string;
|
|
23
23
|
expiryDate: Date;
|
|
24
24
|
issueDate: Date;
|
|
25
|
+
source?: 'static' | 'acme' | 'custom';
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export class SmartCertManager {
|
|
@@ -50,6 +51,12 @@ export class SmartCertManager {
|
|
|
50
51
|
// ACME state manager reference
|
|
51
52
|
private acmeStateManager: AcmeStateManager | null = null;
|
|
52
53
|
|
|
54
|
+
// Custom certificate provision function
|
|
55
|
+
private certProvisionFunction?: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>;
|
|
56
|
+
|
|
57
|
+
// Whether to fallback to ACME if custom provision fails
|
|
58
|
+
private certProvisionFallbackToAcme: boolean = true;
|
|
59
|
+
|
|
53
60
|
constructor(
|
|
54
61
|
private routes: IRouteConfig[],
|
|
55
62
|
private certDir: string = './certs',
|
|
@@ -89,6 +96,20 @@ export class SmartCertManager {
|
|
|
89
96
|
this.globalAcmeDefaults = defaults;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Set custom certificate provision function
|
|
101
|
+
*/
|
|
102
|
+
public setCertProvisionFunction(fn: (domain: string) => Promise<plugins.tsclass.network.ICert | 'http01'>): void {
|
|
103
|
+
this.certProvisionFunction = fn;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set whether to fallback to ACME if custom provision fails
|
|
108
|
+
*/
|
|
109
|
+
public setCertProvisionFallbackToAcme(fallback: boolean): void {
|
|
110
|
+
this.certProvisionFallbackToAcme = fallback;
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
/**
|
|
93
114
|
* Set callback for updating routes (used for challenge routes)
|
|
94
115
|
*/
|
|
@@ -212,15 +233,6 @@ export class SmartCertManager {
|
|
|
212
233
|
route: IRouteConfig,
|
|
213
234
|
domains: string[]
|
|
214
235
|
): Promise<void> {
|
|
215
|
-
if (!this.smartAcme) {
|
|
216
|
-
throw new Error(
|
|
217
|
-
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
|
|
218
|
-
'Please ensure you have configured ACME with an email address either:\n' +
|
|
219
|
-
'1. In the top-level "acme" configuration\n' +
|
|
220
|
-
'2. In the route\'s "tls.acme" configuration'
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
236
|
const primaryDomain = domains[0];
|
|
225
237
|
const routeName = route.name || primaryDomain;
|
|
226
238
|
|
|
@@ -229,10 +241,68 @@ export class SmartCertManager {
|
|
|
229
241
|
if (existingCert && this.isCertificateValid(existingCert)) {
|
|
230
242
|
logger.log('info', `Using existing valid certificate for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
|
231
243
|
await this.applyCertificate(primaryDomain, existingCert);
|
|
232
|
-
this.updateCertStatus(routeName, 'valid', 'acme', existingCert);
|
|
244
|
+
this.updateCertStatus(routeName, 'valid', existingCert.source || 'acme', existingCert);
|
|
233
245
|
return;
|
|
234
246
|
}
|
|
235
247
|
|
|
248
|
+
// Check for custom provision function first
|
|
249
|
+
if (this.certProvisionFunction) {
|
|
250
|
+
try {
|
|
251
|
+
logger.log('info', `Attempting custom certificate provision for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
|
252
|
+
const result = await this.certProvisionFunction(primaryDomain);
|
|
253
|
+
|
|
254
|
+
if (result === 'http01') {
|
|
255
|
+
logger.log('info', `Custom function returned 'http01', falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
|
256
|
+
// Continue with existing ACME logic below
|
|
257
|
+
} else {
|
|
258
|
+
// Use custom certificate
|
|
259
|
+
const customCert = result as plugins.tsclass.network.ICert;
|
|
260
|
+
|
|
261
|
+
// Convert to internal certificate format
|
|
262
|
+
const certData: ICertificateData = {
|
|
263
|
+
cert: customCert.publicKey,
|
|
264
|
+
key: customCert.privateKey,
|
|
265
|
+
ca: '',
|
|
266
|
+
issueDate: new Date(),
|
|
267
|
+
expiryDate: this.extractExpiryDate(customCert.publicKey),
|
|
268
|
+
source: 'custom'
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// Store and apply certificate
|
|
272
|
+
await this.certStore.saveCertificate(routeName, certData);
|
|
273
|
+
await this.applyCertificate(primaryDomain, certData);
|
|
274
|
+
this.updateCertStatus(routeName, 'valid', 'custom', certData);
|
|
275
|
+
|
|
276
|
+
logger.log('info', `Custom certificate applied for ${primaryDomain}`, {
|
|
277
|
+
domain: primaryDomain,
|
|
278
|
+
expiryDate: certData.expiryDate,
|
|
279
|
+
component: 'certificate-manager'
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
logger.log('error', `Custom cert provision failed for ${primaryDomain}: ${error.message}`, {
|
|
285
|
+
domain: primaryDomain,
|
|
286
|
+
error: error.message,
|
|
287
|
+
component: 'certificate-manager'
|
|
288
|
+
});
|
|
289
|
+
// Check if we should fallback to ACME
|
|
290
|
+
if (!this.certProvisionFallbackToAcme) {
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
logger.log('info', `Falling back to Let's Encrypt for ${primaryDomain}`, { domain: primaryDomain, component: 'certificate-manager' });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (!this.smartAcme) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
'SmartAcme not initialized. This usually means no ACME email was provided. ' +
|
|
300
|
+
'Please ensure you have configured ACME with an email address either:\n' +
|
|
301
|
+
'1. In the top-level "acme" configuration\n' +
|
|
302
|
+
'2. In the route\'s "tls.acme" configuration'
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
236
306
|
// Apply renewal threshold from global defaults or route config
|
|
237
307
|
const renewThreshold = route.action.tls?.acme?.renewBeforeDays ||
|
|
238
308
|
this.globalAcmeDefaults?.renewThresholdDays ||
|
|
@@ -280,7 +350,8 @@ export class SmartCertManager {
|
|
|
280
350
|
key: cert.privateKey,
|
|
281
351
|
ca: cert.publicKey, // Use same as cert for now
|
|
282
352
|
expiryDate: new Date(cert.validUntil),
|
|
283
|
-
issueDate: new Date(cert.created)
|
|
353
|
+
issueDate: new Date(cert.created),
|
|
354
|
+
source: 'acme'
|
|
284
355
|
};
|
|
285
356
|
|
|
286
357
|
await this.certStore.saveCertificate(routeName, certData);
|
|
@@ -328,7 +399,8 @@ export class SmartCertManager {
|
|
|
328
399
|
cert,
|
|
329
400
|
key,
|
|
330
401
|
expiryDate: certInfo.validTo,
|
|
331
|
-
issueDate: certInfo.validFrom
|
|
402
|
+
issueDate: certInfo.validFrom,
|
|
403
|
+
source: 'static'
|
|
332
404
|
};
|
|
333
405
|
|
|
334
406
|
// Save to store for consistency
|
|
@@ -399,6 +471,19 @@ export class SmartCertManager {
|
|
|
399
471
|
return cert.expiryDate > expiryThreshold;
|
|
400
472
|
}
|
|
401
473
|
|
|
474
|
+
/**
|
|
475
|
+
* Extract expiry date from a PEM certificate
|
|
476
|
+
*/
|
|
477
|
+
private extractExpiryDate(_certPem: string): Date {
|
|
478
|
+
// For now, we'll default to 90 days for custom certificates
|
|
479
|
+
// In production, you might want to use a proper X.509 parser
|
|
480
|
+
// or require the custom cert provider to include expiry info
|
|
481
|
+
logger.log('info', 'Using default 90-day expiry for custom certificate', {
|
|
482
|
+
component: 'certificate-manager'
|
|
483
|
+
});
|
|
484
|
+
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
|
485
|
+
}
|
|
486
|
+
|
|
402
487
|
|
|
403
488
|
/**
|
|
404
489
|
* Add challenge route to SmartProxy
|
|
@@ -135,6 +135,12 @@ export interface ISmartProxyOptions {
|
|
|
135
135
|
* or a static certificate object for immediate provisioning.
|
|
136
136
|
*/
|
|
137
137
|
certProvisionFunction?: (domain: string) => Promise<TSmartProxyCertProvisionObject>;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether to fallback to ACME if custom certificate provision fails.
|
|
141
|
+
* Default: true
|
|
142
|
+
*/
|
|
143
|
+
certProvisionFallbackToAcme?: boolean;
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
/**
|
|
@@ -46,11 +46,36 @@ export interface IRouteMatch {
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Target
|
|
49
|
+
* Target-specific match criteria for sub-routing within a route
|
|
50
|
+
*/
|
|
51
|
+
export interface ITargetMatch {
|
|
52
|
+
ports?: number[]; // Match specific ports from the route
|
|
53
|
+
path?: string; // Match specific paths (supports wildcards like /api/*)
|
|
54
|
+
headers?: Record<string, string | RegExp>; // Match specific HTTP headers
|
|
55
|
+
method?: string[]; // Match specific HTTP methods (GET, POST, etc.)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Target configuration for forwarding with sub-matching and overrides
|
|
50
60
|
*/
|
|
51
61
|
export interface IRouteTarget {
|
|
62
|
+
// Optional sub-matching criteria within the route
|
|
63
|
+
match?: ITargetMatch;
|
|
64
|
+
|
|
65
|
+
// Target destination
|
|
52
66
|
host: string | string[] | ((context: IRouteContext) => string | string[]); // Host or hosts with optional function for dynamic resolution
|
|
53
67
|
port: number | 'preserve' | ((context: IRouteContext) => number); // Port with optional function for dynamic mapping (use 'preserve' to keep the incoming port)
|
|
68
|
+
|
|
69
|
+
// Optional target-specific overrides (these override route-level settings)
|
|
70
|
+
tls?: IRouteTls; // Override route-level TLS settings
|
|
71
|
+
websocket?: IRouteWebSocket; // Override route-level WebSocket settings
|
|
72
|
+
loadBalancing?: IRouteLoadBalancing; // Override route-level load balancing
|
|
73
|
+
sendProxyProtocol?: boolean; // Override route-level proxy protocol setting
|
|
74
|
+
headers?: IRouteHeaders; // Override route-level headers
|
|
75
|
+
advanced?: IRouteAdvanced; // Override route-level advanced settings
|
|
76
|
+
|
|
77
|
+
// Priority for matching (higher values are checked first, default: 0)
|
|
78
|
+
priority?: number;
|
|
54
79
|
}
|
|
55
80
|
|
|
56
81
|
/**
|
|
@@ -221,19 +246,20 @@ export interface IRouteAction {
|
|
|
221
246
|
// Basic routing
|
|
222
247
|
type: TRouteActionType;
|
|
223
248
|
|
|
224
|
-
//
|
|
225
|
-
|
|
249
|
+
// Targets for forwarding (array supports multiple targets with sub-matching)
|
|
250
|
+
// Required for 'forward' action type
|
|
251
|
+
targets?: IRouteTarget[];
|
|
226
252
|
|
|
227
|
-
// TLS handling
|
|
253
|
+
// TLS handling (default for all targets, can be overridden per target)
|
|
228
254
|
tls?: IRouteTls;
|
|
229
255
|
|
|
230
|
-
// WebSocket support
|
|
256
|
+
// WebSocket support (default for all targets, can be overridden per target)
|
|
231
257
|
websocket?: IRouteWebSocket;
|
|
232
258
|
|
|
233
|
-
// Load balancing options
|
|
259
|
+
// Load balancing options (default for all targets, can be overridden per target)
|
|
234
260
|
loadBalancing?: IRouteLoadBalancing;
|
|
235
261
|
|
|
236
|
-
// Advanced options
|
|
262
|
+
// Advanced options (default for all targets, can be overridden per target)
|
|
237
263
|
advanced?: IRouteAdvanced;
|
|
238
264
|
|
|
239
265
|
// Additional options for backend-specific settings
|
|
@@ -251,7 +277,7 @@ export interface IRouteAction {
|
|
|
251
277
|
// Socket handler function (when type is 'socket-handler')
|
|
252
278
|
socketHandler?: TSocketHandler;
|
|
253
279
|
|
|
254
|
-
// PROXY protocol support
|
|
280
|
+
// PROXY protocol support (default for all targets, can be overridden per target)
|
|
255
281
|
sendProxyProtocol?: boolean;
|
|
256
282
|
}
|
|
257
283
|
|