@serve.zone/dcrouter 15.6.1 → 15.8.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 (50) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +423 -407
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +4 -1
  5. package/dist_ts/classes.dcrouter.js +45 -1
  6. package/dist_ts/db/documents/classes.email-server-settings.doc.d.ts +2 -1
  7. package/dist_ts/db/documents/classes.email-server-settings.doc.js +8 -2
  8. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +2 -1
  9. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +8 -2
  10. package/dist_ts/email/classes.email-settings.manager.d.ts +5 -1
  11. package/dist_ts/email/classes.email-settings.manager.js +24 -1
  12. package/dist_ts/opsserver/handlers/config.handler.js +2 -1
  13. package/dist_ts/opsserver/handlers/email-settings.handler.js +2 -1
  14. package/dist_ts/opsserver/handlers/remoteingress.handler.js +3 -2
  15. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +6 -2
  16. package/dist_ts/remoteingress/classes.remoteingress-manager.js +65 -2
  17. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +6 -0
  18. package/dist_ts/remoteingress/classes.tunnel-manager.js +42 -1
  19. package/dist_ts_apiclient/classes.remoteingress.d.ts +10 -0
  20. package/dist_ts_apiclient/classes.remoteingress.js +23 -1
  21. package/dist_ts_interfaces/data/email-settings.d.ts +4 -0
  22. package/dist_ts_interfaces/data/remoteingress.d.ts +18 -0
  23. package/dist_ts_interfaces/requests/config.d.ts +1 -0
  24. package/dist_ts_interfaces/requests/remoteingress.d.ts +3 -1
  25. package/dist_ts_migrations/index.d.ts +1 -0
  26. package/dist_ts_migrations/index.js +80 -9
  27. package/dist_ts_web/00_commitinfo_data.js +1 -1
  28. package/dist_ts_web/appstate/remoteingress.d.ts +2 -0
  29. package/dist_ts_web/appstate/remoteingress.js +3 -1
  30. package/dist_ts_web/elements/email/ops-view-email-domains.js +7 -1
  31. package/dist_ts_web/elements/network/ops-view-remoteingress.d.ts +2 -0
  32. package/dist_ts_web/elements/network/ops-view-remoteingress.js +54 -2
  33. package/package.json +5 -5
  34. package/readme.md +22 -0
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts/classes.dcrouter.ts +56 -4
  37. package/ts/db/documents/classes.email-server-settings.doc.ts +4 -1
  38. package/ts/db/documents/classes.remote-ingress-edge.doc.ts +4 -1
  39. package/ts/email/classes.email-settings.manager.ts +29 -0
  40. package/ts/opsserver/handlers/config.handler.ts +1 -0
  41. package/ts/opsserver/handlers/email-settings.handler.ts +1 -0
  42. package/ts/opsserver/handlers/remoteingress.handler.ts +2 -0
  43. package/ts/remoteingress/classes.remoteingress-manager.ts +78 -3
  44. package/ts/remoteingress/classes.tunnel-manager.ts +61 -0
  45. package/ts_apiclient/classes.remoteingress.ts +26 -0
  46. package/ts_apiclient/readme.md +8 -0
  47. package/ts_web/00_commitinfo_data.ts +1 -1
  48. package/ts_web/appstate/remoteingress.ts +4 -0
  49. package/ts_web/elements/email/ops-view-email-domains.ts +6 -0
  50. package/ts_web/elements/network/ops-view-remoteingress.ts +55 -1
@@ -63,6 +63,7 @@ export class RemoteIngressHandler {
63
63
  dataArg.tags,
64
64
  dataArg.autoDerivePorts ?? true,
65
65
  dataArg.performance,
66
+ dataArg.egress,
66
67
  ));
67
68
  return { success: true, edge };
68
69
  } catch (err: unknown) {
@@ -117,6 +118,7 @@ export class RemoteIngressHandler {
117
118
  autoDerivePorts: dataArg.autoDerivePorts,
118
119
  enabled: dataArg.enabled,
119
120
  performance: dataArg.performance,
121
+ egress: dataArg.egress,
120
122
  tags: dataArg.tags,
121
123
  });
