@serve.zone/dcrouter 17.8.0 → 17.9.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 (29) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +373 -370
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/config/classes.db-seeder.js +9 -1
  5. package/dist_ts/config/helpers.http-redirects.js +28 -1
  6. package/dist_ts/config/helpers.special-forwards.js +12 -9
  7. package/dist_ts/email/classes.email-route-builder.js +5 -1
  8. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +23 -2
  9. package/dist_ts/remoteingress/edge-derivation-routes.js +5 -1
  10. package/dist_ts/security/classes.route-policy-augmenter.d.ts +10 -6
  11. package/dist_ts/security/classes.route-policy-augmenter.js +65 -51
  12. package/dist_ts_interfaces/data/remoteingress.d.ts +14 -2
  13. package/dist_ts_migrations/index.js +66 -2
  14. package/dist_ts_web/00_commitinfo_data.js +1 -1
  15. package/dist_ts_web/elements/network/ops-view-routes.js +41 -1
  16. package/dist_ts_web/elements/network/special-forward-dialog.js +2 -2
  17. package/package.json +4 -4
  18. package/readme.plan.md +22 -0
  19. package/ts/00_commitinfo_data.ts +1 -1
  20. package/ts/config/classes.db-seeder.ts +8 -0
  21. package/ts/config/helpers.http-redirects.ts +31 -1
  22. package/ts/config/helpers.special-forwards.ts +11 -8
  23. package/ts/email/classes.email-route-builder.ts +4 -0
  24. package/ts/opsserver/handlers/gatewayclient.handler.ts +24 -1
  25. package/ts/remoteingress/edge-derivation-routes.ts +4 -0
  26. package/ts/security/classes.route-policy-augmenter.ts +70 -57
  27. package/ts_web/00_commitinfo_data.ts +1 -1
  28. package/ts_web/elements/network/ops-view-routes.ts +38 -0
  29. package/ts_web/elements/network/special-forward-dialog.ts +1 -1
@@ -1369,6 +1369,19 @@ export class GatewayClientHandler {
1369
1369
  gatewayDnsMode: dnsMode,
1370
1370
  };
1371
1371
  const normalizedRoute = this.normalizeGatewayClientRoute(validatedRoute, resolvedOwnership, externalKey);
1372
+ if (!normalizedRoute.ingress) {
1373
+ const existingIngress = existingRoute?.route.ingress;
1374
+ normalizedRoute.ingress = existingIngress
1375
+ ? structuredClone(existingIngress)
1376
+ : {
1377
+ directHub: true,
1378
+ smartVpn: true,
1379
+ };
1380
+ console.warn(
1381
+ `Gateway client route '${externalKey}' omitted route.ingress; `
1382
+ + `${existingIngress ? 'preserved stored policy' : 'applied legacy directHub+smartVpn compatibility policy'}`,
1383
+ );
1384
+ }
1372
1385
 
1373
1386
  if (existingRoute) {
1374
1387
  const snapshot = structuredClone(existingRoute);
@@ -1596,7 +1609,7 @@ export class GatewayClientHandler {
1596
1609
  };
1597
1610
 
1598
1611
  const route = requireObject(routeArg, 'route');
1599
- requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'remoteIngress'], 'route');
1612
+ requireOnlyKeys(route, ['name', 'match', 'action', 'priority', 'managedRouteKind', 'ingress', 'remoteIngress'], 'route');
1600
1613
  const name = requireString(route.name, 'route.name');
1601
1614
 
1602
1615
  const match = requireObject(route.match, 'route.match');
@@ -1670,6 +1683,15 @@ export class GatewayClientHandler {
1670
1683
  if (route.managedRouteKind !== 'letsencrypt-http01-forward') fail('route.managedRouteKind is unsupported');
1671
1684
  managedRouteKind = route.managedRouteKind as 'letsencrypt-http01-forward';
1672
1685
  }
