@serve.zone/dcrouter 15.11.0 → 16.0.1

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 (73) hide show
  1. package/deno.json +1 -1
  2. package/dist_serve/bundle.js +34 -34
  3. package/dist_ts/00_commitinfo_data.js +2 -2
  4. package/dist_ts/classes.dcrouter.d.ts +10 -3
  5. package/dist_ts/classes.dcrouter.js +39 -43
  6. package/dist_ts/db/documents/classes.cached.email.d.ts +32 -0
  7. package/dist_ts/db/documents/classes.cached.email.js +67 -3
  8. package/dist_ts/db/documents/classes.email-domain.doc.d.ts +2 -1
  9. package/dist_ts/db/documents/classes.email-domain.doc.js +8 -2
  10. package/dist_ts/db/documents/classes.email-server-settings.doc.js +2 -2
  11. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.d.ts +3 -0
  12. package/dist_ts/db/documents/classes.remote-ingress-edge.doc.js +20 -2
  13. package/dist_ts/dns/classes.dns-server-runtime.js +3 -1
  14. package/dist_ts/email/classes.accepted-email-spool.d.ts +5 -1
  15. package/dist_ts/email/classes.accepted-email-spool.js +9 -1
  16. package/dist_ts/email/classes.email-domain.manager.d.ts +17 -1
  17. package/dist_ts/email/classes.email-domain.manager.js +105 -18
  18. package/dist_ts/email/classes.email-route-builder.js +5 -2
  19. package/dist_ts/email/classes.email-settings.manager.js +3 -3
  20. package/dist_ts/email/classes.mail-dns-sync.d.ts +27 -0
  21. package/dist_ts/email/classes.mail-dns-sync.js +196 -0
  22. package/dist_ts/email/classes.mail-egress-coordinator.d.ts +37 -0
  23. package/dist_ts/email/classes.mail-egress-coordinator.js +113 -0
  24. package/dist_ts/email/index.d.ts +2 -0
  25. package/dist_ts/email/index.js +3 -1
  26. package/dist_ts/opsserver/handlers/config.handler.js +4 -2
  27. package/dist_ts/opsserver/handlers/email-domain.handler.js +3 -1
  28. package/dist_ts/opsserver/handlers/email-ops.handler.d.ts +20 -3
  29. package/dist_ts/opsserver/handlers/email-ops.handler.js +141 -19
  30. package/dist_ts/opsserver/handlers/email-settings.handler.js +11 -6
  31. package/dist_ts/opsserver/handlers/remoteingress.handler.js +16 -7
  32. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +33 -1
  33. package/dist_ts/remoteingress/classes.remoteingress-manager.js +94 -2
  34. package/dist_ts/remoteingress/classes.tunnel-manager.d.ts +18 -0
  35. package/dist_ts/remoteingress/classes.tunnel-manager.js +114 -12
  36. package/dist_ts/remoteingress/edge-derivation-routes.d.ts +10 -0
  37. package/dist_ts/remoteingress/edge-derivation-routes.js +28 -0
  38. package/dist_ts/remoteingress/index.d.ts +1 -0
  39. package/dist_ts/remoteingress/index.js +2 -1
  40. package/dist_ts_interfaces/data/email-domain.d.ts +11 -1
  41. package/dist_ts_interfaces/data/email-settings.d.ts +11 -0
  42. package/dist_ts_interfaces/data/remoteingress.d.ts +10 -0
  43. package/dist_ts_interfaces/data/remoteingress.js +5 -2
  44. package/dist_ts_interfaces/requests/email-domains.d.ts +5 -1
  45. package/dist_ts_interfaces/requests/email-ops.d.ts +4 -0
  46. package/dist_ts_interfaces/requests/remoteingress.d.ts +11 -0
  47. package/dist_ts_migrations/index.js +91 -9
  48. package/dist_ts_web/00_commitinfo_data.js +2 -2
  49. package/package.json +4 -4
  50. package/ts/00_commitinfo_data.ts +1 -1
  51. package/ts/classes.dcrouter.ts +41 -48
  52. package/ts/db/documents/classes.cached.email.ts +72 -1
  53. package/ts/db/documents/classes.email-domain.doc.ts +4 -0
  54. package/ts/db/documents/classes.email-server-settings.doc.ts +1 -1
  55. package/ts/db/documents/classes.remote-ingress-edge.doc.ts +9 -0
  56. package/ts/dns/classes.dns-server-runtime.ts +2 -0
  57. package/ts/email/classes.accepted-email-spool.ts +12 -1
  58. package/ts/email/classes.email-domain.manager.ts +123 -18
  59. package/ts/email/classes.email-route-builder.ts +4 -1
  60. package/ts/email/classes.email-settings.manager.ts +2 -2
  61. package/ts/email/classes.mail-dns-sync.ts +218 -0
  62. package/ts/email/classes.mail-egress-coordinator.ts +125 -0
  63. package/ts/email/index.ts +2 -0
  64. package/ts/opsserver/handlers/config.handler.ts +3 -1
  65. package/ts/opsserver/handlers/email-domain.handler.ts +2 -0
  66. package/ts/opsserver/handlers/email-ops.handler.ts +156 -18
  67. package/ts/opsserver/handlers/email-settings.handler.ts +10 -5
  68. package/ts/opsserver/handlers/remoteingress.handler.ts +15 -5
  69. package/ts/remoteingress/classes.remoteingress-manager.ts +111 -0
  70. package/ts/remoteingress/classes.tunnel-manager.ts +125 -10
  71. package/ts/remoteingress/edge-derivation-routes.ts +29 -0
  72. package/ts/remoteingress/index.ts +1 -0
  73. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -107,7 +107,9 @@ export class ConfigHandler {
107
107
  ports: emailSettings?.ports || opts.emailConfig?.ports || [],
108
108
  portMapping,
109
109
  hostname: emailSettings?.hostname || opts.emailConfig?.hostname || null,
110
- outboundMode: emailSettings?.outboundMode || opts.emailOutboundMode || 'direct',
110
+ // Absent everywhere means the 'remoteIngress' default applies — never
111
+ // silently claim 'direct' (that masking was the outbound activation bug).
112
+ outboundMode: emailSettings?.outboundMode || opts.emailOutboundMode || 'remoteIngress',
111
113
  domains: emailDomains,
112
114
  emailRouteCount: emailSettings?.routeCount ?? opts.emailConfig?.routes?.length ?? 0,
113
115
  receivedEmailsPath: emailSettings?.receivedEmailsPath || opts.emailPortConfig?.receivedEmailsPath || null,
@@ -73,6 +73,7 @@ export class EmailDomainHandler {
73
73
  dkimKeySize: dataArg.dkimKeySize,
74
74
  rotateKeys: dataArg.rotateKeys,
75
75
  rotationIntervalDays: dataArg.rotationIntervalDays,
76
+ remoteIngress: dataArg.remoteIngress,
76
77
  });