122
124
 
@@ -1,5 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
- import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
2
+ import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressEgressPolicyConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
3
3
  import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
4
4
 
5
5
  export interface IRemoteIngressFirewallConfig {
@@ -30,6 +30,7 @@ const performanceIntegerMaxByField: Record<TPerformanceIntegerField, number> = {
30
30
  };
31
31
 
32
32
  const maxServerFirstPorts = 128;
33
+ const maxRemoteIngressEgressListEntries = 128;
33
34
  const defaultTunnelPort = 8443;
34
35
 
35
36
  function hasOwn(objectArg: object, keyArg: string): boolean {
@@ -79,6 +80,7 @@ export class RemoteIngressManager {
79
80
  enabled: doc.enabled,
80
81
  autoDerivePorts: doc.autoDerivePorts,
81
82
  performance: doc.performance,
83
+ egress: this.normalizeEgressPolicy(doc.egress),
82
84
  tags: doc.tags,
83
85
  createdAt: doc.createdAt,
84
86
  updatedAt: doc.updatedAt,
@@ -274,6 +276,7 @@ export class RemoteIngressManager {
274
276
  tags?: string[],
275
277
  autoDerivePorts: boolean = true,
276
278
  performance?: IRemoteIngressPerformanceConfig,
279
+ egress?: IRemoteIngressEgressPolicyConfig,
277
280
  ): Promise<IRemoteIngress> {
278
281
  const id = plugins.uuid.v4();
279
282
  const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -287,6 +290,7 @@ export class RemoteIngressManager {
287
290
  enabled: true,
288
291
  autoDerivePorts,
289
292
  performance,
293
+ egress: this.normalizeEgressPolicy(egress),
290
294
  tags: tags || [],
291
295
  createdAt: now,
292
296
  updatedAt: now,
@@ -324,6 +328,7 @@ export class RemoteIngressManager {
324
328
  autoDerivePorts?: boolean;
325
329
  enabled?: boolean;
326
330
  performance?: IRemoteIngressPerformanceConfig;
331
+ egress?: IRemoteIngressEgressPolicyConfig;
327
332
  tags?: string[];
328
333
  },
329
334
  ): Promise<IRemoteIngress | null> {
@@ -337,6 +342,7 @@ export class RemoteIngressManager {
337
342
  if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
338
343
  if (updates.enabled !== undefined) edge.enabled = updates.enabled;
339
344
  if (updates.performance !== undefined) edge.performance = updates.performance;
345
+ if (updates.egress !== undefined) edge.egress = this.normalizeEgressPolicy(updates.egress);
340
346
  if (updates.tags !== undefined) edge.tags = updates.tags;
341
347
  edge.updatedAt = Date.now();
342
348
 
@@ -405,12 +411,13 @@ export class RemoteIngressManager {
405
411
  * Get the list of allowed edges (enabled only) for the Rust hub.
406
412
  * Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
407
413
  */
408
- public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> {
409
- const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> = [];
414
+ public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig; egress?: IRemoteIngressEgressPolicyConfig }> {
415
+ const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig; egress?: IRemoteIngressEgressPolicyConfig }> = [];
410
416
  for (const edge of this.edges.values()) {
411
417
  if (edge.enabled) {
412
418
  const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
413
419
  const performance = edge.performance && Object.keys(edge.performance).length > 0 ? edge.performance : undefined;
420
+ const egress = edge.egress?.enabled ? edge.egress : undefined;
414
421
  result.push({
415
422
  id: edge.id,
416
423
  secret: edge.secret,
@@ -418,12 +425,80 @@ export class RemoteIngressManager {
418
425
  ...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
419
426
  ...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
420
427
  ...(performance ? { performance } : {}),
428
+ ...(egress ? { egress } : {}),
421
429
  });
422
430
  }
423
431
  }
424
432
  return result;
425
433
  }
426
434
 
435
+ private normalizeEgressPolicy(
436
+ egress?: IRemoteIngressEgressPolicyConfig,
437
+ ): IRemoteIngressEgressPolicyConfig | undefined {
438
+ if (!egress) {
439
+ return undefined;
440
+ }
441
+
442
+ const enabled = Boolean(egress.enabled);
443
+ const next: IRemoteIngressEgressPolicyConfig = { enabled };
444
+ if (!enabled) {
445
+ return next;
446
+ }
447
+
448
+ const allowedPorts = this.normalizeEgressAllowedPorts(egress.allowedPorts || [25]);
449
+ next.allowedPorts = allowedPorts;
450
+
451
+ if (egress.allowPrivateRanges !== undefined) {
452
+ next.allowPrivateRanges = Boolean(egress.allowPrivateRanges);
453
+ }
454
+
455
+ if (egress.deniedCidrs !== undefined) {
456
+ if (!Array.isArray(egress.deniedCidrs) || egress.deniedCidrs.length > maxRemoteIngressEgressListEntries) {
457
+ throw new Error(`RemoteIngress egress deniedCidrs must contain at most ${maxRemoteIngressEgressListEntries} entries`);
458
+ }
459
+ const deniedCidrs = [...new Set(egress.deniedCidrs.map((cidr) => `${cidr}`.trim()).filter(Boolean))];
460
+ if (deniedCidrs.length > 0) {
461
+ next.deniedCidrs = deniedCidrs;
462
+ }
463
+ }
464
+
465
+ if (egress.allowedHostPatterns !== undefined) {
466
+ if (!Array.isArray(egress.allowedHostPatterns) || egress.allowedHostPatterns.length > maxRemoteIngressEgressListEntries) {
467
+ throw new Error(`RemoteIngress egress allowedHostPatterns must contain at most ${maxRemoteIngressEgressListEntries} entries`);
468
+ }
469
+ const allowedHostPatterns = [...new Set(egress.allowedHostPatterns.map((pattern) => `${pattern}`.trim()).filter(Boolean))];
470
+ if (allowedHostPatterns.length > 0) {
471
+ next.allowedHostPatterns = allowedHostPatterns;
472
+ }
473
+ }
474
+
475
+ if (egress.maxConcurrentStreams !== undefined) {
476
+ const maxConcurrentStreams = Number(egress.maxConcurrentStreams);
477
+ if (!Number.isSafeInteger(maxConcurrentStreams) || maxConcurrentStreams < 1 || maxConcurrentStreams > 100_000) {
478
+ throw new Error('RemoteIngress egress maxConcurrentStreams must be a positive safe integer no greater than 100000');
479
+ }
480
+ next.maxConcurrentStreams = maxConcurrentStreams;
481
+ }
482
+
483
+ return next;
484
+ }
485
+
486
+ private normalizeEgressAllowedPorts(ports: number[]): number[] {
487
+ if (!Array.isArray(ports) || ports.length === 0 || ports.length > maxRemoteIngressEgressListEntries) {
488
+ throw new Error(`RemoteIngress egress allowedPorts must contain 1-${maxRemoteIngressEgressListEntries} ports`);
489
+ }
490
+ const allowedPorts = [...new Set(ports.map((port) => Number(port)))].sort((a, b) => a - b);
491
+ for (const port of allowedPorts) {
492
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
493
+ throw new Error('RemoteIngress egress allowedPorts must contain valid TCP ports');
494
+ }
495
+ if (port !== 25) {
496
+ throw new Error('RemoteIngress egress is currently limited to outbound SMTP port 25');
497
+ }
498
+ }
499
+ return allowedPorts;
500
+ }
501
+
427
502
  private normalizeHubSettingsUpdate(
428
503
  updates: TRemoteIngressHubSettingsUpdate,
429
504
  ): TRemoteIngressHubSettingsUpdate {
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../plugins.js';
2
+ import { logger } from '../logger.js';
2
3
  import type { IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
3
4
  import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
4
5
 
@@ -12,6 +13,10 @@ export interface ITunnelManagerConfig {
12
13
  performance?: IRemoteIngressPerformanceConfig;
13
14
  }
14
15
 
16
+ export type TRemoteIngressEgressProxyRequest = Omit<plugins.remoteingress.IEgressTcpProxyRequest, 'edgeId'> & {
17
+ edgeId?: string;
18
+ };
19
+
15
20
  /**
16
21
  * Manages the RemoteIngressHub instance and tracks connected edge statuses.
17
22
  */
@@ -62,6 +67,20 @@ export class TunnelManager {
62
67
  }
63
68
  }
64
69
  });
70
+
71
+ this.hub.on('egressConnection', (data: {
72
+ proxyId: string;
73
+ edgeId: string;
74
+ logicalHost: string;
75
+ port: number;
76
+ outcome: string;
77
+ resolvedIp?: string;
78
+ error?: string;
79
+ }) => {
80
+ const message = `RemoteIngress egress ${data.outcome}: edge=${data.edgeId} proxy=${data.proxyId} target=${data.logicalHost}:${data.port}${data.resolvedIp ? ` resolvedIp=${data.resolvedIp}` : ''}${data.error ? ` error=${data.error}` : ''}`;
81
+ const isExpectedLifecycleEvent = data.outcome === 'connected' || data.outcome === 'closed';
82
+ logger.log(isExpectedLifecycleEvent ? 'info' : 'warn', message);
83
+ });
65
84
  }
66
85
 
67
86
  /**
@@ -181,9 +200,51 @@ export class TunnelManager {
181
200
  await run;
182
201
  }
183
202
 
203
+ public async startEgressTcpProxy(
204
+ request: TRemoteIngressEgressProxyRequest,
205
+ ): Promise<plugins.remoteingress.IEgressTcpProxyInfo> {
206
+ if (this.stopped) {
207
+ throw new Error('RemoteIngress hub is not running');
208
+ }
209
+
210
+ const status = await this.hub.getStatus();
211
+ if (this.stopped) {
212
+ throw new Error('RemoteIngress hub is not running');
213
+ }
214
+ const connectedEdges = Array.isArray(status?.connectedEdges) ? status.connectedEdges : [];
215
+ const eligibleEdges = connectedEdges.filter((edge: any) => {
216
+ const hasEgressCapability = Array.isArray(edge.capabilities) && edge.capabilities.includes('egressTcpV1');
217
+ const usesQuic = edge.transportMode === 'quic'
218
+ || (edge.transportMode === 'quicWithFallback' && edge.fallbackUsed === false);
219
+ return edge.egressEnabled === true && hasEgressCapability && usesQuic;
220
+ });
221
+ const selectedEdge = request.edgeId
222
+ ? eligibleEdges.find((edge: any) => edge.edgeId === request.edgeId)
223
+ : eligibleEdges[0];
224
+
225
+ if (!selectedEdge) {
226
+ const edgeHint = request.edgeId ? ` for edge ${request.edgeId}` : '';
227
+ throw new Error(`No eligible RemoteIngress QUIC egress edge is connected${edgeHint}`);
228
+ }
229
+
230
+ return await this.hub.startEgressTcpProxy({
231
+ ...request,
232
+ edgeId: selectedEdge.edgeId,
233
+ });
234
+ }
235
+
236
+ public async stopEgressTcpProxy(proxyId: string): Promise<boolean> {
237
+ if (this.stopped) {
238
+ return false;
239
+ }
240
+ return await this.hub.stopEgressTcpProxy(proxyId);
241
+ }
242
+
184
243
  private applyRustStatus(status: IRemoteIngressStatus, rustEdge: any): void {
185
244
  status.transportMode = rustEdge.transportMode;
186
245
  status.fallbackUsed = rustEdge.fallbackUsed;
246
+ status.capabilities = rustEdge.capabilities;
247
+ status.egressEnabled = rustEdge.egressEnabled;
187
248
  status.performance = rustEdge.performance;
188
249
  status.flowControl = rustEdge.flowControl;
189
250
  status.queues = rustEdge.queues;
@@ -19,6 +19,8 @@ export class RemoteIngress {
19
19
  public effectiveListenPortsUdp?: number[];
20
20
  public manualPorts?: number[];
21
21
  public derivedPorts?: number[];
22
+ public performance?: interfaces.data.IRemoteIngressPerformanceConfig;
23
+ public egress?: interfaces.data.IRemoteIngressEgressPolicyConfig;
22
24
 
23
25
  constructor(clientRef: DcRouterApiClient, data: interfaces.data.IRemoteIngress) {
24
26
  this.clientRef = clientRef;
@@ -36,6 +38,8 @@ export class RemoteIngress {
36
38
  this.effectiveListenPortsUdp = data.effectiveListenPortsUdp;
37
39
  this.manualPorts = data.manualPorts;
38
40
  this.derivedPorts = data.derivedPorts;
41
+ this.performance = data.performance;
42
+ this.egress = data.egress;
39
43
  }
40
44
 
41
45
  public async update(changes: {
@@ -44,6 +48,8 @@ export class RemoteIngress {
44
48
  autoDerivePorts?: boolean;
45
49
  enabled?: boolean;
46
50
  tags?: string[];
51
+ performance?: interfaces.data.IRemoteIngressPerformanceConfig;
52
+ egress?: interfaces.data.IRemoteIngressEgressPolicyConfig;
47
53
  }): Promise<void> {
48
54
  const response = await this.clientRef.request<interfaces.requests.IReq_UpdateRemoteIngress>(
49
55
  'updateRemoteIngress',
@@ -65,6 +71,8 @@ export class RemoteIngress {
65
71
  this.effectiveListenPortsUdp = edge.effectiveListenPortsUdp;
66
72
  this.manualPorts = edge.manualPorts;
67
73
  this.derivedPorts = edge.derivedPorts;
74
+ this.performance = edge.performance;
75
+ this.egress = edge.egress;
68
76
  }
69
77
 
70
78
  public async delete(): Promise<void> {
@@ -106,6 +114,8 @@ export class RemoteIngressBuilder {
106
114
  private edgeName: string = '';
107
115
  private edgeListenPorts?: number[];
108
116
  private edgeAutoDerivePorts?: boolean;
117
+ private edgePerformance?: interfaces.data.IRemoteIngressPerformanceConfig;
118
+ private edgeEgress?: interfaces.data.IRemoteIngressEgressPolicyConfig;
109
119
  private edgeTags?: string[];
110
120
 
111
121
  constructor(clientRef: DcRouterApiClient) {
@@ -132,6 +142,16 @@ export class RemoteIngressBuilder {
132
142
  return this;
133
143
  }
134
144
 
145
+ public setPerformance(performance: interfaces.data.IRemoteIngressPerformanceConfig): this {
146
+ this.edgePerformance = performance;
147
+ return this;
148
+ }
149
+
150
+ public setEgress(egress: interfaces.data.IRemoteIngressEgressPolicyConfig): this {
151
+ this.edgeEgress = egress;
152
+ return this;
153
+ }
154
+
135
155
  public async save(): Promise<RemoteIngress> {
136
156
  const response = await this.clientRef.request<interfaces.requests.IReq_CreateRemoteIngress>(
137
157
  'createRemoteIngress',
@@ -139,6 +159,8 @@ export class RemoteIngressBuilder {
139
159
  name: this.edgeName,
140
160
  listenPorts: this.edgeListenPorts,
141
161
  autoDerivePorts: this.edgeAutoDerivePorts,
162
+ performance: this.edgePerformance,
163
+ egress: this.edgeEgress,
142
164
  tags: this.edgeTags,
143
165
  }) as any,
144
166
  );
@@ -176,11 +198,15 @@ export class RemoteIngressManager {
176
198
  name: string;
177
199
  listenPorts?: number[];
178
200
  autoDerivePorts?: boolean;
201
+ performance?: interfaces.data.IRemoteIngressPerformanceConfig;
202
+ egress?: interfaces.data.IRemoteIngressEgressPolicyConfig;
179
203
  tags?: string[];
180
204
  }): Promise<RemoteIngress> {
181
205
  const builder = this.build().setName(options.name);
182
206
  if (options.listenPorts) builder.setListenPorts(options.listenPorts);
183
207
  if (options.autoDerivePorts !== undefined) builder.setAutoDerivePorts(options.autoDerivePorts);
208
+ if (options.performance) builder.setPerformance(options.performance);
209
+ if (options.egress) builder.setEgress(options.egress);
184
210
  if (options.tags) builder.setTags(options.tags);
185
211
  return builder.save();
186
212
  }
@@ -113,6 +113,12 @@ const edge = await client.remoteIngress.build()
113
113
  .setName('edge-eu-1')
114
114
  .setListenPorts([80, 443])
115
115
  .setAutoDerivePorts(true)
116
+ .setEgress({
117
+ enabled: true,
118
+ allowedPorts: [25],
119
+ allowedHostPatterns: ['*.example.com'],
120
+ maxConcurrentStreams: 100,
121
+ })
116
122
  .setTags(['production', 'eu'])
117
123
  .save();
118
124
 
@@ -120,6 +126,8 @@ const connectionToken = await edge.getConnectionToken();
120
126
  console.log(token.tokenValue, connectionToken);
121
127
  ```
122
128
 
129
+ RemoteIngress resources expose `performance` and `egress` fields. `create()` and `update()` accept the same fields as the builder. Egress is default-disabled; dcrouter currently accepts outbound SMTP egress on port `25` only.
130
+
123
131
  ## What This Package Is Not
124
132
 
125
133
  - It does not start dcrouter.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '15.6.1',
6
+ version: '15.8.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -95,6 +95,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
95
95
  listenPorts?: number[];
96
96
  autoDerivePorts?: boolean;
97
97
  performance?: interfaces.data.IRemoteIngressPerformanceConfig;
98
+ egress?: interfaces.data.IRemoteIngressEgressPolicyConfig;
98
99
  tags?: string[];
99
100
  }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
100
101
  const context = getActionContext();
@@ -111,6 +112,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
111
112
  listenPorts: dataArg.listenPorts,
112
113
  autoDerivePorts: dataArg.autoDerivePorts,
113
114
  performance: dataArg.performance,
115
+ egress: dataArg.egress,
114
116
  tags: dataArg.tags,
115
117
  });
116
118
 
@@ -164,6 +166,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
164
166
  listenPorts?: number[];
165
167
  autoDerivePorts?: boolean;
166
168
  performance?: interfaces.data.IRemoteIngressPerformanceConfig;
169
+ egress?: interfaces.data.IRemoteIngressEgressPolicyConfig;
167
170
  tags?: string[];
168
171
  }>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
169
172
  const context = getActionContext();
@@ -181,6 +184,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
181
184
  listenPorts: dataArg.listenPorts,
182
185
  autoDerivePorts: dataArg.autoDerivePorts,
183
186
  performance: dataArg.performance,
187
+ egress: dataArg.egress,
184
188
  tags: dataArg.tags,
185
189
  });
186
190
 
@@ -330,6 +330,11 @@ export class OpsViewEmailDomains extends DeesElement {
330
330
  .description=${'Public hostname used in SMTP banners and DNS records'}
331
331
  .value=${settings?.hostname || ''}
332
332
  ></dees-input-text>
333
+ <dees-input-checkbox
334
+ .key=${'outboundRemoteIngress'}
335
+ .label=${'Route outbound SMTP through RemoteIngress'}
336
+ .value=${settings?.outboundMode === 'remoteIngress'}
337
+ ></dees-input-checkbox>
333
338
  <dees-input-text
334
339
  .key=${'ports'}
335
340
  .label=${'Public ports'}
@@ -370,6 +375,7 @@ export class OpsViewEmailDomains extends DeesElement {
370
375
  {
371
376
  enabled: Boolean(data.enabled),
372
377
  hostname: String(data.hostname || '').trim() || null,
378
+ outboundMode: Boolean(data.outboundRemoteIngress) ? 'remoteIngress' : 'direct',
373
379
  ports: this.parsePortList(String(data.ports || '')),
374
380
  portMapping: this.parsePortMapping(String(data.portMapping || '')),
375
381
  maxMessageSize: maxMessageSizeRaw ? Number.parseInt(maxMessageSizeRaw, 10) : null,
@@ -259,6 +259,7 @@ export class OpsViewRemoteIngress extends DeesElement {
259
259
  transport: this.getTransportHtml(edge.id),
260
260
  publicIp: this.getEdgePublicIp(edge.id),
261
261
  ports: this.getPortsHtml(edge),
262
+ smtpEgress: this.getEgressHtml(edge),
262
263
  tunnels: this.getEdgeTunnelCount(edge.id),
263
264
  maxConnections: this.getMaxConnectionsHtml(edge),
264
265
  window: this.getWindowHtml(edge.id),
@@ -281,6 +282,9 @@ export class OpsViewRemoteIngress extends DeesElement {
281
282
  <dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
282
283
  <dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
283
284
  <dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'}></dees-input-text>
285
+ <dees-input-checkbox .key=${'egressEnabled'} .label=${'Allow outbound SMTP egress'} .value=${false}></dees-input-checkbox>
286
+ <dees-input-text .key=${'egressAllowedHostPatterns'} .label=${'SMTP Egress Host Patterns'} .description=${'Optional comma-separated MX host patterns. Leave empty to allow public MX hosts on port 25.'}></dees-input-text>
287
+ <dees-input-text .key=${'egressMaxConcurrentStreams'} .label=${'SMTP Egress Max Streams'} .description=${'Optional concurrent outbound SMTP stream cap for this edge.'}></dees-input-text>
284
288
  <dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
285
289
  </dees-form>
286
290
  `,
@@ -305,8 +309,10 @@ export class OpsViewRemoteIngress extends DeesElement {
305
309
  : undefined;
306
310
  const autoDerivePorts = formData.autoDerivePorts !== false;
307
311
  let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
312
+ let egress: interfaces.data.IRemoteIngressEgressPolicyConfig;
308
313
  try {
309
314
  performance = this.collectPerformanceOverride(formData);
315
+ egress = this.collectEgressPolicy(formData);
310
316
  } catch (err: unknown) {
311
317
  const { DeesToast } = await import('@design.estate/dees-catalog');
312
318
  DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
@@ -317,7 +323,7 @@ export class OpsViewRemoteIngress extends DeesElement {
317
323
  : undefined;
318
324
  await appstate.remoteIngressStatePart.dispatchAction(
319
325
  appstate.createRemoteIngressAction,
320
- { name, listenPorts, autoDerivePorts, performance, tags },
326
+ { name, listenPorts, autoDerivePorts, performance, egress, tags },
321
327
  );
322
328
  await modalArg.destroy();
323
329
  },
@@ -375,6 +381,9 @@ export class OpsViewRemoteIngress extends DeesElement {
375
381
  <dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
376
382
  <dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
377
383
  <dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'} .value=${edge.performance?.maxStreamsPerEdge?.toString() || ''}></dees-input-text>
384
+ <dees-input-checkbox .key=${'egressEnabled'} .label=${'Allow outbound SMTP egress'} .value=${edge.egress?.enabled === true}></dees-input-checkbox>
385
+ <dees-input-text .key=${'egressAllowedHostPatterns'} .label=${'SMTP Egress Host Patterns'} .description=${'Optional comma-separated MX host patterns. Leave empty to allow public MX hosts on port 25.'} .value=${(edge.egress?.allowedHostPatterns || []).join(', ')}></dees-input-text>
386
+ <dees-input-text .key=${'egressMaxConcurrentStreams'} .label=${'SMTP Egress Max Streams'} .description=${'Optional concurrent outbound SMTP stream cap for this edge.'} .value=${edge.egress?.maxConcurrentStreams?.toString() || ''}></dees-input-text>
378
387
  <dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
379
388
  </dees-form>
380
389
  `,
@@ -397,8 +406,10 @@ export class OpsViewRemoteIngress extends DeesElement {
397
406
  : [];
398
407
  const autoDerivePorts = formData.autoDerivePorts !== false;
399
408
  let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
409
+ let egress: interfaces.data.IRemoteIngressEgressPolicyConfig;
400
410
  try {
401
411
  performance = this.collectPerformanceOverride(formData, edge.performance);
412
+ egress = this.collectEgressPolicy(formData, edge.egress);
402
413
  } catch (err: unknown) {
403
414
  const { DeesToast } = await import('@design.estate/dees-catalog');
404
415
  DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
@@ -415,6 +426,7 @@ export class OpsViewRemoteIngress extends DeesElement {
415
426
  listenPorts,
416
427
  autoDerivePorts,
417
428
  performance,
429
+ egress,
418
430
  tags,
419
431
  },
420
432
  );
@@ -534,6 +546,16 @@ export class OpsViewRemoteIngress extends DeesElement {
534
546
  `;
535
547
  }
536
548
 
549
+ private getEgressHtml(edge: interfaces.data.IRemoteIngress): TemplateResult | string {
550
+ if (!edge.egress?.enabled) return '-';
551
+ return html`
552
+ <div class="metricStack">
553
+ <strong>SMTP</strong>
554
+ <span class="metricMuted">port ${(edge.egress.allowedPorts || [25]).join(', ')}</span>
555
+ </div>
556
+ `;
557
+ }
558
+
537
559
  private getTransportHtml(edgeId: string): TemplateResult | string {
538
560
  const status = this.getEdgeStatus(edgeId);
539
561
  if (!status?.connected) return '-';
@@ -618,6 +640,38 @@ export class OpsViewRemoteIngress extends DeesElement {
618
640
  return base ? {} : undefined;
619
641
  }
620
642
 
643
+ private collectEgressPolicy(
644
+ formData: Record<string, any>,
645
+ base?: interfaces.data.IRemoteIngressEgressPolicyConfig,
646
+ ): interfaces.data.IRemoteIngressEgressPolicyConfig {
647
+ const enabled = formData.egressEnabled !== false && Boolean(formData.egressEnabled);
648
+ if (!enabled) {
649
+ return { enabled: false };
650
+ }
651
+ const next: interfaces.data.IRemoteIngressEgressPolicyConfig = {
652
+ ...(base || {}),
653
+ enabled: true,
654
+ allowedPorts: [25],
655
+ };
656
+ const hostPatternsText = `${formData.egressAllowedHostPatterns || ''}`.trim();
657
+ if (hostPatternsText) {
658
+ next.allowedHostPatterns = hostPatternsText.split(',').map((pattern) => pattern.trim()).filter(Boolean);
659
+ } else {
660
+ delete next.allowedHostPatterns;
661
+ }
662
+ const maxStreamsText = `${formData.egressMaxConcurrentStreams || ''}`.trim();
663
+ if (maxStreamsText) {
664
+ const maxConcurrentStreams = Number.parseInt(maxStreamsText, 10);
665
+ if (!Number.isInteger(maxConcurrentStreams) || maxConcurrentStreams < 1) {
666
+ throw new Error('SMTP Egress Max Streams must be a positive integer');
667
+ }
668
+ next.maxConcurrentStreams = maxConcurrentStreams;
669
+ } else {
670
+ delete next.maxConcurrentStreams;
671
+ }
672
+ return next;
673
+ }
674
+
621
675
  private async showHubSettingsDialog(): Promise<void> {
622
676
  const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
623
677
  const hubSettings = this.riState.hubSettings;