1686
+ let ingress: { directHub: boolean; smartVpn: boolean } | undefined;
1687
+ if (route.ingress !== undefined) {
1688
+ const ingressValue = requireObject(route.ingress, 'route.ingress');
1689
+ requireOnlyKeys(ingressValue, ['directHub', 'smartVpn'], 'route.ingress');
1690
+ ingress = {
1691
+ directHub: requireBoolean(ingressValue.directHub, 'route.ingress.directHub'),
1692
+ smartVpn: requireBoolean(ingressValue.smartVpn, 'route.ingress.smartVpn'),
1693
+ };
1694
+ }
1673
1695
  let remoteIngress: { enabled: boolean; edgeFilter?: string[] } | undefined;
1674
1696
  if (route.remoteIngress !== undefined) {
1675
1697
  const remoteValue = requireObject(route.remoteIngress, 'route.remoteIngress');
@@ -1694,6 +1716,7 @@ export class GatewayClientHandler {
1694
1716
  action: { type: 'forward', targets, ...(tls ? { tls } : {}), ...(websocket ? { websocket } : {}) },
1695
1717
  ...(priority !== undefined ? { priority } : {}),
1696
1718
  ...(managedRouteKind ? { managedRouteKind } : {}),
1719
+ ...(ingress ? { ingress } : {}),
1697
1720
  ...(remoteIngress ? { remoteIngress } : {}),
1698
1721
  };
1699
1722
  }
