@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.
Files changed (42) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +11786 -7664
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/config/classes.route-config-manager.d.ts +24 -4
  5. package/dist_ts/config/classes.route-config-manager.js +195 -34
  6. package/dist_ts/config/helpers.http-redirects.js +21 -12
  7. package/dist_ts/config/helpers.special-forwards.d.ts +14 -0
  8. package/dist_ts/config/helpers.special-forwards.js +85 -0
  9. package/dist_ts/opsserver/handlers/gatewayclient.handler.d.ts +1 -0
  10. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +56 -9
  11. package/dist_ts/opsserver/handlers/route-management.handler.js +36 -1
  12. package/dist_ts_interfaces/data/route-management.d.ts +19 -1
  13. package/dist_ts_interfaces/data/route-management.js +5 -1
  14. package/dist_ts_interfaces/requests/route-management.d.ts +50 -1
  15. package/dist_ts_web/00_commitinfo_data.js +1 -1
  16. package/dist_ts_web/appstate/routes.d.ts +10 -0
  17. package/dist_ts_web/appstate/routes.js +67 -1
  18. package/dist_ts_web/elements/network/ops-view-redirects.d.ts +1 -0
  19. package/dist_ts_web/elements/network/ops-view-redirects.js +12 -4
  20. package/dist_ts_web/elements/network/ops-view-routes.d.ts +20 -1
  21. package/dist_ts_web/elements/network/ops-view-routes.js +182 -52
  22. package/dist_ts_web/elements/network/ops-view-special-forwards.d.ts +12 -0
  23. package/dist_ts_web/elements/network/ops-view-special-forwards.js +375 -0
  24. package/dist_ts_web/elements/ops-dashboard.js +1 -3
  25. package/dist_ts_web/router.js +10 -2
  26. package/package.json +5 -5
  27. package/readme.md +7 -3
  28. package/ts/00_commitinfo_data.ts +1 -1
  29. package/ts/config/classes.route-config-manager.ts +271 -32
  30. package/ts/config/helpers.http-redirects.ts +24 -16
  31. package/ts/config/helpers.special-forwards.ts +108 -0
  32. package/ts/opsserver/handlers/gatewayclient.handler.ts +55 -8
  33. package/ts/opsserver/handlers/route-management.handler.ts +59 -0
  34. package/ts_apiclient/readme.md +2 -2
  35. package/ts_web/00_commitinfo_data.ts +1 -1
  36. package/ts_web/appstate/routes.ts +84 -0
  37. package/ts_web/elements/network/ops-view-redirects.ts +5 -1
  38. package/ts_web/elements/network/ops-view-routes.ts +175 -31
  39. package/ts_web/elements/network/ops-view-special-forwards.ts +352 -0
  40. package/ts_web/elements/ops-dashboard.ts +0 -2
  41. package/ts_web/readme.md +9 -0
  42. package/ts_web/router.ts +10 -1
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '16.1.1',
6
+ version: '16.2.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -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
- const mergedAction = patch.route.action
223
- ? { ...stored.route.action, ...patch.route.action }
224
- : stored.route.action;
225
- // Handle explicit null to remove nested action properties (e.g., tls: null)
226
- if (patch.route.action) {
227
- for (const [key, val] of Object.entries(patch.route.action)) {
228
- if (val === null) {
229
- delete (mergedAction as any)[key];
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
- // Handle explicit null to remove optional top-level route properties (e.g., remoteIngress: null)
236
- for (const [key, val] of Object.entries(patch.route)) {
237
- if (val === null && key !== 'action' && key !== 'match') {
238
- delete (mergedRoute as any)[key];
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
- stored.route = mergedRoute;
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
- ...stored.metadata,
250
- ...patch.metadata,
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 deleteRoute(id: string): Promise<IRouteMutationResult> {
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
- if (Object.values(normalized).every((value) => value === undefined)) {
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
- const metadata = storedRoute.metadata;
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 IRedirectConflict {
22
- routeName: string;
23
- covers: boolean;
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 ? (conflict.covers ? 'covered' : 'skipped') : 'active',
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 ? [conflict.routeName] : [],
52
+ coveredByRouteNames: conflict?.routeNames || [],
53
53
  remoteIngress: Boolean(candidate.remoteIngress?.enabled),
54
54
  notes: conflict
55
- ? conflict.covers
55
+ ? conflict.status === 'covered'
56
56
  ? 'An explicit HTTP route already covers this redirect scope.'
57
- : 'Skipped because an explicit HTTP route overlaps this redirect scope.'
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
- ): IRedirectConflict | undefined {
144
- for (const route of httpRoutes) {
145
- if (!httpRouteOverlapsCandidate(route, candidate)) {
146
- continue;
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
- routeName: getRouteDisplayName(route),
151
- covers: httpRouteCoversCandidate(route, candidate),
148
+ status: 'covered',
149
+ routeNames: coveringRoutes.map(getRouteDisplayName).sort(),
152
150
  };
153
151
  }
154
152
 
155
- return undefined;
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
+ }