@serve.zone/dcrouter 16.1.1 → 16.2.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/deno.json +1 -1
- package/dist_serve/bundle.js +11786 -7664
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/config/classes.route-config-manager.d.ts +24 -4
- package/dist_ts/config/classes.route-config-manager.js +195 -34
- package/dist_ts/config/helpers.http-redirects.js +21 -12
- package/dist_ts/config/helpers.special-forwards.d.ts +14 -0
- package/dist_ts/config/helpers.special-forwards.js +85 -0
- package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
- package/dist_ts/opsserver/handlers/gatewayclient.handler.js +56 -9
- package/dist_ts/opsserver/handlers/route-management.handler.js +36 -1
- package/dist_ts_interfaces/data/route-management.d.ts +19 -1
- package/dist_ts_interfaces/data/route-management.js +5 -1
- package/dist_ts_interfaces/requests/route-management.d.ts +50 -1
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/dist_ts_web/appstate/routes.d.ts +10 -0
- package/dist_ts_web/appstate/routes.js +67 -1
- package/dist_ts_web/elements/network/ops-view-redirects.d.ts +1 -0
- package/dist_ts_web/elements/network/ops-view-redirects.js +12 -4
- package/dist_ts_web/elements/network/ops-view-routes.d.ts +20 -1
- package/dist_ts_web/elements/network/ops-view-routes.js +182 -52
- package/dist_ts_web/elements/network/ops-view-special-forwards.d.ts +12 -0
- package/dist_ts_web/elements/network/ops-view-special-forwards.js +375 -0
- package/dist_ts_web/elements/ops-dashboard.js +1 -3
- package/dist_ts_web/router.js +10 -2
- package/package.json +5 -5
- package/readme.md +7 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/config/classes.route-config-manager.ts +271 -32
- package/ts/config/helpers.http-redirects.ts +24 -16
- package/ts/config/helpers.special-forwards.ts +108 -0
- package/ts/opsserver/handlers/gatewayclient.handler.ts +55 -8
- package/ts/opsserver/handlers/route-management.handler.ts +59 -0
- package/ts_apiclient/readme.md +2 -2
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate/routes.ts +84 -0
- package/ts_web/elements/network/ops-view-redirects.ts +5 -1
- package/ts_web/elements/network/ops-view-routes.ts +175 -31
- package/ts_web/elements/network/ops-view-special-forwards.ts +352 -0
- package/ts_web/elements/ops-dashboard.ts +0 -2
- package/ts_web/readme.md +9 -0
- package/ts_web/router.ts +10 -1
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { RouteDoc } from '../db/index.js';
|
|
|
4
4
|
import { routePathClasses } from '../../ts_interfaces/data/route-management.js';
|
|
5
5
|
import type {
|
|
6
6
|
IHttpRedirectInfo,
|
|
7
|
+
ILetsEncryptHttp01ForwardConfig,
|
|
7
8
|
IRoute,
|
|
8
9
|
IMergedRoute,
|
|
9
10
|
IRouteWarning,
|
|
@@ -18,6 +19,11 @@ import { type IHttp3Config, augmentRouteWithHttp3, routeNeedsHttpProtocol } from
|
|
|
18
19
|
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
|
19
20
|
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
|
|
20
21
|
import { deriveHttpRedirects } from './helpers.http-redirects.js';
|
|
22
|
+
import {
|
|
23
|
+
buildLetsEncryptHttp01Route,
|
|
24
|
+
letsEncryptHttp01ManagedRouteKind,
|
|
25
|
+
type ISpecialForwardResolvedTarget,
|
|
26
|
+
} from './helpers.special-forwards.js';
|
|
21
27
|
|
|
22
28
|
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
|
|
23
29
|
|
|
@@ -26,6 +32,12 @@ export interface IRouteMutationResult {
|
|
|
26
32
|
message?: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
interface IRouteMutationOptions {
|
|
36
|
+
trustedManagedMutation?: boolean;
|
|
37
|
+
replaceMetadata?: boolean;
|
|
38
|
+
replaceRoute?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Simple async mutex — serializes concurrent applyRoutes() calls so the Rust engine
|
|
31
43
|
* never receives rapid overlapping route updates that can churn UDP/QUIC listeners.
|
|
@@ -145,7 +157,17 @@ export class RouteConfigManager {
|
|
|
145
157
|
createdBy: string,
|
|
146
158
|
enabled = true,
|
|
147
159
|
metadata?: IRouteMetadata,
|
|
160
|
+
options: IRouteMutationOptions = {},
|
|
148
161
|
): Promise<string> {
|
|
162
|
+
if (!options.trustedManagedMutation && this.hasReservedManagedMetadata(metadata)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
'Managed route ownership metadata is reserved; use a specialized route endpoint',
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
metadata = options.trustedManagedMutation
|
|
168
|
+
? metadata
|
|
169
|
+
: { ...metadata, ownerType: 'operator' };
|
|
170
|
+
|
|
149
171
|
const id = plugins.uuid.v4();
|
|
150
172
|
const now = Date.now();
|
|
151
173
|
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
|
|
@@ -187,6 +209,34 @@ export class RouteConfigManager {
|
|
|
187
209
|
return id;
|
|
188
210
|
}
|
|
189
211
|
|
|
212
|
+
public async createManagedRoute(
|
|
213
|
+
route: IDcRouterRouteConfig,
|
|
214
|
+
createdBy: string,
|
|
215
|
+
enabled: boolean,
|
|
216
|
+
metadata: IRouteMetadata,
|
|
217
|
+
): Promise<string> {
|
|
218
|
+
if (!this.isManagedMetadata(metadata)) {
|
|
219
|
+
throw new Error('Managed route metadata is required');
|
|
220
|
+
}
|
|
221
|
+
return await this.createRoute(route, createdBy, enabled, metadata, {
|
|
222
|
+
trustedManagedMutation: true,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
public async createLetsEncryptHttp01Forward(
|
|
227
|
+
config: ILetsEncryptHttp01ForwardConfig,
|
|
228
|
+
createdBy: string,
|
|
229
|
+
enabled = true,
|
|
230
|
+
): Promise<string> {
|
|
231
|
+
const compiled = this.compileLetsEncryptHttp01Forward(config);
|
|
232
|
+
return await this.createManagedRoute(
|
|
233
|
+
compiled.route,
|
|
234
|
+
createdBy,
|
|
235
|
+
enabled,
|
|
236
|
+
compiled.metadata,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
190
240
|
public async updateRoute(
|
|
191
241
|
id: string,
|
|
192
242
|
patch: {
|
|
@@ -194,11 +244,27 @@ export class RouteConfigManager {
|
|
|
194
244
|
enabled?: boolean;
|
|
195
245
|
metadata?: Partial<IRouteMetadata>;
|
|
196
246
|
},
|
|
247
|
+
options: IRouteMutationOptions = {},
|
|
197
248
|
): Promise<IRouteMutationResult> {
|
|
198
249
|
const stored = this.routes.get(id);
|
|
199
250
|
if (!stored) {
|
|
200
251
|
return { success: false, message: 'Route not found' };
|
|
201
252
|
}
|
|
253
|
+
const isToggleOnlyPatch = patch.enabled !== undefined
|
|
254
|
+
&& patch.route === undefined
|
|
255
|
+
&& patch.metadata === undefined;
|
|
256
|
+
if (!options.trustedManagedMutation && this.hasReservedManagedMetadata(patch.metadata)) {
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
message: 'Managed route ownership metadata is reserved; use a specialized route endpoint',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (!options.trustedManagedMutation && this.isManagedRoute(stored) && !isToggleOnlyPatch) {
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
message: 'Managed routes must be changed through their specialized endpoint',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
202
268
|
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings);
|
|
203
269
|
if (sourceBindingsPayloadError) {
|
|
204
270
|
return { success: false, message: sourceBindingsPayloadError };
|
|
@@ -208,9 +274,6 @@ export class RouteConfigManager {
|
|
|
208
274
|
const previousMetadata = structuredClone(stored.metadata);
|
|
209
275
|
const previousEnabled = stored.enabled;
|
|
210
276
|
|
|
211
|
-
const isToggleOnlyPatch = patch.enabled !== undefined
|
|
212
|
-
&& patch.route === undefined
|
|
213
|
-
&& patch.metadata === undefined;
|
|
214
277
|
if (stored.origin !== 'api' && !isToggleOnlyPatch) {
|
|
215
278
|
return {
|
|
216
279
|
success: false,
|
|
@@ -219,36 +282,41 @@ export class RouteConfigManager {
|
|
|
219
282
|
}
|
|
220
283
|
|
|
221
284
|
if (patch.route) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
285
|
+
if (options.replaceRoute) {
|
|
286
|
+
stored.route = structuredClone(patch.route) as IDcRouterRouteConfig;
|
|
287
|
+
} else {
|
|
288
|
+
const mergedAction = patch.route.action
|
|
289
|
+
? { ...stored.route.action, ...patch.route.action }
|
|
290
|
+
: stored.route.action;
|
|
291
|
+
// Handle explicit null to remove nested action properties (e.g., tls: null)
|
|
292
|
+
if (patch.route.action) {
|
|
293
|
+
for (const [key, val] of Object.entries(patch.route.action)) {
|
|
294
|
+
if (val === null) {
|
|
295
|
+
delete (mergedAction as any)[key];
|
|
296
|
+
}
|
|
230
297
|
}
|
|
231
298
|
}
|
|
232
|
-
|
|
233
|
-
const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
|
299
|
+
const mergedRoute = { ...stored.route, ...patch.route, action: mergedAction } as IDcRouterRouteConfig;
|
|
234
300
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
301
|
+
// Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
|
|
302
|
+
for (const [key, val] of Object.entries(patch.route)) {
|
|
303
|
+
if (val === null && key !== 'action' && key !== 'match') {
|
|
304
|
+
delete (mergedRoute as any)[key];
|
|
305
|
+
}
|
|
239
306
|
}
|
|
240
|
-
}
|
|
241
307
|
|
|
242
|
-
|
|
308
|
+
stored.route = mergedRoute;
|
|
309
|
+
}
|
|
243
310
|
}
|
|
244
311
|
if (patch.enabled !== undefined) {
|
|
245
312
|
stored.enabled = patch.enabled;
|
|
246
313
|
}
|
|
247
314
|
if (patch.metadata !== undefined) {
|
|
248
|
-
stored.metadata = this.normalizeRouteMetadata(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
315
|
+
stored.metadata = this.normalizeRouteMetadata(
|
|
316
|
+
options.replaceMetadata
|
|
317
|
+
? patch.metadata
|
|
318
|
+
: { ...stored.metadata, ...patch.metadata },
|
|
319
|
+
);
|
|
252
320
|
}
|
|
253
321
|
|
|
254
322
|
// Re-resolve if metadata refs exist and resolver is available
|
|
@@ -273,11 +341,68 @@ export class RouteConfigManager {
|
|
|
273
341
|
return { success: true };
|
|
274
342
|
}
|
|
275
343
|
|
|
276
|
-
public async
|
|
344
|
+
public async updateManagedRoute(
|
|
345
|
+
id: string,
|
|
346
|
+
patch: {
|
|
347
|
+
route?: Partial<IDcRouterRouteConfig>;
|
|
348
|
+
enabled?: boolean;
|
|
349
|
+
metadata?: Partial<IRouteMetadata>;
|
|
350
|
+
},
|
|
351
|
+
options: Pick<IRouteMutationOptions, 'replaceMetadata' | 'replaceRoute'> = {},
|
|
352
|
+
): Promise<IRouteMutationResult> {
|
|
353
|
+
const stored = this.routes.get(id);
|
|
354
|
+
if (!stored || !this.isManagedRoute(stored)) {
|
|
355
|
+
return { success: false, message: 'Managed route not found' };
|
|
356
|
+
}
|
|
357
|
+
return await this.updateRoute(id, patch, {
|
|
358
|
+
trustedManagedMutation: true,
|
|
359
|
+
...options,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
public async updateLetsEncryptHttp01Forward(
|
|
364
|
+
id: string,
|
|
365
|
+
config: ILetsEncryptHttp01ForwardConfig,
|
|
366
|
+
enabled?: boolean,
|
|
367
|
+
): Promise<IRouteMutationResult> {
|
|
368
|
+
const stored = this.routes.get(id);
|
|
369
|
+
if (
|
|
370
|
+
!stored
|
|
371
|
+
|| stored.metadata?.ownerType === 'gatewayClient'
|
|
372
|
+
|| stored.metadata?.managedRouteKind !== letsEncryptHttp01ManagedRouteKind
|
|
373
|
+
) {
|
|
374
|
+
return { success: false, message: 'Let\'s Encrypt HTTP-01 forward not found' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const compiled = this.compileLetsEncryptHttp01Forward(config);
|
|
378
|
+
return await this.updateManagedRoute(
|
|
379
|
+
id,
|
|
380
|
+
{
|
|
381
|
+
route: compiled.route,
|
|
382
|
+
metadata: compiled.metadata,
|
|
383
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
replaceMetadata: true,
|
|
387
|
+
replaceRoute: true,
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
public async deleteRoute(
|
|
393
|
+
id: string,
|
|
394
|
+
options: IRouteMutationOptions = {},
|
|
395
|
+
): Promise<IRouteMutationResult> {
|
|
277
396
|
const stored = this.routes.get(id);
|
|
278
397
|
if (!stored) {
|
|
279
398
|
return { success: false, message: 'Route not found' };
|
|
280
399
|
}
|
|
400
|
+
if (!options.trustedManagedMutation && this.isManagedRoute(stored)) {
|
|
401
|
+
return {
|
|
402
|
+
success: false,
|
|
403
|
+
message: 'Managed routes must be deleted through their specialized endpoint',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
281
406
|
if (stored.origin !== 'api') {
|
|
282
407
|
return {
|
|
283
408
|
success: false,
|
|
@@ -292,6 +417,26 @@ export class RouteConfigManager {
|
|
|
292
417
|
return { success: true };
|
|
293
418
|
}
|
|
294
419
|
|
|
420
|
+
public async deleteManagedRoute(id: string): Promise<IRouteMutationResult> {
|
|
421
|
+
const stored = this.routes.get(id);
|
|
422
|
+
if (!stored || !this.isManagedRoute(stored)) {
|
|
423
|
+
return { success: false, message: 'Managed route not found' };
|
|
424
|
+
}
|
|
425
|
+
return await this.deleteRoute(id, { trustedManagedMutation: true });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
public async deleteLetsEncryptHttp01Forward(id: string): Promise<IRouteMutationResult> {
|
|
429
|
+
const stored = this.routes.get(id);
|
|
430
|
+
if (
|
|
431
|
+
!stored
|
|
432
|
+
|| stored.metadata?.ownerType === 'gatewayClient'
|
|
433
|
+
|| stored.metadata?.managedRouteKind !== letsEncryptHttp01ManagedRouteKind
|
|
434
|
+
) {
|
|
435
|
+
return { success: false, message: 'Let\'s Encrypt HTTP-01 forward not found' };
|
|
436
|
+
}
|
|
437
|
+
return await this.deleteManagedRoute(id);
|
|
438
|
+
}
|
|
439
|
+
|
|
295
440
|
public async toggleRoute(id: string, enabled: boolean): Promise<IRouteMutationResult> {
|
|
296
441
|
return this.updateRoute(id, { enabled });
|
|
297
442
|
}
|
|
@@ -471,6 +616,97 @@ export class RouteConfigManager {
|
|
|
471
616
|
}
|
|
472
617
|
}
|
|
473
618
|
|
|
619
|
+
private compileLetsEncryptHttp01Forward(
|
|
620
|
+
config: ILetsEncryptHttp01ForwardConfig,
|
|
621
|
+
): { route: IDcRouterRouteConfig; metadata: IRouteMetadata } {
|
|
622
|
+
if (!this.referenceResolver) {
|
|
623
|
+
throw new Error('Reference resolver is unavailable');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const publicProfile = this.referenceResolver.listProfiles().find((profile) => {
|
|
627
|
+
return profile.name.trim().toUpperCase() === 'PUBLIC';
|
|
628
|
+
});
|
|
629
|
+
if (!publicProfile) {
|
|
630
|
+
throw new Error('The PUBLIC source profile is required for HTTP-01 forwards');
|
|
631
|
+
}
|
|
632
|
+
const publicSecurity = this.referenceResolver.resolveSourceProfileSecurity(publicProfile.id);
|
|
633
|
+
if (!publicSecurity?.ipAllowList?.includes('*')) {
|
|
634
|
+
throw new Error('The PUBLIC source profile must allow all source addresses');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const networkTargetRef = config.networkTargetRef?.trim();
|
|
638
|
+
if (networkTargetRef && config.target) {
|
|
639
|
+
throw new Error('Choose either an inline target or a Network Target, not both');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
let target: ISpecialForwardResolvedTarget;
|
|
643
|
+
let networkTargetName: string | undefined;
|
|
644
|
+
if (networkTargetRef) {
|
|
645
|
+
const networkTarget = this.referenceResolver.getTarget(networkTargetRef);
|
|
646
|
+
if (!networkTarget) {
|
|
647
|
+
throw new Error(`Network Target '${networkTargetRef}' was not found`);
|
|
648
|
+
}
|
|
649
|
+
target = {
|
|
650
|
+
host: networkTarget.host,
|
|
651
|
+
port: networkTarget.port,
|
|
652
|
+
};
|
|
653
|
+
networkTargetName = networkTarget.name;
|
|
654
|
+
} else if (config.target) {
|
|
655
|
+
target = config.target;
|
|
656
|
+
} else {
|
|
657
|
+
throw new Error('An inline target or Network Target is required');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
route: buildLetsEncryptHttp01Route(config, target),
|
|
662
|
+
metadata: {
|
|
663
|
+
ownerType: 'operator',
|
|
664
|
+
managedRouteKind: letsEncryptHttp01ManagedRouteKind,
|
|
665
|
+
sourceBindings: [{
|
|
666
|
+
sourceProfileRef: publicProfile.id,
|
|
667
|
+
sourceProfileName: publicProfile.name,
|
|
668
|
+
}],
|
|
669
|
+
...(networkTargetRef
|
|
670
|
+
? {
|
|
671
|
+
networkTargetRef,
|
|
672
|
+
networkTargetName,
|
|
673
|
+
lastResolvedAt: Date.now(),
|
|
674
|
+
}
|
|
675
|
+
: {}),
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private hasReservedManagedMetadata(metadata?: Partial<IRouteMetadata>): boolean {
|
|
681
|
+
return Boolean(
|
|
682
|
+
metadata
|
|
683
|
+
&& (
|
|
684
|
+
metadata.ownerType !== undefined
|
|
685
|
+
|| metadata.gatewayClientType !== undefined
|
|
686
|
+
|| metadata.gatewayClientId !== undefined
|
|
687
|
+
|| metadata.gatewayClientAppId !== undefined
|
|
688
|
+
|| metadata.externalKey !== undefined
|
|
689
|
+
|| metadata.managedRouteKind !== undefined
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
private isManagedMetadata(metadata?: Partial<IRouteMetadata>): boolean {
|
|
695
|
+
return Boolean(
|
|
696
|
+
metadata
|
|
697
|
+
&& (
|
|
698
|
+
metadata.ownerType === 'gatewayClient'
|
|
699
|
+
|| metadata.managedRouteKind !== undefined
|
|
700
|
+
|| metadata.gatewayClientId
|
|
701
|
+
|| metadata.externalKey
|
|
702
|
+
),
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private isManagedRoute(storedRoute: IRoute): boolean {
|
|
707
|
+
return storedRoute.origin === 'api' && this.isManagedMetadata(storedRoute.metadata);
|
|
708
|
+
}
|
|
709
|
+
|
|
474
710
|
private normalizeRouteMetadata(metadata?: Partial<IRouteMetadata>): IRouteMetadata | undefined {
|
|
475
711
|
if (!metadata) {
|
|
476
712
|
return undefined;
|
|
@@ -500,6 +736,9 @@ export class RouteConfigManager {
|
|
|
500
736
|
gatewayClientId: normalizeString(metadata.gatewayClientId),
|
|
501
737
|
gatewayClientAppId: normalizeString(metadata.gatewayClientAppId),
|
|
502
738
|
externalKey: normalizeString(metadata.externalKey),
|
|
739
|
+
managedRouteKind: metadata.managedRouteKind === letsEncryptHttp01ManagedRouteKind
|
|
740
|
+
? metadata.managedRouteKind
|
|
741
|
+
: undefined,
|
|
503
742
|
};
|
|
504
743
|
|
|
505
744
|
if (!normalized.networkTargetRef) {
|
|
@@ -515,7 +754,13 @@ export class RouteConfigManager {
|
|
|
515
754
|
normalized.externalKey = undefined;
|
|
516
755
|
}
|
|
517
756
|
|
|
518
|
-
|
|
757
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
758
|
+
if (value === undefined) {
|
|
759
|
+
delete (normalized as Record<string, unknown>)[key];
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (Object.keys(normalized).length === 0) {
|
|
519
764
|
return undefined;
|
|
520
765
|
}
|
|
521
766
|
|
|
@@ -807,13 +1052,7 @@ export class RouteConfigManager {
|
|
|
807
1052
|
}
|
|
808
1053
|
|
|
809
1054
|
private isManagedAccessRoute(storedRoute: IRoute): boolean {
|
|
810
|
-
|
|
811
|
-
if (storedRoute.origin !== 'api' || !metadata) {
|
|
812
|
-
return false;
|
|
813
|
-
}
|
|
814
|
-
return metadata.ownerType === 'gatewayClient'
|
|
815
|
-
|| Boolean(metadata.gatewayClientId)
|
|
816
|
-
|| Boolean(metadata.externalKey);
|
|
1055
|
+
return this.isManagedRoute(storedRoute);
|
|
817
1056
|
}
|
|
818
1057
|
|
|
819
1058
|
private prepareRouteForApply(
|
|
@@ -18,9 +18,9 @@ interface IRedirectCandidate {
|
|
|
18
18
|
remoteIngress?: IRouteRemoteIngress;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
interface
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
interface IRedirectConflictAssessment {
|
|
22
|
+
status: 'covered' | 'skipped';
|
|
23
|
+
routeNames: string[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export interface IHttpRedirectDerivationResult {
|
|
@@ -40,7 +40,7 @@ export function deriveHttpRedirectConfiguration(
|
|
|
40
40
|
const conflict = findHttpConflict(candidate, httpRoutes);
|
|
41
41
|
const redirectInfo: IHttpRedirectInfo = {
|
|
42
42
|
id: candidate.id,
|
|
43
|
-
status: conflict
|
|
43
|
+
status: conflict?.status || 'active',
|
|
44
44
|
domainPattern: candidate.domainPattern,
|
|
45
45
|
pathPattern: candidate.pathPattern,
|
|
46
46
|
fromTemplate: 'http://{domain}{path}',
|
|
@@ -49,12 +49,12 @@ export function deriveHttpRedirectConfiguration(
|
|
|
49
49
|
priority: REDIRECT_PRIORITY,
|
|
50
50
|
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
|
|
51
51
|
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
|
|
52
|
-
coveredByRouteNames: conflict
|
|
52
|
+
coveredByRouteNames: conflict?.routeNames || [],
|
|
53
53
|
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
|
|
54
54
|
notes: conflict
|
|
55
|
-
? conflict.
|
|
55
|
+
? conflict.status === 'covered'
|
|
56
56
|
? 'An explicit HTTP route already covers this redirect scope.'
|
|
57
|
-
: 'Skipped because
|
|
57
|
+
: 'Skipped because one or more explicit HTTP routes overlap at equal or lower priority.'
|
|
58
58
|
: undefined,
|
|
59
59
|
};
|
|
60
60
|
|
|
@@ -140,19 +140,27 @@ function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
|
|
|
140
140
|
function findHttpConflict(
|
|
141
141
|
candidate: IRedirectCandidate,
|
|
142
142
|
httpRoutes: plugins.smartproxy.IRouteConfig[],
|
|
143
|
-
):
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
143
|
+
): IRedirectConflictAssessment | undefined {
|
|
144
|
+
const overlaps = httpRoutes.filter((route) => httpRouteOverlapsCandidate(route, candidate));
|
|
145
|
+
const coveringRoutes = overlaps.filter((route) => httpRouteCoversCandidate(route, candidate));
|
|
146
|
+
if (coveringRoutes.length > 0) {
|
|
149
147
|
return {
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
status: 'covered',
|
|
149
|
+
routeNames: coveringRoutes.map(getRouteDisplayName).sort(),
|
|
152
150
|
};
|
|
153
151
|
}
|
|
154
152
|
|
|
155
|
-
|
|
153
|
+
const unsafeOverlaps = overlaps.filter((route) => {
|
|
154
|
+
const priority = typeof route.priority === 'number' ? route.priority : 0;
|
|
155
|
+
return !route.match.path || priority <= REDIRECT_PRIORITY;
|
|
156
|
+
});
|
|
157
|
+
if (unsafeOverlaps.length === 0) {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
status: 'skipped',
|
|
162
|
+
routeNames: unsafeOverlaps.map(getRouteDisplayName).sort(),
|
|
163
|
+
};
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
function httpRouteOverlapsCandidate(
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ILetsEncryptHttp01ForwardConfig,
|
|
3
|
+
TManagedRouteKind,
|
|
4
|
+
} from '../../ts_interfaces/data/route-management.js';
|
|
5
|
+
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
|
6
|
+
|
|
7
|
+
export const letsEncryptHttp01ManagedRouteKind: TManagedRouteKind =
|
|
8
|
+
'letsencrypt-http01-forward';
|
|
9
|
+
export const letsEncryptHttp01Path = '/.well-known/acme-challenge/*';
|
|
10
|
+
export const letsEncryptHttp01Priority = 1000;
|
|
11
|
+
|
|
12
|
+
export interface ISpecialForwardResolvedTarget {
|
|
13
|
+
host: string | string[];
|
|
14
|
+
port: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compile operator input into the immutable portion of a managed HTTP-01 route.
|
|
19
|
+
*/
|
|
20
|
+
export function buildLetsEncryptHttp01Route(
|
|
21
|
+
configArg: ILetsEncryptHttp01ForwardConfig,
|
|
22
|
+
targetArg: ISpecialForwardResolvedTarget,
|
|
23
|
+
): IDcRouterRouteConfig {
|
|
24
|
+
const name = configArg.name.trim();
|
|
25
|
+
if (!name) {
|
|
26
|
+
throw new Error('Special Forward name is required');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const domains = normalizeLetsEncryptHttp01Domains(configArg.domains);
|
|
30
|
+
const target = normalizeTarget(targetArg);
|
|
31
|
+
const edgeFilter = configArg.remoteIngress?.edgeFilter
|
|
32
|
+
?.map((entry) => entry.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
description: 'Managed Let\'s Encrypt HTTP-01 challenge forward',
|
|
38
|
+
priority: letsEncryptHttp01Priority,
|
|
39
|
+
tags: ['managed', 'special-forward', 'letsencrypt', 'http01'],
|
|
40
|
+
match: {
|
|
41
|
+
ports: 80,
|
|
42
|
+
domains,
|
|
43
|
+
path: letsEncryptHttp01Path,
|
|
44
|
+
protocol: 'http',
|
|
45
|
+
transport: 'tcp',
|
|
46
|
+
},
|
|
47
|
+
action: {
|
|
48
|
+
type: 'forward',
|
|
49
|
+
targets: [target],
|
|
50
|
+
},
|
|
51
|
+
...(configArg.remoteIngress?.enabled
|
|
52
|
+
? {
|
|
53
|
+
remoteIngress: {
|
|
54
|
+
enabled: true,
|
|
55
|
+
...(edgeFilter?.length ? { edgeFilter } : {}),
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeLetsEncryptHttp01Domains(domainsArg: string[]): string[] {
|
|
63
|
+
if (!Array.isArray(domainsArg)) {
|
|
64
|
+
throw new Error('At least one domain is required');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const domains = [...new Set(domainsArg.map((domain) => {
|
|
68
|
+
return domain.trim().toLowerCase().replace(/\.$/, '');
|
|
69
|
+
}).filter(Boolean))];
|
|
70
|
+
|
|
71
|
+
if (domains.length === 0) {
|
|
72
|
+
throw new Error('At least one domain is required');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const domain of domains) {
|
|
76
|
+
if (domain.includes('*')) {
|
|
77
|
+
throw new Error(`HTTP-01 does not support wildcard domain '${domain}'`);
|
|
78
|
+
}
|
|
79
|
+
if (!isValidPublicHostname(domain)) {
|
|
80
|
+
throw new Error(`Invalid public hostname '${domain}'`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return domains;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeTarget(targetArg: ISpecialForwardResolvedTarget): ISpecialForwardResolvedTarget {
|
|
88
|
+
const hosts = (Array.isArray(targetArg.host) ? targetArg.host : [targetArg.host])
|
|
89
|
+
.map((host) => host.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
if (hosts.length === 0) {
|
|
92
|
+
throw new Error('A target host or Network Target is required');
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isInteger(targetArg.port) || targetArg.port < 1 || targetArg.port > 65535) {
|
|
95
|
+
throw new Error('Target port must be between 1 and 65535');
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
host: hosts.length === 1 ? hosts[0] : hosts,
|
|
99
|
+
port: targetArg.port,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isValidPublicHostname(hostname: string): boolean {
|
|
104
|
+
if (hostname.length > 253 || !hostname.includes('.')) return false;
|
|
105
|
+
return hostname.split('.').every((label) => {
|
|
106
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label);
|
|
107
|
+
});
|
|
108
|
+
}
|