@@ -21,6 +21,10 @@ export function buildEdgeOnlyDerivationRoutes(options: { dnsNsDomains?: string[]
21
21
  type: 'forward',
22
22
  targets: [{ host: '127.0.0.1', port: 53 }],
23
23
  },
24
+ ingress: {
25
+ directHub: false,
26
+ smartVpn: false,
27
+ },
24
28
  remoteIngress: {
25
29
  enabled: true,
26
30
  edgeFilter: [REMOTE_INGRESS_DNS_TAG],
@@ -4,63 +4,70 @@ import type { ISecurityCompiledPolicy } from '../../ts_interfaces/data/security-
4
4
  import type { DcRouter } from '../classes.dcrouter.js';
5
5
 
6
6
  type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
7
+ type TIngressOrigin = NonNullable<
8
+ NonNullable<plugins.smartproxy.IRouteConfig['security']>['ingress']
9
+ >['allowedOrigins'][number];
7
10
 
8
11
  /**
9
12
  * Pure route-policy transformations applied before routes reach SmartProxy:
10
- * per-listener inbound PROXY protocol policies (driven by RemoteIngress and
11
- * VPN) and merging of compiled security policies.
13
+ * route-level trusted ingress authorization, listener-level inbound PROXY
14
+ * protocol policy, and merging of compiled security policies.
12
15
  */
13
16
  export class RoutePolicyAugmenter {
14
17
  constructor(private dcRouterRef: DcRouter) {}
15
18
 
16
19
  /**
17
- * Ensure every route on a shared listener carries a compatible
18
- * inboundProxyProtocol policy, deriving defaults from RemoteIngress/VPN.
20
+ * Compile each dcrouter ingress policy into SmartProxy route security, then
21
+ * derive one compatible PROXY protocol mode for every shared listener.
22
+ *
23
+ * The listener mode only controls parsing. Route security remains the
24
+ * authorization boundary, so a direct request cannot fall through to a
25
+ * wildcard route that only permits RemoteIngress or SmartVPN.
19
26
  */
20
27
  public applyInboundProxyProtocolPolicies(
21
28
  routes: plugins.smartproxy.IRouteConfig[],
22
29
  ): plugins.smartproxy.IRouteConfig[] {
23
- const policiesByListener = new Map<string, TInboundProxyProtocolPolicy>();
24
-
25
- for (const route of routes) {
26
- const policy = route.match?.inboundProxyProtocol || this.getDesiredInboundProxyProtocolPolicy(route);
27
- if (!policy) {
28
- continue;
30
+ const routesWithIngressSecurity = routes.map((route) => {
31
+ const allowedOrigins = this.getAllowedIngressOrigins(route);
32
+ if (allowedOrigins.length === 0) {
33
+ throw new Error(`Route '${route.name}' has no enabled ingress path`);
29
34
  }
35
+ return {
36
+ ...route,
37
+ security: {
38
+ ...route.security,
39
+ ingress: {
40
+ allowedOrigins,
41
+ },
42
+ },
43
+ };
44
+ });
45
+
46
+ const originsByListener = new Map<string, Set<TIngressOrigin>>();
47
+ for (const route of routesWithIngressSecurity) {
48
+ const origins = route.security!.ingress!.allowedOrigins;
30
49
  for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
31
- const mergedPolicy = this.mergeInboundProxyProtocolPolicies(
32
- policiesByListener.get(listenerKey),
33
- policy,
34
- );
35
- if (mergedPolicy) {
36
- policiesByListener.set(listenerKey, mergedPolicy);
50
+ const listenerOrigins = originsByListener.get(listenerKey) || new Set<TIngressOrigin>();
51
+ for (const origin of origins) {
52
+ listenerOrigins.add(origin);
37
53
  }
54
+ originsByListener.set(listenerKey, listenerOrigins);
38
55
  }
39
56
  }
40
57
 
41
- if (policiesByListener.size === 0) {
42
- return routes;
43
- }
44
-
45
- return routes.map((route) => {
46
- if (route.match?.inboundProxyProtocol) {
47
- return route;
48
- }
49
- let listenerPolicy: TInboundProxyProtocolPolicy | undefined;
58
+ return routesWithIngressSecurity.map((route) => {
59
+ const listenerOrigins = new Set<TIngressOrigin>();
50
60
  for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
51
- listenerPolicy = this.mergeInboundProxyProtocolPolicies(
52
- listenerPolicy,
53
- policiesByListener.get(listenerKey),
54
- );
55
- }
56
- if (!listenerPolicy) {
57
- return route;
61
+ for (const origin of originsByListener.get(listenerKey) || []) {
62
+ listenerOrigins.add(origin);
63
+ }
58
64
  }
65
+ const inboundProxyProtocol = this.getListenerProxyProtocolPolicy(listenerOrigins);
59
66
  return {
60
67
  ...route,
61
68
  match: {
62
69
  ...route.match,
63
- inboundProxyProtocol: listenerPolicy,
70
+ inboundProxyProtocol,
64
71
  },
65
72
  };
66
73
  });
@@ -92,21 +99,40 @@ export class RoutePolicyAugmenter {
92
99
  };
93
100
  }
94
101
 
95
- private getDesiredInboundProxyProtocolPolicy(
102
+ private getAllowedIngressOrigins(
96
103
  route: plugins.smartproxy.IRouteConfig,
97
- ): TInboundProxyProtocolPolicy | undefined {
104
+ ): TIngressOrigin[] {
98
105
  const dcRoute = route as IDcRouterRouteConfig;
99
- if (this.dcRouterRef.isRemoteIngressHubEnabled() && dcRoute.remoteIngress?.enabled) {
100
- const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
101
- if (ports.some((port) => port === 25 || port === 587)) {
102
- return { mode: 'required' };
103
- }
104
- return { mode: 'optional' };
106
+ const origins = new Set<TIngressOrigin>();
107
+
108
+ if (dcRoute.ingress) {
109
+ if (dcRoute.ingress.directHub) origins.add('direct');
110
+ if (dcRoute.ingress.smartVpn) origins.add('smartVpn');
111
+ } else if (dcRoute.vpnOnly) {
112
+ origins.add('smartVpn');
113
+ } else {
114
+ // Compatibility for runtime routes and producers predating the explicit
115
+ // contract. Persisted routes are backfilled by the versioned migration.
116
+ origins.add('direct');
117
+ origins.add('smartVpn');
105
118
  }
106
- if (this.dcRouterRef.options.vpnConfig?.enabled) {
107
- return { mode: 'optional' };
119
+
120
+ if (dcRoute.remoteIngress?.enabled) {
121
+ origins.add('trustedProxy');
108
122
  }
109
- return undefined;
123
+
124
+ return [...origins];
125
+ }
126
+
127
+ private getListenerProxyProtocolPolicy(
128
+ origins: Set<TIngressOrigin>,
129
+ ): TInboundProxyProtocolPolicy {
130
+ const allowsDirect = origins.has('direct');
131
+ const allowsProxied = origins.has('trustedProxy') || origins.has('smartVpn');
132
+
133
+ if (allowsDirect && allowsProxied) return { mode: 'optional' };
134
+ if (allowsProxied) return { mode: 'required' };
135
+ return { mode: 'reject' };
110
136
  }
111
137
 
112
138
  private getInboundProxyListenerKeys(route: plugins.smartproxy.IRouteConfig): string[] {
@@ -124,17 +150,4 @@ export class RoutePolicyAugmenter {
124
150
  }
125
151
  return keys;
126
152
  }
127
-
128
- private mergeInboundProxyProtocolPolicies(
129
- current?: TInboundProxyProtocolPolicy,
130
- next?: TInboundProxyProtocolPolicy,
131
- ): TInboundProxyProtocolPolicy | undefined {
132
- if (!current) return next;
133
- if (!next) return current;
134
- if (current.mode === 'required') return current;
135
- if (next.mode === 'required') return next;
136
- if (current.mode === 'optional') return current;
137
- if (next.mode === 'optional') return next;
138
- return current;
139
- }
140
153
  }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '17.8.0',
6
+ version: '17.9.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -500,6 +500,8 @@ export class OpsViewRoutes extends DeesElement {
500
500
  tags.push(mr.origin);
501
501
  if (!mr.enabled) tags.push('disabled');
502
502
  if (mr.route.vpnOnly) tags.push('vpn-only');
503
+ if (mr.route.ingress?.directHub) tags.push('direct-hub');
504
+ if (mr.route.ingress?.smartVpn && !mr.route.vpnOnly) tags.push('smartvpn');
503
505
 
504
506
  return {
505
507
  ...mr.route,
@@ -776,12 +778,18 @@ export class OpsViewRoutes extends DeesElement {
776
778
  const meta = merged.metadata;
777
779
  const isSystemManaged = this.isSystemManagedRoute(merged);
778
780
  const sourceBindingSummary = this.describeSourcePolicy(meta);
781
+ const ingressSummary = [
782
+ ...(merged.route.ingress?.directHub ? ['Direct hub'] : []),
783
+ ...(merged.route.ingress?.smartVpn ? ['SmartVPN'] : []),
784
+ ...(merged.route.remoteIngress?.enabled ? ['RemoteIngress'] : []),
785
+ ].join(', ') || 'None';
779
786
  await DeesModal.createAndShow({
780
787
  heading: `Route: ${merged.route.name}`,
781
788
  content: html`
782
789
  <div style="color: #ccc; padding: 8px 0;">
783
790
  <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
784
791
  <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
792
+ <p>Ingress: <strong>${ingressSummary}</strong></p>
785
793
  ${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
786
794
  <p>ID: <code style="color: #888;">${merged.id}</code></p>
787
795
  ${isSystemManaged ? html`<p>This route is managed by the system, a gateway client, or a specialized workflow.</p>` : ''}
@@ -908,6 +916,8 @@ export class OpsViewRoutes extends DeesElement {
908
916
  : '';
909
917
  const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
910
918
  const currentVpnOnly = route.vpnOnly === true;
919
+ const currentDirectHub = route.ingress?.directHub === true;
920
+ const currentSmartVpn = route.ingress?.smartVpn === true;
911
921
  const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
912
922
  const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
913
923
  const sourceProfileOptions = getSourceProfileOptions(profiles);
@@ -947,6 +957,7 @@ export class OpsViewRoutes extends DeesElement {
947
957
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
948
958
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
949
959
  <dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
960
+ <dees-input-checkbox .key=${'directHub'} .label=${'Listen on Hub directly'} .description=${'Accept direct connections on the gateway hub. Pair private routes with the LOCAL NETWORK source policy.'} .value=${currentDirectHub}></dees-input-checkbox>
950
961
  <dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${currentVpnOnly}></dees-input-checkbox>
951
962
  <dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
952
963
  <div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
@@ -1002,6 +1013,15 @@ export class OpsViewRoutes extends DeesElement {
1002
1013
  ? formData.remoteIngressEdgeFilter.filter(Boolean)
1003
1014
  : [];
1004
1015
  const vpnOnly = Boolean(formData.vpnOnly);
1016
+ const directHub = Boolean(formData.directHub);
1017
+ if (vpnOnly && (directHub || remoteIngressEnabled)) {
1018
+ alert('VPN only cannot be combined with direct hub or RemoteIngress.');
1019
+ return;
1020
+ }
1021
+ if (!directHub && !vpnOnly && !currentSmartVpn && !remoteIngressEnabled) {
1022
+ alert('Enable at least one ingress path.');
1023
+ return;
1024
+ }
1005
1025
 
1006
1026
  const updatedRoute: any = {
1007
1027
  name: formData.name,
@@ -1018,6 +1038,10 @@ export class OpsViewRoutes extends DeesElement {
1018
1038
  },
1019
1039
  ],
1020
1040
  },
1041
+ ingress: {
1042
+ directHub,
1043
+ smartVpn: vpnOnly || currentSmartVpn,
1044
+ },
1021
1045
  vpnOnly: vpnOnly ? true : null,
1022
1046
  remoteIngress: remoteIngressEnabled
1023
1047
  ? {
@@ -1121,6 +1145,7 @@ export class OpsViewRoutes extends DeesElement {
1121
1145
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
1122
1146
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
1123
1147
  <dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
1148
+ <dees-input-checkbox .key=${'directHub'} .label=${'Listen on Hub directly'} .description=${'Off by default. Pair private routes with the LOCAL NETWORK source policy.'} .value=${false}></dees-input-checkbox>
1124
1149
  <dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${false}></dees-input-checkbox>
1125
1150
  <dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
1126
1151
  <div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
@@ -1176,6 +1201,15 @@ export class OpsViewRoutes extends DeesElement {
1176
1201
  ? formData.remoteIngressEdgeFilter.filter(Boolean)
1177
1202
  : [];
1178
1203
  const vpnOnly = Boolean(formData.vpnOnly);
1204
+ const directHub = Boolean(formData.directHub);
1205
+ if (vpnOnly && (directHub || remoteIngressEnabled)) {
1206
+ alert('VPN only cannot be combined with direct hub or RemoteIngress.');
1207
+ return;
1208
+ }
1209
+ if (!directHub && !vpnOnly && !remoteIngressEnabled) {
1210
+ alert('Enable at least one ingress path.');
1211
+ return;
1212
+ }
1179
1213
 
1180
1214
  const route: any = {
1181
1215
  name: formData.name,
@@ -1192,6 +1226,10 @@ export class OpsViewRoutes extends DeesElement {
1192
1226
  },
1193
1227
  ],
1194
1228
  },
1229
+ ingress: {
1230
+ directHub,
1231
+ smartVpn: vpnOnly,
1232
+ },
1195
1233
  ...(vpnOnly ? { vpnOnly: true } : {}),
1196
1234
  ...(remoteIngressEnabled
1197
1235
  ? {
@@ -93,7 +93,7 @@ export async function showLetsEncryptHttp01ForwardDialog(options: {
93
93
  <dees-input-checkbox
94
94
  .key=${'remoteIngressEnabled'}
95
95
  .label=${'Enable Remote Ingress'}
96
- .value=${existing?.route.remoteIngress?.enabled === true}
96
+ .value=${existing ? existing.route.remoteIngress?.enabled === true : true}
97
97
  ></dees-input-checkbox>
98
98
  <dees-input-list
99
99
  .key=${'remoteIngressEdgeFilter'}