77
78
  return { success: true, domain };
78
79
  } catch (err: unknown) {
@@ -96,6 +97,7 @@ export class EmailDomainHandler {
96
97
  rotateKeys: dataArg.rotateKeys,
97
98
  rotationIntervalDays: dataArg.rotationIntervalDays,
98
99
  rateLimits: dataArg.rateLimits,
100
+ remoteIngress: dataArg.remoteIngress,
99
101
  });
100
102
  return { success: true };
101
103
  } catch (err: unknown) {
@@ -2,6 +2,7 @@ import * as plugins from '../../plugins.js';
2
2
  import type { OpsServer } from '../classes.opsserver.js';
3
3
  import * as interfaces from '../../../ts_interfaces/index.js';
4
4
  import { requireOpsAuth } from '../helpers/auth.js';
5
+ import { CachedEmail } from '../../db/index.js';
5
6
 
6
7
  export class EmailOpsHandler {
7
8
  constructor(private opsServerRef: OpsServer) {
@@ -20,7 +21,7 @@ export class EmailOpsHandler {
20
21
  'getAllEmails',
21
22
  async (dataArg) => {
22
23
  await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
23
- const emails = this.getAllQueueEmails();
24
+ const emails = await this.getAllEmails(dataArg.direction, dataArg.limit);
24
25
  return { emails };
25
26
  }
26
27
  )
@@ -32,7 +33,7 @@ export class EmailOpsHandler {
32
33
  'getEmailDetail',
33
34
  async (dataArg) => {
34
35
  await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'emails:read' });
35
- const email = this.getEmailDetail(dataArg.emailId);
36
+ const email = await this.getEmailDetail(dataArg.emailId);
36
37
  return { email };
37
38
  }
38
39
  )
@@ -85,36 +86,172 @@ export class EmailOpsHandler {
85
86
  }
86
87
 
87
88
  /**
88
- * Get all queue items mapped to catalog IEmail format
89
+ * List accepted emails (inbound + outbound) from the durable CachedEmail
90
+ * store, plus any live outbound queue items not yet represented there.
89
91
  */
90
- private getAllQueueEmails(): interfaces.requests.IEmail[] {
91
- const emailServer = this.opsServerRef.dcRouterRef.emailServer;
92
- if (!emailServer) {
93
- return [];
92
+ private async getAllEmails(
93
+ direction?: interfaces.requests.TEmailDirection,
94
+ limitArg?: number,
95
+ ): Promise<interfaces.requests.IEmail[]> {
96
+ const limit = Math.min(Math.max(Math.floor(limitArg ?? 200), 1), 1000);
97
+ const cachedDocs = this.isCachedEmailStoreAvailable()
98
+ ? await CachedEmail.findRecent(limit, { direction })
99
+ : [];
100
+ const emails = cachedDocs.map((doc) => this.mapCachedEmailToEmail(doc));
101
+ const representedIds = new Set(cachedDocs.map((doc) => doc.id));
102
+
103
+ // Live delivery-queue items are always outbound; every spooled message
104
+ // carries the cache-id header, so dedupe against the cached listing.
105
+ if (direction !== 'inbound') {
106
+ const emailServer = this.opsServerRef.dcRouterRef.emailServer;
107
+ const spool = this.opsServerRef.dcRouterRef.acceptedEmailSpool;
108
+ for (const item of emailServer?.getQueueItems() ?? []) {
109
+ const cachedId = spool.getCachedEmailIdFromQueueItem(item);
110
+ if (cachedId && representedIds.has(cachedId)) {
111
+ continue;
112
+ }
113
+ emails.push(this.mapQueueItemToEmail(item));
114
+ }
94
115
  }
95
- const emails = emailServer.getQueueItems().map((item) => this.mapQueueItemToEmail(item));
96
116
 
97
- // Sort by createdAt descending (newest first)
117
+ // Sort by timestamp descending (newest first)
98
118
  emails.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
99
119
 
100
- return emails;
120
+ return emails.slice(0, limit);
101
121
  }
102
122
 
103
123
  /**
104
- * Get a single email detail by ID
124
+ * Get a single email detail by ID — live queue item first (freshest state),
125
+ * else the durable CachedEmail row (covers inbound + historical mail).
105
126
  */
106
- private getEmailDetail(emailId: string): interfaces.requests.IEmailDetail | null {
127
+ private async getEmailDetail(emailId: string): Promise<interfaces.requests.IEmailDetail | null> {
107
128
  const emailServer = this.opsServerRef.dcRouterRef.emailServer;
108
- if (!emailServer) {
109
- return null;
129
+ const item = emailServer?.getQueueItem(emailId);
130
+ if (item) {
131
+ return this.mapQueueItemToEmailDetail(item);
132
+ }
133
+
134
+ if (this.isCachedEmailStoreAvailable()) {
135
+ const cachedEmail = await CachedEmail.findById(emailId);
136
+ if (cachedEmail) {
137
+ return this.mapCachedEmailToEmailDetail(cachedEmail);
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** Durable listing needs the DB; without it only the live queue is visible. */
144
+ private isCachedEmailStoreAvailable(): boolean {
145
+ return this.opsServerRef.dcRouterRef.dcRouterDb?.isReady() === true;
146
+ }
147
+
148
+ /**
149
+ * Map a CachedEmail row to catalog IEmail format
150
+ */
151
+ private mapCachedEmailToEmail(doc: CachedEmail): interfaces.requests.IEmail {
152
+ return {
153
+ id: doc.id,
154
+ direction: doc.direction,
155
+ status: this.mapStatus(doc.status),
156
+ from: doc.from || '',
157
+ to: doc.to?.[0] || '',
158
+ subject: doc.subject || '',
159
+ timestamp: new Date(doc.acceptedAt || 0).toISOString(),
160
+ messageId: doc.messageId || '',
161
+ size: this.formatSize(doc.rawContent?.length || 0),
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Map a CachedEmail row to catalog IEmailDetail format
167
+ */
168
+ private mapCachedEmailToEmailDetail(doc: CachedEmail): interfaces.requests.IEmailDetail {
169
+ const base = this.mapCachedEmailToEmail(doc);
170
+ const { headers, body } = this.parseRawEmail(doc.rawContent || '');
171
+ const session = this.parseCachedEmailSession(doc);
172
+
173
+ return {
174
+ ...base,
175
+ toList: doc.to || [],
176
+ cc: doc.cc || [],
177
+ smtpLog: [],
178
+ connectionInfo: {
179
+ sourceIp: session.remoteAddress || '',
180
+ sourceHostname: session.clientHostname || '',
181
+ destinationIp: '',
182
+ destinationPort: 0,
183
+ tlsVersion: session.secure ? 'TLS' : '',
184
+ tlsCipher: '',
185
+ authenticated: !!session.authenticated,
186
+ authMethod: '',
187
+ authUser: this.extractSessionUser(session),
188
+ },
189
+ authenticationResults: {
190
+ spf: 'none',
191
+ spfDomain: '',
192
+ dkim: 'none',
193
+ dkimDomain: '',
194
+ dmarc: 'none',
195
+ dmarcPolicy: '',
196
+ },
197
+ rejectionReason: doc.status === 'failed' ? doc.lastError : undefined,
198
+ bounceMessage: doc.status === 'failed' ? doc.lastError : undefined,
199
+ headers,
200
+ body,
201
+ };
202
+ }
203
+
204
+ /** Session snapshot persisted in routeData at acceptance time. */
205
+ private parseCachedEmailSession(doc: CachedEmail): {
206
+ remoteAddress?: string;
207
+ clientHostname?: string;
208
+ secure?: boolean;
209
+ authenticated?: boolean;
210
+ user?: unknown;
211
+ } {
212
+ try {
213
+ const routeData = doc.routeData ? JSON.parse(doc.routeData) : {};
214
+ return routeData.session || {};
215
+ } catch {
216
+ return {};
110
217
  }
111
- const item = emailServer.getQueueItem(emailId);
218
+ }
112
219
 
113
- if (!item) {
114
- return null;
220
+ private extractSessionUser(session: { user?: unknown }): string {
221
+ if (!session.user) {
222
+ return '';
115
223
  }
224
+ if (typeof session.user === 'string') {
225
+ return session.user;
226
+ }
227
+ const user = session.user as { username?: string; id?: string };
228
+ return user.username || user.id || '';
229
+ }
230
+
231
+ /** Split raw RFC822 content into an unfolded header map and the body. */
232
+ private parseRawEmail(rawContent: string): { headers: Record<string, string>; body: string } {
233
+ const separatorMatch = rawContent.match(/\r?\n\r?\n/);
234
+ const headerBlock = separatorMatch
235
+ ? rawContent.slice(0, separatorMatch.index)
236
+ : rawContent;
237
+ const body = separatorMatch
238
+ ? rawContent.slice((separatorMatch.index || 0) + separatorMatch[0].length)
239
+ : '';
116
240
 
117
- return this.mapQueueItemToEmailDetail(item);
241
+ const headers: Record<string, string> = {};
242
+ let currentKey: string | undefined;
243
+ for (const line of headerBlock.split(/\r?\n/)) {
244
+ if (/^[\t ]/.test(line) && currentKey) {
245
+ headers[currentKey] += ` ${line.trim()}`;
246
+ continue;
247
+ }
248
+ const idx = line.indexOf(':');
249
+ if (idx > 0) {
250
+ currentKey = line.slice(0, idx).trim();
251
+ headers[currentKey] = line.slice(idx + 1).trim();
252
+ }
253
+ }
254
+ return { headers, body };
118
255
  }
119
256
 
120
257
  /**
@@ -243,6 +380,7 @@ export class EmailOpsHandler {
243
380
  switch (queueStatus) {
244
381
  case 'pending':
245
382
  case 'processing':
383
+ case 'queued':
246
384
  return 'pending';
247
385
  case 'delivered':
248
386
  return 'delivered';
@@ -49,16 +49,21 @@ export class EmailSettingsHandler {
49
49
  }
50
50
 
51
51
  private getSettings(): interfaces.data.IEmailServerSettings {
52
- const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
52
+ const dcRouter = this.opsServerRef.dcRouterRef;
53
+ const outboundEgress = dcRouter.getOutboundEgressStatus();
54
+ const manager = dcRouter.emailSettingsManager;
53
55
  if (manager) {
54
- return manager.getPublicSettings();
56
+ return { ...manager.getPublicSettings(), outboundEgress };
55
57
  }
56
- const emailConfig = this.opsServerRef.dcRouterRef.options.emailConfig;
57
- const emailPortConfig = this.opsServerRef.dcRouterRef.options.emailPortConfig;
58
+ const emailConfig = dcRouter.options.emailConfig;
59
+ const emailPortConfig = dcRouter.options.emailPortConfig;
58
60
  return {
59
61
  enabled: Boolean(emailConfig),
60
62
  hostname: emailConfig?.hostname || null,
61
- outboundMode: this.opsServerRef.dcRouterRef.options.emailOutboundMode || 'direct',
63
+ // No settings manager yet: report the config seed; absent means the
64
+ // 'remoteIngress' default applies — never silently claim 'direct'.
65
+ outboundMode: dcRouter.options.emailOutboundMode || 'remoteIngress',
66
+ outboundEgress,
62
67
  ports: [...(emailConfig?.ports || [])],
63
68
  portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
64
69
  receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
@@ -64,12 +64,18 @@ export class RemoteIngressHandler {
64
64
  dataArg.autoDerivePorts ?? true,
65
65
  dataArg.performance,
66
66
  dataArg.egress,
67
+ {
68
+ publicIp: dataArg.publicIp,
69
+ publicIpV6: dataArg.publicIpV6,
70
+ mailHostname: dataArg.mailHostname,
71
+ },
67
72
  ));
68
73
  return { success: true, edge };
69
74
  } catch (err: unknown) {
70
75
  return {
71
76
  success: false,
72
77
  edge: null as any,
78
+ message: (err as Error).message,
73
79
  };
74
80
  }
75
81
  },
@@ -111,6 +117,7 @@ export class RemoteIngressHandler {
111
117
  scope: 'remote-ingress:write',
112
118
  requireAdminIdentity: true,
113
119
  });
120
+ let failureMessage: string | undefined;
114
121
  const result = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(async (manager) => {
115
122
  const edge = await manager.updateEdge(dataArg.id, {
116
123
  name: dataArg.name,
@@ -120,6 +127,9 @@ export class RemoteIngressHandler {
120
127
  performance: dataArg.performance,
121
128
  egress: dataArg.egress,
122
129
  tags: dataArg.tags,
130
+ publicIp: dataArg.publicIp,
131
+ publicIpV6: dataArg.publicIpV6,
132
+ mailHostname: dataArg.mailHostname,
123
133
  });
124
134
 
125
135
  if (!edge) {
@@ -136,14 +146,14 @@ export class RemoteIngressHandler {
136
146
  derivedPorts: breakdown.derived,
137
147
  };
138
148
  }).catch((err: unknown) => {
139
- if ((err as Error).message.includes('RemoteIngress')) {
140
- return null;
141
- }
142
- throw err;
149
+ // Validation rejections (bad IP, hub-IP guard, bad FQDN) and a
150
+ // missing manager both surface as success:false with the reason.
151
+ failureMessage = (err as Error).message;
152
+ return null;
143
153
  });
144
154
 
145
155
  if (!result) {
146
- return { success: false, edge: null as any };
156
+ return { success: false, edge: null as any, message: failureMessage };
147
157
  }
148
158
  return {
149
159
  success: true,
@@ -1,7 +1,17 @@
1
1
  import * as plugins from '../plugins.js';
2
2
  import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressEgressPolicyConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
3
+ import { REMOTE_INGRESS_MAIL_TAG } from '../../ts_interfaces/data/remoteingress.js';
3
4
  import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
4
5
 
6
+ /** Operator-declared host identity of an edge (public IPs + PTR-aligned mail hostname). */
7
+ export interface IRemoteIngressEdgeHostInfo {
8
+ publicIp?: string;
9
+ publicIpV6?: string;
10
+ mailHostname?: string;
11
+ }
12
+
13
+ const fqdnRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
14
+
5
15
  export interface IRemoteIngressFirewallConfig {
6
16
  blockedIps?: string[];
7
17
  }
@@ -51,6 +61,8 @@ export class RemoteIngressManager {
51
61
  private edges: Map<string, IRemoteIngress> = new Map();
52
62
  private routes: IDcRouterRouteConfig[] = [];
53
63
  private firewallConfig?: IRemoteIngressFirewallConfig;
64
+ private hubPublicIps: string[] = [];
65
+ private onEdgesChanged?: (reason: string) => void;
54
66
  private hubSettings: IRemoteIngressHubSettings = {
55
67
  enabled: false,
56
68
  tunnelPort: defaultTunnelPort,
@@ -60,6 +72,26 @@ export class RemoteIngressManager {
60
72
 
61
73
  constructor() {}
62
74
 
75
+ /**
76
+ * Set the hub's own public IPs. Edge publicIp values equal to any of these
77
+ * are rejected — the hub IP must never become an edge-fronted DNS target.
78
+ */
79
+ public setHubPublicIps(ips: Array<string | undefined>): void {
80
+ this.hubPublicIps = ips.filter((ip): ip is string => Boolean(ip));
81
+ }
82
+
83
+ /**
84
+ * Register a callback fired after every edge mutation (create/update/delete)
85
+ * so dependent state — mail DNS records — can reconcile.
86
+ */
87
+ public setOnEdgesChanged(callback: (reason: string) => void): void {
88
+ this.onEdgesChanged = callback;
89
+ }
90
+
91
+ private notifyEdgesChanged(reason: string): void {
92
+ this.onEdgesChanged?.(reason);
93
+ }
94
+
63
95
  /**
64
96
  * Load all edge registrations from the database into memory.
65
97
  */
@@ -82,6 +114,9 @@ export class RemoteIngressManager {
82
114
  performance: doc.performance,
83
115
  egress: this.normalizeEgressPolicy(doc.egress),
84
116
  tags: doc.tags,
117
+ publicIp: doc.publicIp,
118
+ publicIpV6: doc.publicIpV6,
119
+ mailHostname: doc.mailHostname,
85
120
  createdAt: doc.createdAt,
86
121
  updatedAt: doc.updatedAt,
87
122
  };
@@ -277,6 +312,7 @@ export class RemoteIngressManager {
277
312
  autoDerivePorts: boolean = true,
278
313
  performance?: IRemoteIngressPerformanceConfig,
279
314
  egress?: IRemoteIngressEgressPolicyConfig,
315
+ hostInfo?: IRemoteIngressEdgeHostInfo,
280
316
  ): Promise<IRemoteIngress> {
281
317
  const id = plugins.uuid.v4();
282
318
  const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -292,6 +328,9 @@ export class RemoteIngressManager {
292
328
  performance,
293
329
  egress: this.normalizeEgressPolicy(egress),
294
330
  tags: tags || [],
331
+ publicIp: this.normalizeEdgePublicIp(hostInfo?.publicIp, 4),
332
+ publicIpV6: this.normalizeEdgePublicIp(hostInfo?.publicIpV6, 6),
333
+ mailHostname: this.normalizeMailHostname(hostInfo?.mailHostname),
295
334
  createdAt: now,
296
335
  updatedAt: now,
297
336
  };
@@ -300,6 +339,7 @@ export class RemoteIngressManager {
300
339
  Object.assign(doc, edge);
301
340
  await doc.save();
302
341
  this.edges.set(id, edge);
342
+ this.notifyEdgesChanged(`edge created: ${name}`);
303
343
  return edge;
304
344
  }
305
345
 
@@ -330,6 +370,10 @@ export class RemoteIngressManager {
330
370
  performance?: IRemoteIngressPerformanceConfig;
331
371
  egress?: IRemoteIngressEgressPolicyConfig;
332
372
  tags?: string[];
373
+ /** null clears the stored value. */
374
+ publicIp?: string | null;
375
+ publicIpV6?: string | null;
376
+ mailHostname?: string | null;
333
377
  },
334
378
  ): Promise<IRemoteIngress | null> {
335
379
  const edge = this.edges.get(id);
@@ -344,6 +388,15 @@ export class RemoteIngressManager {
344
388
  if (updates.performance !== undefined) edge.performance = updates.performance;
345
389
  if (updates.egress !== undefined) edge.egress = this.normalizeEgressPolicy(updates.egress);
346
390
  if (updates.tags !== undefined) edge.tags = updates.tags;
391
+ if (updates.publicIp !== undefined) {
392
+ edge.publicIp = updates.publicIp === null ? undefined : this.normalizeEdgePublicIp(updates.publicIp, 4);
393
+ }
394
+ if (updates.publicIpV6 !== undefined) {
395
+ edge.publicIpV6 = updates.publicIpV6 === null ? undefined : this.normalizeEdgePublicIp(updates.publicIpV6, 6);
396
+ }
397
+ if (updates.mailHostname !== undefined) {
398
+ edge.mailHostname = updates.mailHostname === null ? undefined : this.normalizeMailHostname(updates.mailHostname);
399
+ }
347
400
  edge.updatedAt = Date.now();
348
401
 
349
402
  const doc = await RemoteIngressEdgeDoc.findById(id);
@@ -352,6 +405,7 @@ export class RemoteIngressManager {
352
405
  await doc.save();
353
406
  }
354
407
  this.edges.set(id, edge);
408
+ this.notifyEdgesChanged(`edge updated: ${edge.name}`);
355
409
  return edge;
356
410
  }
357
411
 
@@ -367,6 +421,7 @@ export class RemoteIngressManager {
367
421
  await doc.delete();
368
422
  }
369
423
  this.edges.delete(id);
424
+ this.notifyEdgesChanged(`edge deleted: ${id}`);
370
425
  return true;
371
426
  }
372
427
 
@@ -432,6 +487,62 @@ export class RemoteIngressManager {
432
487
  return result;
433
488
  }
434
489
 
490
+ /**
491
+ * Resolve enabled edges whose id or tags match any entry in the filter
492
+ * (same semantics as route-level edgeFilter in derivePortsForEdge).
493
+ */
494
+ public resolveEdgesByFilter(filter: string[]): IRemoteIngress[] {
495
+ if (!Array.isArray(filter) || filter.length === 0) {
496
+ return [];
497
+ }
498
+ const result: IRemoteIngress[] = [];
499
+ for (const edge of this.edges.values()) {
500
+ if (!edge.enabled) continue;
501
+ const idMatch = filter.includes(edge.id);
502
+ const tagMatch = edge.tags?.some((tag) => filter.includes(tag)) ?? false;
503
+ if (idMatch || tagMatch) {
504
+ result.push(edge);
505
+ }
506
+ }
507
+ return result;
508
+ }
509
+
510
+ /** Enabled edges carrying the well-known 'mail' tag. */
511
+ public getMailEdges(): IRemoteIngress[] {
512
+ return this.resolveEdgesByFilter([REMOTE_INGRESS_MAIL_TAG]);
513
+ }
514
+
515
+ private normalizeEdgePublicIp(value: string | undefined, family: 4 | 6): string | undefined {
516
+ if (value === undefined) {
517
+ return undefined;
518
+ }
519
+ const ip = `${value}`.trim();
520
+ if (!ip) {
521
+ return undefined;
522
+ }
523
+ if (plugins.smartnetwork.getIpVersion(ip) !== family) {
524
+ throw new Error(`Edge publicIp${family === 6 ? 'V6' : ''} must be a valid IPv${family} address`);
525
+ }
526
+ if (this.hubPublicIps.includes(ip)) {
527
+ throw new Error(`Edge public IP ${ip} equals the hub public IP — the hub IP must never front edge traffic`);
528
+ }
529
+ return ip;
530
+ }
531
+
532
+ private normalizeMailHostname(value: string | undefined): string | undefined {
533
+ if (value === undefined) {
534
+ return undefined;
535
+ }
536
+ const hostname = `${value}`.trim().toLowerCase().replace(/\.$/, '');
537
+ if (!hostname) {
538
+ return undefined;
539
+ }
540
+ if (hostname.length > 253 || !fqdnRegex.test(hostname)) {
541
+ throw new Error('Edge mailHostname must be a fully qualified domain name');
542
+ }
543
+ return hostname;
544
+ }
545
+
435
546
  private normalizeEgressPolicy(
436
547
  egress?: IRemoteIngressEgressPolicyConfig,
437
548
  ): IRemoteIngressEgressPolicyConfig | undefined {