@serve.zone/dcrouter 15.0.0 → 15.0.2

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 (53) hide show
  1. package/deno.json +1 -1
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/acme/classes.smartacme-lifecycle.d.ts +25 -0
  4. package/dist_ts/acme/classes.smartacme-lifecycle.js +144 -0
  5. package/dist_ts/acme/index.d.ts +1 -0
  6. package/dist_ts/acme/index.js +2 -1
  7. package/dist_ts/classes.dcrouter.d.ts +21 -139
  8. package/dist_ts/classes.dcrouter.js +71 -1585
  9. package/dist_ts/dns/classes.dns-server-runtime.d.ts +37 -0
  10. package/dist_ts/dns/classes.dns-server-runtime.js +449 -0
  11. package/dist_ts/dns/index.d.ts +1 -0
  12. package/dist_ts/dns/index.js +2 -1
  13. package/dist_ts/email/classes.accepted-email-spool.d.ts +55 -0
  14. package/dist_ts/email/classes.accepted-email-spool.js +345 -0
  15. package/dist_ts/email/classes.email-route-builder.d.ts +28 -0
  16. package/dist_ts/email/classes.email-route-builder.js +260 -0
  17. package/dist_ts/email/index.d.ts +2 -0
  18. package/dist_ts/email/index.js +3 -1
  19. package/dist_ts/opsserver/handlers/gatewayclient.handler.js +10 -8
  20. package/dist_ts/remoteingress/classes.hub-lifecycle.d.ts +27 -0
  21. package/dist_ts/remoteingress/classes.hub-lifecycle.js +241 -0
  22. package/dist_ts/remoteingress/classes.remoteingress-manager.d.ts +1 -2
  23. package/dist_ts/remoteingress/index.d.ts +1 -0
  24. package/dist_ts/remoteingress/index.js +2 -1
  25. package/dist_ts/security/classes.route-policy-augmenter.d.ts +22 -0
  26. package/dist_ts/security/classes.route-policy-augmenter.js +120 -0
  27. package/dist_ts/security/index.d.ts +1 -0
  28. package/dist_ts/security/index.js +2 -1
  29. package/dist_ts/vpn/classes.vpn-access-resolver.d.ts +34 -0
  30. package/dist_ts/vpn/classes.vpn-access-resolver.js +101 -0
  31. package/dist_ts/vpn/index.d.ts +1 -0
  32. package/dist_ts/vpn/index.js +2 -1
  33. package/dist_ts_migrations/index.js +92 -9
  34. package/dist_ts_web/00_commitinfo_data.js +1 -1
  35. package/package.json +2 -2
  36. package/ts/00_commitinfo_data.ts +1 -1
  37. package/ts/acme/classes.smartacme-lifecycle.ts +155 -0
  38. package/ts/acme/index.ts +1 -0
  39. package/ts/classes.dcrouter.ts +118 -1919
  40. package/ts/dns/classes.dns-server-runtime.ts +525 -0
  41. package/ts/dns/index.ts +1 -0
  42. package/ts/email/classes.accepted-email-spool.ts +434 -0
  43. package/ts/email/classes.email-route-builder.ts +312 -0
  44. package/ts/email/index.ts +2 -0
  45. package/ts/opsserver/handlers/gatewayclient.handler.ts +9 -7
  46. package/ts/remoteingress/classes.hub-lifecycle.ts +278 -0
  47. package/ts/remoteingress/classes.remoteingress-manager.ts +1 -1
  48. package/ts/remoteingress/index.ts +1 -0
  49. package/ts/security/classes.route-policy-augmenter.ts +140 -0
  50. package/ts/security/index.ts +1 -0
  51. package/ts/vpn/classes.vpn-access-resolver.ts +126 -0
  52. package/ts/vpn/index.ts +1 -0
  53. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -27,61 +27,22 @@ import { commitinfo } from './00_commitinfo_data.js';
27
27
  import { OpsServer } from './opsserver/index.js';
28
28
  import { MetricsManager } from './monitoring/index.js';
29
29
  import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
30
- import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
31
- import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
30
+ import { RemoteIngressHubLifecycle, RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
31
+ import { VpnAccessResolver, VpnManager, type IVpnManagerConfig } from './vpn/index.js';
32
32
  import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
33
33
  import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
34
- import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
34
+ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager, RoutePolicyAugmenter } from './security/index.js';
35
35
  import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
36
36
  import { DnsManager } from './dns/manager.dns.js';
37
+ import { DnsServerRuntime } from './dns/classes.dns-server-runtime.js';
37
38
  import { AcmeConfigManager } from './acme/manager.acme-config.js';
38
- import { EmailDomainManager, EmailSettingsManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
39
+ import { SmartAcmeLifecycle } from './acme/classes.smartacme-lifecycle.js';
40
+ import { AcceptedEmailSpool, EmailDomainManager, EmailRouteBuilder, EmailSettingsManager, SmartMtaStorageManager, WorkAppMailManager, type TSmartMtaQueueItemLike } from './email/index.js';
39
41
  import type { IRoute } from '../ts_interfaces/data/route-management.js';
40
42
  import type { IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
41
43
  import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
42
44
  import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
43
45
 
44
- type TInboundProxyProtocolPolicy = NonNullable<plugins.smartproxy.IRouteMatch['inboundProxyProtocol']>;
45
- const DCROUTER_CACHE_ID_HEADER = 'X-Dcrouter-Cached-Email-Id';
46
- const ACCEPTED_EMAIL_SPOOL_INTERVAL_MS = 60_000;
47
- const ACCEPTED_EMAIL_RETRY_DELAY_MS = 5 * 60_000;
48
- const ACCEPTED_EMAIL_QUEUE_LEASE_MS = 30 * 60_000;
49
- const ACCEPTED_EMAIL_SPOOL_BATCH_SIZE = 25;
50
- const ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS = 30_000;
51
-
52
- type TSmartMtaQueueItemLike = {
53
- processingResult?: {
54
- headers?: Record<string, string>;
55
- email?: { headers?: Record<string, string> };
56
- };
57
- status?: 'pending' | 'processing' | 'queued' | 'delivered' | 'failed' | 'deferred';
58
- attempts?: number;
59
- nextAttempt?: Date;
60
- lastError?: string;
61
- };
62
-
63
- type TStoredCachedEmailEnvelopeAddress = {
64
- address: string;
65
- args?: Record<string, string>;
66
- };
67
-
68
- type TStoredCachedEmailSession = {
69
- id?: string;
70
- clientHostname?: string;
71
- remoteAddress?: string;
72
- secure?: boolean;
73
- authenticated?: boolean;
74
- user?: IExtendedSmtpSession['user'];
75
- envelope?: {
76
- mailFrom?: TStoredCachedEmailEnvelopeAddress;
77
- rcptTo?: TStoredCachedEmailEnvelopeAddress[];
78
- };
79
- };
80
-
81
- type TStoredCachedEmailRouteData = {
82
- session?: TStoredCachedEmailSession;
83
- };
84
-
85
46
  export interface IDcRouterOptions {
86
47
  /** Base directory for all dcrouter data. Defaults to ~/.serve.zone/dcrouter */
87
48
  baseDir?: string;
@@ -320,16 +281,8 @@ export class DcRouter {
320
281
  // Remote Ingress
321
282
  public remoteIngressManager?: RemoteIngressManager;
322
283
  public tunnelManager?: TunnelManager;
323
- private remoteIngressHubLifecycleChain: Promise<void> = Promise.resolve();
324
284
  private smartProxyLifecycleChain: Promise<void> = Promise.resolve();
325
285
  private emailLifecycleChain: Promise<void> = Promise.resolve();
326
- private acceptedEmailSpoolTimer?: ReturnType<typeof setInterval> & { unref?: () => void };
327
- private acceptedEmailSpoolRun?: Promise<void>;
328
- private acceptedEmailSpoolProcessing = false;
329
- private acceptedEmailSpoolStopping = false;
330
- private acceptedEmailQueueUpdatePromises = new Set<Promise<void>>();
331
- private remoteIngressHubStopping = false;
332
- private remoteIngressHubGeneration = 0;
333
286
 
334
287
  // VPN
335
288
  public vpnManager?: VpnManager;
@@ -349,16 +302,13 @@ export class DcRouter {
349
302
  public emailSettingsManager?: EmailSettingsManager;
350
303
  public emailDomainManager?: EmailDomainManager;
351
304
  public workAppMailManager: WorkAppMailManager;
305
+ public acceptedEmailSpool: AcceptedEmailSpool;
352
306
  public securityPolicyManager?: SecurityPolicyManager;
353
307
 
354
308
  // Auto-discovered public IP (populated by generateAuthoritativeRecords)
355
309
  public detectedPublicIp: string | null = null;
356
310
 
357
311
  // DNS query logging rate limiter state
358
- private dnsLogWindowSecond: number = 0; // epoch second of current window
359
- private dnsLogWindowCount: number = 0; // queries logged this second
360
- private dnsBatchCount: number = 0;
361
- private dnsBatchTimer: ReturnType<typeof setTimeout> | null = null;
362
312
 
363
313
  // Certificate status tracking from SmartProxy events (keyed by domain)
364
314
  public certificateStatusMap = new Map<string, {
@@ -376,19 +326,19 @@ export class DcRouter {
376
326
  // Service lifecycle management
377
327
  public serviceManager: plugins.taskbuffer.ServiceManager;
378
328
  private serviceSubjectSubscription?: plugins.smartrx.rxjs.Subscription;
379
- public smartAcmeReady = false;
380
- private smartAcmeServiceStarted = false;
381
- private smartAcmeStartGeneration = 0;
382
- private smartAcmeStartPromise?: Promise<void>;
383
- private smartAcmeRetryTimer?: ReturnType<typeof setTimeout>;
384
- private smartAcmeRetryAttempt = 0;
329
+ public smartAcmeLifecycle: SmartAcmeLifecycle;
330
+ public vpnAccessResolver: VpnAccessResolver;
331
+ public remoteIngressHubLifecycle: RemoteIngressHubLifecycle;
332
+ public dnsServerRuntime: DnsServerRuntime;
333
+ public emailRouteBuilder: EmailRouteBuilder;
334
+ public routePolicyAugmenter: RoutePolicyAugmenter;
385
335
 
386
336
  // TypedRouter for API endpoints
387
337
  public typedrouter = new plugins.typedrequest.TypedRouter();
388
338
 
389
339
  // Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
390
340
  private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
391
- private seedEmailRoutes: IDcRouterRouteConfig[] = [];
341
+ public seedEmailRoutes: IDcRouterRouteConfig[] = [];
392
342
  private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
393
343
  // Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
394
344
  private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
@@ -409,6 +359,13 @@ export class DcRouter {
409
359
  plugins.path.join(this.resolvedPaths.dataDir, 'smartmta-storage')
410
360
  );
411
361
  this.workAppMailManager = new WorkAppMailManager(this);
362
+ this.acceptedEmailSpool = new AcceptedEmailSpool(this);
363
+ this.smartAcmeLifecycle = new SmartAcmeLifecycle(this);
364
+ this.vpnAccessResolver = new VpnAccessResolver(this);
365
+ this.remoteIngressHubLifecycle = new RemoteIngressHubLifecycle(this);
366
+ this.dnsServerRuntime = new DnsServerRuntime(this);
367
+ this.emailRouteBuilder = new EmailRouteBuilder(this);
368
+ this.routePolicyAugmenter = new RoutePolicyAugmenter(this);
412
369
 
413
370
  // Initialize service manager and register all services
414
371
  this.serviceManager = new plugins.taskbuffer.ServiceManager({
@@ -635,7 +592,7 @@ export class DcRouter {
635
592
  }
636
593
  }
637
594
  } finally {
638
- await this.stopSmartAcme();
595
+ await this.smartAcmeLifecycle.stop();
639
596
  }
640
597
  });
641
598
  })
@@ -652,12 +609,12 @@ export class DcRouter {
652
609
  .optional()
653
610
  .dependsOn('SmartProxy')
654
611
  .withStart(async () => {
655
- this.smartAcmeServiceStarted = true;
656
- this.startSmartAcmeInBackground();
612
+ this.smartAcmeLifecycle.serviceStarted = true;
613
+ this.smartAcmeLifecycle.startInBackground();
657
614
  })
658
615
  .withStop(async () => {
659
- this.smartAcmeServiceStarted = false;
660
- await this.stopSmartAcme();
616
+ this.smartAcmeLifecycle.serviceStarted = false;
617
+ await this.smartAcmeLifecycle.stop();
661
618
  })
662
619
  .withRetry({ maxRetries: 0 }),
663
620
  );
@@ -684,20 +641,20 @@ export class DcRouter {
684
641
  this.routeConfigManager = new RouteConfigManager(
685
642
  () => this.smartProxy,
686
643
  () => this.options.http3,
687
- this.createVpnClientAccessResolver(),
644
+ this.vpnAccessResolver.createRouteAllowResolver(),
688
645
  this.referenceResolver,
689
646
  // Sync routes to RemoteIngressManager whenever routes change,
690
647
  // then push updated derived ports to the Rust hub binary
691
648
  async (routes) => {
692
649
  try {
693
- await this.updateRemoteIngressRoutes(routes as IDcRouterRouteConfig[]);
650
+ await this.remoteIngressHubLifecycle.updateRoutes(routes as IDcRouterRouteConfig[]);
694
651
  } catch (err: unknown) {
695
652
  logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
696
653
  }
697
654
  },
698
655
  (preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
699
- (storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
700
- (routes) => this.applyInboundProxyProtocolPolicies(routes),
656
+ (storedRoute: IRoute) => this.emailRouteBuilder.hydrateStoredRouteForRuntime(storedRoute),
657
+ (routes) => this.routePolicyAugmenter.applyInboundProxyProtocolPolicies(routes),
701
658
  );
702
659
  this.apiTokenManager = new ApiTokenManager();
703
660
  await this.apiTokenManager.initialize();
@@ -763,20 +720,11 @@ export class DcRouter {
763
720
  .optional()
764
721
  .dependsOn('SmartProxy', ...((this.options.dbConfig?.enabled !== false) ? ['EmailServer'] : []))
765
722
  .withStart(async () => {
766
- await this.setupDnsWithSocketHandler();
723
+ await this.dnsServerRuntime.setup();
767
724
  })
768
725
  .withStop(async () => {
769
726
  // Flush pending DNS batch log
770
- if (this.dnsBatchTimer) {
771
- clearTimeout(this.dnsBatchTimer);
772
- if (this.dnsBatchCount > 0) {
773
- logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (final flush)`, { zone: 'dns' });
774
- }
775
- this.dnsBatchTimer = null;
776
- this.dnsBatchCount = 0;
777
- this.dnsLogWindowSecond = 0;
778
- this.dnsLogWindowCount = 0;
779
- }
727
+ this.dnsServerRuntime.flushQueryLogBatch();
780
728
  if (this.dnsServer) {
781
729
  this.dnsServer.removeAllListeners();
782
730
  await this.dnsServer.stop();
@@ -814,10 +762,10 @@ export class DcRouter {
814
762
  .optional()
815
763
  .dependsOn('SmartProxy', 'RemoteIngressManager')
816
764
  .withStart(async () => {
817
- await this.setupRemoteIngress();
765
+ await this.remoteIngressHubLifecycle.setup();
818
766
  })
819
767
  .withStop(async () => {
820
- await this.stopRemoteIngress();
768
+ await this.remoteIngressHubLifecycle.stop();
821
769
  })
822
770
  .withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
823
771
  );
@@ -857,7 +805,7 @@ export class DcRouter {
857
805
  });
858
806
  }
859
807
 
860
- private isRemoteIngressHubEnabled(): boolean {
808
+ public isRemoteIngressHubEnabled(): boolean {
861
809
  return this.remoteIngressManager?.getHubSettings().enabled
862
810
  ?? this.options.remoteIngressConfig?.enabled
863
811
  ?? false;
@@ -893,138 +841,6 @@ export class DcRouter {
893
841
  return seed;
894
842
  }
895
843
 
896
- private startSmartAcmeInBackground(): void {
897
- if (!this.smartAcme) {
898
- this.smartAcmeReady = false;
899
- return;
900
- }
901
-
902
- const generation = ++this.smartAcmeStartGeneration;
903
- this.smartAcmeReady = false;
904
- this.smartAcmeRetryAttempt = 0;
905
- this.clearSmartAcmeRetryTimer();
906
- this.scheduleSmartAcmeStart(generation, 0);
907
- }
908
-
909
- private scheduleSmartAcmeStart(generation: number, delayMs: number): void {
910
- this.clearSmartAcmeRetryTimer();
911
- const retryTimer = setTimeout(() => {
912
- this.smartAcmeRetryTimer = undefined;
913
- this.runSmartAcmeStartAttempt(generation).catch((err) => {
914
- logger.log('error', `Unexpected SmartAcme startup error: ${(err as Error).message}`);
915
- });
916
- }, delayMs);
917
- this.smartAcmeRetryTimer = retryTimer;
918
- const unrefableTimer = retryTimer as any;
919
- if (typeof unrefableTimer?.unref === 'function') {
920
- unrefableTimer.unref();
921
- }
922
- }
923
-
924
- private async runSmartAcmeStartAttempt(generation: number): Promise<void> {
925
- const smartAcme = this.smartAcme;
926
- if (!smartAcme || generation !== this.smartAcmeStartGeneration) {
927
- return;
928
- }
929
-
930
- const startPromise = smartAcme.start();
931
- this.smartAcmeStartPromise = startPromise;
932
-
933
- try {
934
- await startPromise;
935
- if (generation !== this.smartAcmeStartGeneration || this.smartAcme !== smartAcme) {
936
- await smartAcme.stop().catch((err) => {
937
- logger.log('warn', `Failed to stop stale SmartAcme instance: ${(err as Error).message}`);
938
- });
939
- return;
940
- }
941
-
942
- this.smartAcmeReady = true;
943
- this.smartAcmeRetryAttempt = 0;
944
- logger.log('info', 'SmartAcme DNS-01 provider is now ready');
945
- this.retriggerCertificateProvisioningAfterSmartAcmeReady();
946
- } catch (err) {
947
- if (generation !== this.smartAcmeStartGeneration || this.smartAcme !== smartAcme) {
948
- return;
949
- }
950
-
951
- this.smartAcmeReady = false;
952
- await smartAcme.stop().catch((stopErr) => {
953
- logger.log('warn', `Failed to clean up SmartAcme after startup failure: ${(stopErr as Error).message}`);
954
- });
955
- this.smartAcmeRetryAttempt++;
956
- if (this.smartAcmeRetryAttempt > 20) {
957
- logger.log('error', `SmartAcme DNS-01 provider failed after 20 startup attempts: ${(err as Error).message}`);
958
- return;
959
- }
960
-
961
- const baseDelayMs = 5000;
962
- const maxDelayMs = 3_600_000;
963
- const delayMs = Math.min(baseDelayMs * Math.pow(2, this.smartAcmeRetryAttempt - 1), maxDelayMs);
964
- const jitter = 0.8 + Math.random() * 0.4;
965
- const actualDelayMs = Math.floor(delayMs * jitter);
966
- logger.log('warn', `SmartAcme DNS-01 provider startup failed: ${(err as Error).message}; retrying in ${actualDelayMs}ms (attempt ${this.smartAcmeRetryAttempt}/20)`);
967
- this.scheduleSmartAcmeStart(generation, actualDelayMs);
968
- } finally {
969
- if (this.smartAcmeStartPromise === startPromise) {
970
- this.smartAcmeStartPromise = undefined;
971
- }
972
- }
973
- }
974
-
975
- private retriggerCertificateProvisioningAfterSmartAcmeReady(): void {
976
- // During startup, certProvisionFunction returns 'http01' while SmartAcme is not ready,
977
- // but Rust ACME is disabled when certProvisionFunction is set. Re-applying routes
978
- // retries provisioning now that DNS-01 is available.
979
- if (this.routeConfigManager) {
980
- logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
981
- this.routeConfigManager.applyRoutes().catch((err: any) => {
982
- logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
983
- });
984
- return;
985
- }
986
-
987
- if (this.smartProxy) {
988
- if (this.certProvisionScheduler) {
989
- this.certProvisionScheduler.clear();
990
- }
991
- const currentRoutes = this.smartProxy.routeManager.getRoutes();
992
- logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
993
- this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
994
- logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
995
- });
996
- }
997
- }
998
-
999
- private clearSmartAcmeRetryTimer(): void {
1000
- if (this.smartAcmeRetryTimer) {
1001
- clearTimeout(this.smartAcmeRetryTimer);
1002
- this.smartAcmeRetryTimer = undefined;
1003
- }
1004
- }
1005
-
1006
- private async stopSmartAcme(): Promise<void> {
1007
- this.smartAcmeStartGeneration++;
1008
- this.smartAcmeReady = false;
1009
- this.smartAcmeRetryAttempt = 0;
1010
- this.clearSmartAcmeRetryTimer();
1011
-
1012
- const smartAcme = this.smartAcme;
1013
- if (!smartAcme) {
1014
- return;
1015
- }
1016
-
1017
- try {
1018
- await smartAcme.stop();
1019
- } catch (err) {
1020
- logger.log('error', 'Error stopping SmartAcme', { error: String(err) });
1021
- } finally {
1022
- if (this.smartAcme === smartAcme) {
1023
- this.smartAcme = undefined;
1024
- }
1025
- }
1026
- }
1027
-
1028
844
  public async start() {
1029
845
  await this.checkSystemLimits();
1030
846
  logger.log('info', 'Starting DcRouter Services');
@@ -1196,7 +1012,7 @@ export class DcRouter {
1196
1012
  this.smartProxy = undefined;
1197
1013
  }
1198
1014
  } finally {
1199
- await this.stopSmartAcme();
1015
+ await this.smartAcmeLifecycle.stop();
1200
1016
  }
1201
1017
  }
1202
1018
 
@@ -1207,7 +1023,7 @@ export class DcRouter {
1207
1023
 
1208
1024
  this.seedEmailRoutes = [];
1209
1025
  if (this.options.emailConfig && this.options.dbConfig?.enabled !== false) {
1210
- this.seedEmailRoutes = this.generateEmailRoutes(this.options.emailConfig);
1026
+ this.seedEmailRoutes = this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig);
1211
1027
  logger.log('debug', 'Email routes generated', { routes: JSON.stringify(this.seedEmailRoutes) });
1212
1028
  } else if (this.options.emailConfig) {
1213
1029
  logger.log('warn', 'Email routes skipped because dbConfig.enabled=false and SMTP acceptance requires durable DB persistence');
@@ -1224,7 +1040,7 @@ export class DcRouter {
1224
1040
  // Combined routes for SmartProxy bootstrap (before DB routes are loaded)
1225
1041
  let routes: plugins.smartproxy.IRouteConfig[] = [
1226
1042
  ...this.seedConfigRoutes,
1227
- ...this.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
1043
+ ...this.emailRouteBuilder.getRuntimeEmailRoutes(this.seedEmailRoutes as IDcRouterRouteConfig[]),
1228
1044
  ...this.runtimeDnsRoutes,
1229
1045
  ];
1230
1046
 
@@ -1273,10 +1089,10 @@ export class DcRouter {
1273
1089
  routes = augmentRoutesWithHttp3(routes, http3Config);
1274
1090
  logger.log('info', 'HTTP/3: Augmented qualifying HTTPS routes with QUIC/H3 configuration');
1275
1091
  }
1276
- routes = this.applyInboundProxyProtocolPolicies(routes);
1092
+ routes = this.routePolicyAugmenter.applyInboundProxyProtocolPolicies(routes);
1277
1093
 
1278
1094
  const compiledSecurityPolicy = await this.securityPolicyManager?.compileSmartProxyPolicy();
1279
- const mergedSecurityPolicy = this.mergeSecurityPolicies(
1095
+ const mergedSecurityPolicy = this.routePolicyAugmenter.mergeSecurityPolicies(
1280
1096
  (this.options.smartProxyConfig as any)?.securityPolicy,
1281
1097
  compiledSecurityPolicy,
1282
1098
  );
@@ -1359,7 +1175,7 @@ export class DcRouter {
1359
1175
  // SmartAcme starts in the background because ACME account setup can be slow or rate-limited,
1360
1176
  // and must not block dcrouter's global startup timeout.
1361
1177
  if (this.smartAcme) {
1362
- await this.stopSmartAcme();
1178
+ await this.smartAcmeLifecycle.stop();
1363
1179
  }
1364
1180
  if (challengeHandlers.length > 0) {
1365
1181
  // Safe non-null: challengeHandlers.length > 0 implies both dnsManager
@@ -1371,15 +1187,15 @@ export class DcRouter {
1371
1187
  challengeHandlers: challengeHandlers,
1372
1188
  challengePriority: ['dns-01'],
1373
1189
  });
1374
- if (this.smartAcmeServiceStarted) {
1375
- this.startSmartAcmeInBackground();
1190
+ if (this.smartAcmeLifecycle.serviceStarted) {
1191
+ this.smartAcmeLifecycle.startInBackground();
1376
1192
  }
1377
1193
 
1378
1194
  const scheduler = this.certProvisionScheduler;
1379
1195
  smartProxyConfig.certProvisionFallbackToAcme = false;
1380
1196
  smartProxyConfig.certProvisionFunction = async (domain, eventComms) => {
1381
1197
  // If SmartAcme is not yet ready (still starting or retrying), fall back to HTTP-01
1382
- if (!this.smartAcmeReady) {
1198
+ if (!this.smartAcmeLifecycle.ready) {
1383
1199
  eventComms.warn(`SmartAcme not yet initialized, falling back to http-01 for ${domain}`);
1384
1200
  return 'http01';
1385
1201
  }
@@ -1492,7 +1308,7 @@ export class DcRouter {
1492
1308
  if (this.smartProxy === smartProxy) {
1493
1309
  this.smartProxy = undefined;
1494
1310
  }
1495
- await this.stopSmartAcme();
1311
+ await this.smartAcmeLifecycle.stop();
1496
1312
  if (this.certProvisionScheduler) {
1497
1313
  this.certProvisionScheduler.clear();
1498
1314
  this.certProvisionScheduler = undefined;
@@ -1570,7 +1386,7 @@ export class DcRouter {
1570
1386
  }
1571
1387
 
1572
1388
  const compiledSmartProxyPolicy = await this.securityPolicyManager.compileSmartProxyPolicy();
1573
- const mergedSecurityPolicy = this.mergeSecurityPolicies(
1389
+ const mergedSecurityPolicy = this.routePolicyAugmenter.mergeSecurityPolicies(
1574
1390
  (this.options.smartProxyConfig as any)?.securityPolicy,
1575
1391
  compiledSmartProxyPolicy,
1576
1392
  );
@@ -1583,240 +1399,9 @@ export class DcRouter {
1583
1399
  }
1584
1400
 
1585
1401
  const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall();
1586
- await this.queueRemoteIngressHubTask(async () => {
1587
- if (this.remoteIngressHubStopping) return;
1588
- if (this.remoteIngressManager) {
1589
- this.remoteIngressManager.setFirewallConfig(firewallConfig);
1590
- }
1591
- if (this.tunnelManager) {
1592
- await this.tunnelManager.syncAllowedEdges();
1593
- }
1594
- });
1595
- }
1596
-
1597
- private mergeSecurityPolicies(
1598
- ...policies: Array<Partial<ISecurityCompiledPolicy> | undefined>
1599
- ): ISecurityCompiledPolicy | undefined {
1600
- const blockedIps = new Set<string>();
1601
- const blockedCidrs = new Set<string>();
1602
-
1603
- for (const policy of policies) {
1604
- for (const ip of policy?.blockedIps || []) {
1605
- if (ip) blockedIps.add(ip);
1606
- }
1607
- for (const cidr of policy?.blockedCidrs || []) {
1608
- if (cidr) blockedCidrs.add(cidr);
1609
- }
1610
- }
1611
-
1612
- if (blockedIps.size === 0 && blockedCidrs.size === 0) {
1613
- return undefined;
1614
- }
1615
-
1616
- return {
1617
- blockedIps: [...blockedIps].sort(),
1618
- blockedCidrs: [...blockedCidrs].sort(),
1619
- };
1620
- }
1621
-
1622
-
1623
-
1624
- private applyInboundProxyProtocolPolicies(
1625
- routes: plugins.smartproxy.IRouteConfig[],
1626
- ): plugins.smartproxy.IRouteConfig[] {
1627
- const policiesByListener = new Map<string, TInboundProxyProtocolPolicy>();
1628
-
1629
- for (const route of routes) {
1630
- const policy = route.match?.inboundProxyProtocol || this.getDesiredInboundProxyProtocolPolicy(route);
1631
- if (!policy) {
1632
- continue;
1633
- }
1634
- for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
1635
- const mergedPolicy = this.mergeInboundProxyProtocolPolicies(
1636
- policiesByListener.get(listenerKey),
1637
- policy,
1638
- );
1639
- if (mergedPolicy) {
1640
- policiesByListener.set(listenerKey, mergedPolicy);
1641
- }
1642
- }
1643
- }
1644
-
1645
- if (policiesByListener.size === 0) {
1646
- return routes;
1647
- }
1648
-
1649
- return routes.map((route) => {
1650
- if (route.match?.inboundProxyProtocol) {
1651
- return route;
1652
- }
1653
- let listenerPolicy: TInboundProxyProtocolPolicy | undefined;
1654
- for (const listenerKey of this.getInboundProxyListenerKeys(route)) {
1655
- listenerPolicy = this.mergeInboundProxyProtocolPolicies(
1656
- listenerPolicy,
1657
- policiesByListener.get(listenerKey),
1658
- );
1659
- }
1660
- if (!listenerPolicy) {
1661
- return route;
1662
- }
1663
- return {
1664
- ...route,
1665
- match: {
1666
- ...route.match,
1667
- inboundProxyProtocol: listenerPolicy,
1668
- },
1669
- };
1670
- });
1671
- }
1672
-
1673
- private getDesiredInboundProxyProtocolPolicy(
1674
- route: plugins.smartproxy.IRouteConfig,
1675
- ): TInboundProxyProtocolPolicy | undefined {
1676
- const dcRoute = route as IDcRouterRouteConfig;
1677
- if (this.isRemoteIngressHubEnabled() && dcRoute.remoteIngress?.enabled) {
1678
- const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
1679
- if (ports.some((port) => port === 25 || port === 587)) {
1680
- return { mode: 'required' };
1681
- }
1682
- return { mode: 'optional' };
1683
- }
1684
- if (this.options.vpnConfig?.enabled) {
1685
- return { mode: 'optional' };
1686
- }
1687
- return undefined;
1688
- }
1689
-
1690
- private getInboundProxyListenerKeys(route: plugins.smartproxy.IRouteConfig): string[] {
1691
- const ports = plugins.smartproxy.expandPortRange(route.match.ports as any) as number[];
1692
- const transports = route.match.transport === 'udp'
1693
- ? ['udp']
1694
- : route.match.transport === 'all'
1695
- ? ['tcp', 'udp']
1696
- : ['tcp'];
1697
- const keys: string[] = [];
1698
- for (const port of ports) {
1699
- for (const transport of transports) {
1700
- keys.push(`${transport}:${port}`);
1701
- }
1702
- }
1703
- return keys;
1704
- }
1705
-
1706
- private mergeInboundProxyProtocolPolicies(
1707
- current?: TInboundProxyProtocolPolicy,
1708
- next?: TInboundProxyProtocolPolicy,
1709
- ): TInboundProxyProtocolPolicy | undefined {
1710
- if (!current) return next;
1711
- if (!next) return current;
1712
- if (current.mode === 'required') return current;
1713
- if (next.mode === 'required') return next;
1714
- if (current.mode === 'optional') return current;
1715
- if (next.mode === 'optional') return next;
1716
- return current;
1402
+ await this.remoteIngressHubLifecycle.applyFirewallConfig(firewallConfig);
1717
1403
  }
1718
1404
 
1719
- /**
1720
- * Generate SmartProxy routes for email configuration
1721
- */
1722
- private generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): IDcRouterRouteConfig[] {
1723
- const emailRoutes: IDcRouterRouteConfig[] = [];
1724
-
1725
- // Create routes for each email port
1726
- for (const port of emailConfig.ports) {
1727
- // Create a descriptive name for the route based on the port
1728
- let routeName = 'email-route';
1729
- let tlsMode: 'terminate' | undefined;
1730
-
1731
- // Handle different email ports differently
1732
- switch (port) {
1733
- case 25: // SMTP
1734
- routeName = 'smtp-route';
1735
- break;
1736
-
1737
- case 587: // Submission
1738
- routeName = 'submission-route';
1739
- break;
1740
-
1741
- case 465: // SMTPS
1742
- routeName = 'smtps-route';
1743
- tlsMode = 'terminate'; // SmartProxy owns public TLS; backend remains server-first SMTP
1744
- break;
1745
-
1746
- default:
1747
- routeName = `email-port-${port}-route`;
1748
-
1749
- // Check if we have specific settings for this port
1750
- if (this.options.emailPortConfig?.portSettings &&
1751
- this.options.emailPortConfig.portSettings[port]) {
1752
- const portSettings = this.options.emailPortConfig.portSettings[port];
1753
-
1754
- // If this port requires TLS termination, set the mode accordingly
1755
- if (portSettings.terminateTls) {
1756
- tlsMode = 'terminate';
1757
- }
1758
-
1759
- // Override the route name if specified
1760
- if (portSettings.routeName) {
1761
- routeName = portSettings.routeName;
1762
- }
1763
- }
1764
- break;
1765
- }
1766
-
1767
- // Create forward action to route to internal email server ports
1768
- const defaultPortMapping: Record<number, number> = {
1769
- 25: 10025, // SMTP
1770
- 587: 10587, // Submission
1771
- 465: 10465 // SMTPS
1772
- };
1773
-
1774
- const portMapping = this.options.emailPortConfig?.portMapping || defaultPortMapping;
1775
- const internalPort = portMapping[port] || port + 10000;
1776
-
1777
- let action: any = {
1778
- type: 'forward',
1779
- sendProxyProtocol: true,
1780
- targets: [{
1781
- host: 'localhost', // Forward to internal email server
1782
- port: internalPort,
1783
- sendProxyProtocol: true,
1784
- }]
1785
- };
1786
-
1787
- // Plain SMTP/STARTTLS ports must not carry TLS metadata, or SmartProxy waits for TLS/SNI first.
1788
- if (tlsMode === 'terminate') {
1789
- action.tls = {
1790
- mode: tlsMode,
1791
- certificate: 'auto',
1792
- };
1793
- }
1794
-
1795
- // Create the route configuration
1796
- const routeConfig: IDcRouterRouteConfig = {
1797
- name: routeName,
1798
- match: {
1799
- ports: [port],
1800
- transport: 'tcp',
1801
- },
1802
- action: action
1803
- };
1804
-
1805
- if (this.isRemoteIngressHubEnabled()) {
1806
- routeConfig.remoteIngress = { enabled: true };
1807
- const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(port);
1808
- if (inboundProxyProtocol) {
1809
- routeConfig.match.inboundProxyProtocol = inboundProxyProtocol;
1810
- }
1811
- }
1812
-
1813
- // Add the route to our list
1814
- emailRoutes.push(routeConfig);
1815
- }
1816
-
1817
- return emailRoutes;
1818
- }
1819
-
1820
1405
  /**
1821
1406
  * Generate SmartProxy routes for DNS configuration
1822
1407
  */
@@ -1845,7 +1430,7 @@ export class DcRouter {
1845
1430
  action: includeSocketHandler
1846
1431
  ? {
1847
1432
  type: 'socket-handler' as any,
1848
- socketHandler: this.createDnsSocketHandler()
1433
+ socketHandler: this.dnsServerRuntime.createSocketHandler()
1849
1434
  } as any
1850
1435
  : {
1851
1436
  type: 'socket-handler' as any,
@@ -1858,198 +1443,6 @@ export class DcRouter {
1858
1443
  return dnsRoutes;
1859
1444
  }
1860
1445
 
1861
- private getRuntimeEmailRoutes(emailRoutes: IDcRouterRouteConfig[]): plugins.smartproxy.IRouteConfig[] {
1862
- return emailRoutes.map((route) => this.createServerFirstEmailRuntimeRoute(route) || route);
1863
- }
1864
-
1865
- private getCurrentGeneratedEmailRouteNames(): Set<string> {
1866
- if (this.options.dbConfig?.enabled === false) {
1867
- return new Set();
1868
- }
1869
- const sourceRoutes = this.seedEmailRoutes.length > 0
1870
- ? this.seedEmailRoutes
1871
- : this.options.emailConfig
1872
- ? this.generateEmailRoutes(this.options.emailConfig)
1873
- : [];
1874
- return new Set(sourceRoutes.map((route) => route.name).filter(Boolean) as string[]);
1875
- }
1876
-
1877
- private shouldHydrateGeneratedEmailRoute(storedRoute: IRoute): boolean {
1878
- if (storedRoute.origin !== 'email') {
1879
- return false;
1880
- }
1881
- const routeName = storedRoute.route.name;
1882
- if (!routeName || !this.getCurrentGeneratedEmailRouteNames().has(routeName)) {
1883
- return false;
1884
- }
1885
- const expectedSystemKey = `email:${routeName}`;
1886
- return !storedRoute.systemKey || storedRoute.systemKey === expectedSystemKey;
1887
- }
1888
-
1889
- private createServerFirstEmailRuntimeRoute(
1890
- route: plugins.smartproxy.IRouteConfig,
1891
- ): plugins.smartproxy.IRouteConfig | undefined {
1892
- const action = route.action as any;
1893
- if (action?.type !== 'forward') {
1894
- return undefined;
1895
- }
1896
- const tlsMode = action.tls?.mode;
1897
- if (tlsMode === 'terminate-and-reencrypt') {
1898
- return undefined;
1899
- }
1900
- const routePorts = plugins.smartproxy.expandPortRange(route.match?.ports as any) as number[];
1901
- if (routePorts.length !== 1) {
1902
- return undefined;
1903
- }
1904
-
1905
- const target = action.targets?.[0];
1906
- if (!target || action.targets.length !== 1 || typeof target.port !== 'number') {
1907
- return undefined;
1908
- }
1909
- if (typeof target.host !== 'string') {
1910
- return undefined;
1911
- }
1912
-
1913
- const targetHost = target.host === 'localhost' ? '127.0.0.1' : target.host;
1914
- const inboundProxyProtocol = this.getRemoteIngressEmailInboundProxyPolicy(routePorts[0]);
1915
- return {
1916
- ...route,
1917
- match: {
1918
- ...route.match,
1919
- ...(inboundProxyProtocol
1920
- ? { inboundProxyProtocol }
1921
- : {}),
1922
- },
1923
- action: {
1924
- type: 'socket-handler' as any,
1925
- ...(action.tls
1926
- ? { tls: action.tls }
1927
- : {}),
1928
- socketHandler: this.createEmailSocketProxyHandler(targetHost, target.port),
1929
- } as any,
1930
- };
1931
- }
1932
-
1933
- private getRemoteIngressEmailInboundProxyPolicy(
1934
- port: number,
1935
- ): TInboundProxyProtocolPolicy | undefined {
1936
- if (!this.isRemoteIngressHubEnabled()) {
1937
- return undefined;
1938
- }
1939
- return { mode: port === 25 || port === 587 ? 'required' : 'optional' };
1940
- }
1941
-
1942
- private createEmailSocketProxyHandler(
1943
- targetHost: string,
1944
- targetPort: number,
1945
- ): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
1946
- return (clientSocket, context) => {
1947
- let backendSocket: plugins.net.Socket | undefined;
1948
- let connectTimeout: ReturnType<typeof setTimeout> & { unref?: () => void };
1949
- let cleanupDone = false;
1950
-
1951
- const cleanup = () => {
1952
- if (cleanupDone) return;
1953
- cleanupDone = true;
1954
- clearTimeout(connectTimeout);
1955
- clientSocket.removeListener('timeout', cleanup);
1956
- clientSocket.removeListener('error', cleanup);
1957
- clientSocket.removeListener('end', cleanup);
1958
- clientSocket.removeListener('close', cleanup);
1959
- backendSocket?.removeListener('timeout', cleanup);
1960
- backendSocket?.removeListener('error', cleanup);
1961
- backendSocket?.removeListener('end', cleanup);
1962
- backendSocket?.removeListener('close', cleanup);
1963
- clientSocket.destroy();
1964
- backendSocket?.destroy();
1965
- };
1966
-
1967
- connectTimeout = setTimeout(() => {
1968
- cleanup();
1969
- }, 30_000);
1970
- connectTimeout.unref?.();
1971
-
1972
- clientSocket.setTimeout(300_000);
1973
- clientSocket.on('timeout', cleanup);
1974
- clientSocket.on('error', cleanup);
1975
- clientSocket.on('end', cleanup);
1976
- clientSocket.on('close', cleanup);
1977
-
1978
- backendSocket = plugins.net.connect(targetPort, targetHost, () => {
1979
- clearTimeout(connectTimeout);
1980
- backendSocket?.setTimeout(300_000);
1981
- const proxyHeader = this.createProxyProtocolV1Header(
1982
- context?.clientIp,
1983
- targetHost,
1984
- 0,
1985
- targetPort,
1986
- );
1987
- if (!proxyHeader) {
1988
- cleanup();
1989
- return;
1990
- }
1991
- backendSocket!.write(proxyHeader, () => {
1992
- clientSocket.pipe(backendSocket!);
1993
- backendSocket!.pipe(clientSocket);
1994
- });
1995
- });
1996
- backendSocket.setTimeout(30_000);
1997
- backendSocket.on('timeout', cleanup);
1998
- backendSocket.on('error', cleanup);
1999
- backendSocket.on('end', cleanup);
2000
- backendSocket.on('close', cleanup);
2001
- };
2002
- }
2003
-
2004
- private createProxyProtocolV1Header(
2005
- sourceIp: string | undefined,
2006
- destinationIp: string,
2007
- sourcePort: number,
2008
- destinationPort: number,
2009
- ): string | undefined {
2010
- if (!sourceIp || !plugins.net.isIP(sourceIp)) {
2011
- logger.log('warn', `Cannot create email PROXY protocol header for invalid source IP: ${sourceIp || 'unknown'}`);
2012
- return undefined;
2013
- }
2014
- const sourceFamily = plugins.net.isIP(sourceIp);
2015
- const destinationAddress = destinationIp === 'localhost' || destinationIp === '127.0.0.1' || destinationIp === '::1'
2016
- ? sourceFamily === 6 ? '::1' : '127.0.0.1'
2017
- : destinationIp;
2018
- const destinationFamily = plugins.net.isIP(destinationAddress);
2019
- if (!destinationFamily) {
2020
- logger.log('warn', `Cannot create email PROXY protocol header for invalid destination IP: ${destinationIp}`);
2021
- return undefined;
2022
- }
2023
- if (sourceFamily !== destinationFamily) {
2024
- return undefined;
2025
- }
2026
- const protocol = sourceFamily === 6 ? 'TCP6' : 'TCP4';
2027
- return `PROXY ${protocol} ${sourceIp} ${destinationAddress} ${sourcePort} ${destinationPort}\r\n`;
2028
- }
2029
-
2030
- private hydrateStoredRouteForRuntime(storedRoute: IRoute): plugins.smartproxy.IRouteConfig | undefined {
2031
- const routeName = storedRoute.route.name || '';
2032
- const isDohRoute = storedRoute.origin === 'dns'
2033
- && storedRoute.route.action?.type === 'socket-handler'
2034
- && routeName.startsWith('dns-over-https-');
2035
-
2036
- if (!isDohRoute) {
2037
- if (this.shouldHydrateGeneratedEmailRoute(storedRoute)) {
2038
- return this.createServerFirstEmailRuntimeRoute(storedRoute.route);
2039
- }
2040
- return undefined;
2041
- }
2042
-
2043
- return {
2044
- ...storedRoute.route,
2045
- action: {
2046
- ...storedRoute.route.action,
2047
- type: 'socket-handler' as any,
2048
- socketHandler: this.createDnsSocketHandler(),
2049
- } as any,
2050
- };
2051
- }
2052
-
2053
1446
  /**
2054
1447
  * Check if a domain matches a pattern (including wildcard support)
2055
1448
  * @param domain The domain to check
@@ -2217,7 +1610,7 @@ export class DcRouter {
2217
1610
  if (configuredDecision && !configuredDecision.accepted) {
2218
1611
  return configuredDecision;
2219
1612
  }
2220
- const dcrouterDecision = await this.acceptSmartMtaMessage(
1613
+ const dcrouterDecision = await this.acceptedEmailSpool.acceptMessage(
2221
1614
  context,
2222
1615
  configuredDecision ? configuredDecision.continueProcessing === true : true,
2223
1616
  );
@@ -2258,16 +1651,16 @@ export class DcRouter {
2258
1651
 
2259
1652
  try {
2260
1653
  this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: TSmartMtaQueueItemLike) => {
2261
- this.trackAcceptedEmailQueueUpdate(item, 'queued', 'Unable to update accepted email after queue enqueue');
1654
+ this.acceptedEmailSpool.trackQueueUpdate(item, 'queued', 'Unable to update accepted email after queue enqueue');
2262
1655
  });
2263
1656
  this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: TSmartMtaQueueItemLike) => {
2264
- this.trackAcceptedEmailQueueUpdate(item, 'delivered', 'Unable to mark accepted email delivered');
1657
+ this.acceptedEmailSpool.trackQueueUpdate(item, 'delivered', 'Unable to mark accepted email delivered');
2265
1658
  });
2266
1659
  this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', (item: TSmartMtaQueueItemLike) => {
2267
- this.trackAcceptedEmailQueueUpdate(item, 'queued', 'Unable to defer accepted email');
1660
+ this.acceptedEmailSpool.trackQueueUpdate(item, 'queued', 'Unable to defer accepted email');
2268
1661
  });
2269
1662
  this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: TSmartMtaQueueItemLike) => {
2270
- this.trackAcceptedEmailQueueUpdate(item, 'failed', 'Unable to mark accepted email failed');
1663
+ this.acceptedEmailSpool.trackQueueUpdate(item, 'failed', 'Unable to mark accepted email failed');
2271
1664
  });
2272
1665
 
2273
1666
  // Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
@@ -2320,18 +1713,17 @@ export class DcRouter {
2320
1713
  updateQueueSize();
2321
1714
  }
2322
1715
 
2323
- await this.recoverQueuedAcceptedEmails();
2324
- this.startAcceptedEmailSpoolProcessor();
1716
+ await this.acceptedEmailSpool.recoverQueuedEmails();
1717
+ this.acceptedEmailSpool.start();
2325
1718
  } catch (error: unknown) {
2326
- this.acceptedEmailSpoolStopping = true;
2327
- this.clearAcceptedEmailSpoolTimer();
1719
+ this.acceptedEmailSpool.beginStop();
2328
1720
  try {
2329
1721
  await emailServer.stop();
2330
1722
  } catch (stopError: unknown) {
2331
1723
  logger.log('warn', `Error cleaning up failed UnifiedEmailServer setup: ${(stopError as Error).message}`);
2332
1724
  }
2333
- await this.stopAcceptedEmailSpoolProcessor();
2334
- await this.drainAcceptedEmailQueueUpdates();
1725
+ await this.acceptedEmailSpool.stop();
1726
+ await this.acceptedEmailSpool.drainQueueUpdates();
2335
1727
  this.clearEmailEventSubscriptions();
2336
1728
  if (this.emailServer === emailServer) {
2337
1729
  this.emailServer = undefined;
@@ -2342,421 +1734,63 @@ export class DcRouter {
2342
1734
  logger.log('info', `Email server started on ports: ${emailConfig.ports.join(', ')}`);
2343
1735
  }
2344
1736
 
2345
- private async acceptSmartMtaMessage(
2346
- context: IMessageAcceptanceContext,
2347
- processAfterAccept = true,
2348
- ): Promise<IMessageAcceptanceDecision> {
2349
- if (!this.dcRouterDb?.isReady()) {
2350
- throw new Error('DcRouterDb is not available for email acceptance');
2351
- }
2352
- this.throwIfMessageAcceptanceAborted(context.abortSignal);
2353
-
2354
- const rawMessage = context.rawMessage;
2355
- const session = context.session;
2356
- const envelope = session.envelope;
2357
- const envelopeRecipients = Array.isArray(envelope.rcptTo)
2358
- ? envelope.rcptTo.map((recipient) => recipient.address).filter(Boolean)
2359
- : [];
2360
- const email = context.email;
2361
- const headers = email.headers;
2362
-
2363
- const cachedEmail = CachedEmail.createNew();
2364
- this.removeHeader(email.headers, DCROUTER_CACHE_ID_HEADER);
2365
- email.headers[DCROUTER_CACHE_ID_HEADER] = cachedEmail.id;
2366
- cachedEmail.messageId = headers['Message-ID'] || headers['message-id'] || cachedEmail.id;
2367
- cachedEmail.from = envelope.mailFrom?.address || email.from || '';
2368
- cachedEmail.to = envelopeRecipients.length > 0
2369
- ? envelopeRecipients
2370
- : Array.isArray(email.to) ? email.to : [];
2371
- cachedEmail.cc = Array.isArray(email.cc) ? email.cc : [];
2372
- cachedEmail.bcc = Array.isArray(email.bcc) ? email.bcc : [];
2373
- cachedEmail.subject = email.subject || '';
2374
- cachedEmail.rawContent = this.setDcRouterCacheIdHeader(rawMessage.toString('utf8'), cachedEmail.id);
2375
- cachedEmail.status = 'pending';
2376
- cachedEmail.nextAttempt = new Date();
2377
- cachedEmail.routeData = JSON.stringify({
2378
- acceptedAt: new Date().toISOString(),
2379
- session: {
2380
- id: session.id,
2381
- remoteAddress: session.remoteAddress,
2382
- clientHostname: session.clientHostname,
2383
- secure: !!session.secure,
2384
- authenticated: !!session.authenticated,
2385
- user: session.user,
2386
- envelope,
2387
- },
2388
- });
2389
- cachedEmail.updateSenderDomain();
2390
- await cachedEmail.save();
2391
- if (context.abortSignal?.aborted) {
2392
- cachedEmail.markFailed('Message acceptance aborted before SMTP success');
2393
- await cachedEmail.save();
2394
- throw new Error('Message acceptance aborted before SMTP success');
2395
- }
1737
+
1738
+ /**
1739
+ * Update the unified email configuration
1740
+ * @param config New email configuration
1741
+ */
1742
+ public async updateEmailConfig(config: IUnifiedEmailServerOptions): Promise<void> {
1743
+ await this.queueEmailLifecycleTask(async () => {
1744
+ // Stop existing email components
1745
+ await this.stopUnifiedEmailComponents();
2396
1746
 
2397
- if (processAfterAccept) {
2398
- this.runAcceptedEmailSpoolProcessor();
2399
- } else {
2400
- cachedEmail.markDelivered();
2401
- await cachedEmail.save();
2402
- }
1747
+ // Update configuration
1748
+ this.options.emailConfig = config;
1749
+ this.emailDomainManager?.setBaseEmailDomains(config.domains as IEmailDomainConfig[] | undefined);
1750
+ await this.emailDomainManager?.syncManagedDomainsToRuntime();
2403
1751
 
2404
- return {
2405
- accepted: true,
2406
- smtpCode: 250,
2407
- smtpMessage: '2.0.0 Message accepted for delivery',
2408
- continueProcessing: false,
2409
- };
2410
- }
1752
+ // Start email handling with new configuration
1753
+ await this.setupUnifiedEmailHandling();
2411
1754
 
2412
- private setDcRouterCacheIdHeader(rawContent: string, cachedEmailId: string): string {
2413
- const headerRegex = new RegExp(`^${DCROUTER_CACHE_ID_HEADER}:.*(?:\r?\n[\t ].*)*\r?\n?`, 'gim');
2414
- const sanitizedContent = rawContent.replace(headerRegex, '');
2415
- return `${DCROUTER_CACHE_ID_HEADER}: ${cachedEmailId}\r\n${sanitizedContent}`;
1755
+ logger.log('info', 'Unified email configuration updated');
1756
+ });
2416
1757
  }
2417
1758
 
2418
- private async processAcceptedCachedEmail(
2419
- cachedEmail: CachedEmail,
2420
- emailData: Email | plugins.buffer.Buffer,
2421
- session: IExtendedSmtpSession,
2422
- emailServer: UnifiedEmailServer,
2423
- ): Promise<void> {
2424
- cachedEmail.status = 'processing';
2425
- cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
2426
- await cachedEmail.save();
2427
-
2428
- try {
2429
- await emailServer.processEmailByMode(emailData, session);
2430
- if (session.matchedRoute?.action.type === 'forward') {
2431
- cachedEmail.markDelivered();
2432
- } else {
2433
- const currentCachedEmail = await CachedEmail.findById(cachedEmail.id) || cachedEmail;
2434
- if (this.isCachedEmailTerminal(currentCachedEmail)) {
2435
- return;
2436
- }
2437
- currentCachedEmail.status = 'queued';
2438
- currentCachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
2439
- await currentCachedEmail.save();
2440
- return;
1759
+ public async updateEmailServerSettings(
1760
+ settings: TEmailServerSettingsUpdate,
1761
+ updatedBy = 'system',
1762
+ ): Promise<IEmailServerSettings> {
1763
+ return await this.queueEmailLifecycleTask(async () => {
1764
+ if (!this.emailSettingsManager) {
1765
+ throw new Error('EmailSettingsManager is not initialized');
2441
1766
  }
2442
- await cachedEmail.save();
2443
- } catch (error: unknown) {
2444
- cachedEmail.scheduleRetry(ACCEPTED_EMAIL_RETRY_DELAY_MS);
2445
- cachedEmail.lastError = (error as Error).message;
2446
- await cachedEmail.save();
2447
- logger.log('warn', `Accepted email ${cachedEmail.id} deferred after SmartMTA handoff failure: ${(error as Error).message}`);
2448
- }
2449
- }
2450
-
2451
- private startAcceptedEmailSpoolProcessor(): void {
2452
- this.clearAcceptedEmailSpoolTimer();
2453
- this.acceptedEmailSpoolStopping = false;
2454
- const runProcessor = () => {
2455
- this.runAcceptedEmailSpoolProcessor();
2456
- };
2457
- this.acceptedEmailSpoolTimer = setInterval(
2458
- runProcessor,
2459
- ACCEPTED_EMAIL_SPOOL_INTERVAL_MS,
2460
- ) as ReturnType<typeof setInterval> & { unref?: () => void };
2461
- this.acceptedEmailSpoolTimer.unref?.();
2462
- runProcessor();
2463
- }
2464
-
2465
- private clearAcceptedEmailSpoolTimer(): void {
2466
- if (this.acceptedEmailSpoolTimer) {
2467
- clearInterval(this.acceptedEmailSpoolTimer);
2468
- this.acceptedEmailSpoolTimer = undefined;
2469
- }
2470
- }
2471
1767
 
2472
- private async stopAcceptedEmailSpoolProcessor(): Promise<void> {
2473
- this.acceptedEmailSpoolStopping = true;
2474
- this.clearAcceptedEmailSpoolTimer();
2475
- const spoolRun = this.acceptedEmailSpoolRun;
2476
- if (spoolRun) {
2477
- const settled = await this.waitForPromiseToSettleWithTimeout(
2478
- spoolRun,
2479
- ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
2480
- );
2481
- if (!settled) {
2482
- logger.log('warn', 'Timed out waiting for accepted email spool processing to stop');
2483
- }
2484
- }
2485
- }
1768
+ const updatedSettings = await this.emailSettingsManager.updateSettings(settings, updatedBy);
1769
+ this.emailDomainManager?.setBaseEmailDomains(this.options.emailConfig?.domains as IEmailDomainConfig[] | undefined);
1770
+ await this.emailDomainManager?.syncManagedDomainsToRuntime();
1771
+ this.seedEmailRoutes = this.options.emailConfig
1772
+ ? this.emailRouteBuilder.generateEmailRoutes(this.options.emailConfig)
1773
+ : [];
2486
1774
 
2487
- private runAcceptedEmailSpoolProcessor(): void {
2488
- if (this.acceptedEmailSpoolRun) {
2489
- return;
2490
- }
2491
- const run = this.processAcceptedEmailSpool().catch((error) => {
2492
- logger.log('warn', `Accepted email spool processing failed: ${(error as Error).message}`);
2493
- });
2494
- this.acceptedEmailSpoolRun = run;
2495
- void run.finally(() => {
2496
- if (this.acceptedEmailSpoolRun === run) {
2497
- this.acceptedEmailSpoolRun = undefined;
1775
+ if (this.routeConfigManager) {
1776
+ await this.routeConfigManager.initialize(
1777
+ this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
1778
+ this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
1779
+ this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
1780
+ );
2498
1781
  }
2499
- });
2500
- }
2501
1782
 
2502
- private async processAcceptedEmailSpool(): Promise<void> {
2503
- const emailServer = this.emailServer;
2504
- if (this.acceptedEmailSpoolProcessing || !emailServer || !this.dcRouterDb?.isReady()) {
2505
- return;
2506
- }
2507
- this.acceptedEmailSpoolProcessing = true;
2508
- try {
2509
- const cachedEmails = await CachedEmail.findPendingForDelivery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
2510
- for (const cachedEmail of cachedEmails) {
2511
- if (this.acceptedEmailSpoolStopping || this.emailServer !== emailServer) {
2512
- break;
1783
+ if (this.options.emailConfig) {
1784
+ if (this.emailServer) {
1785
+ await this.stopUnifiedEmailComponents();
2513
1786
  }
2514
- const session = this.buildCachedEmailSession(cachedEmail);
2515
- const rawMessage = plugins.buffer.Buffer.from(cachedEmail.rawContent || '', 'utf8');
2516
- await this.processAcceptedCachedEmail(cachedEmail, rawMessage, session, emailServer);
1787
+ await this.setupUnifiedEmailHandling();
1788
+ } else if (this.emailServer) {
1789
+ await this.stopUnifiedEmailComponents();
2517
1790
  }
2518
- } finally {
2519
- this.acceptedEmailSpoolProcessing = false;
2520
- }
2521
- }
2522
1791
 
2523
- private buildCachedEmailSession(cachedEmail: CachedEmail): IExtendedSmtpSession {
2524
- const routeData = this.parseCachedEmailRouteData(cachedEmail);
2525
- const storedSession = routeData.session || {};
2526
- const storedEnvelope = storedSession.envelope || {};
2527
- const storedRcptTo = Array.isArray(storedEnvelope.rcptTo) && storedEnvelope.rcptTo.length > 0
2528
- ? storedEnvelope.rcptTo
2529
- : cachedEmail.to.map((address) => ({ address, args: {} }));
2530
- const rcptTo = storedRcptTo
2531
- .filter((recipient) => !!recipient.address)
2532
- .map((recipient) => ({ address: recipient.address, args: recipient.args || {} }));
2533
- const mailFrom = storedEnvelope.mailFrom
2534
- ? { address: storedEnvelope.mailFrom.address, args: storedEnvelope.mailFrom.args || {} }
2535
- : { address: cachedEmail.from || '', args: {} };
2536
- const session = {
2537
- id: `${storedSession.id || cachedEmail.id}-replay-${Date.now()}`,
2538
- state: 'DATA' as unknown as IExtendedSmtpSession['state'],
2539
- clientHostname: storedSession.clientHostname || '',
2540
- mailFrom: mailFrom.address,
2541
- rcptTo: rcptTo.map((recipient) => recipient.address),
2542
- emailData: cachedEmail.rawContent || '',
2543
- useTLS: !!storedSession.secure,
2544
- connectionEnded: false,
2545
- remoteAddress: storedSession.remoteAddress || '127.0.0.1',
2546
- secure: !!storedSession.secure,
2547
- authenticated: !!storedSession.authenticated,
2548
- envelope: {
2549
- mailFrom,
2550
- rcptTo,
2551
- },
2552
- } as IExtendedSmtpSession;
2553
- if (storedSession.user) {
2554
- session.user = storedSession.user;
2555
- }
2556
- return session;
2557
- }
2558
-
2559
- private parseCachedEmailRouteData(cachedEmail: CachedEmail): TStoredCachedEmailRouteData {
2560
- try {
2561
- return cachedEmail.routeData ? JSON.parse(cachedEmail.routeData) : {};
2562
- } catch {
2563
- return {};
2564
- }
2565
- }
2566
-
2567
- private async updateAcceptedEmailFromQueueItem(
2568
- item: TSmartMtaQueueItemLike,
2569
- status: 'queued' | 'delivered' | 'failed',
2570
- ): Promise<void> {
2571
- const cachedEmailId = this.getCachedEmailIdFromQueueItem(item);
2572
- if (!cachedEmailId || !this.dcRouterDb?.isReady()) {
2573
- return;
2574
- }
2575
- const cachedEmail = await CachedEmail.findById(cachedEmailId);
2576
- if (!cachedEmail) {
2577
- return;
2578
- }
2579
- if (this.isCachedEmailTerminal(cachedEmail) && status !== 'delivered') {
2580
- return;
2581
- }
2582
- cachedEmail.attempts = Math.max(cachedEmail.attempts || 0, item.attempts || 0);
2583
- if (status === 'delivered') {
2584
- cachedEmail.markDelivered();
2585
- } else if (status === 'failed') {
2586
- cachedEmail.markFailed(item.lastError || 'SmartMTA delivery failed');
2587
- } else {
2588
- cachedEmail.status = 'queued';
2589
- cachedEmail.nextAttempt = new Date(Date.now() + ACCEPTED_EMAIL_QUEUE_LEASE_MS);
2590
- }
2591
- await cachedEmail.save();
2592
- }
2593
-
2594
- private trackAcceptedEmailQueueUpdate(
2595
- item: TSmartMtaQueueItemLike,
2596
- status: 'queued' | 'delivered' | 'failed',
2597
- failureMessage: string,
2598
- ): void {
2599
- const updatePromise = this.updateAcceptedEmailFromQueueItem(item, status).catch((error) => {
2600
- logger.log('warn', `${failureMessage}: ${(error as Error).message}`);
2601
- });
2602
- this.acceptedEmailQueueUpdatePromises.add(updatePromise);
2603
- void updatePromise.finally(() => {
2604
- this.acceptedEmailQueueUpdatePromises.delete(updatePromise);
2605
- });
2606
- }
2607
-
2608
- private async drainAcceptedEmailQueueUpdates(): Promise<void> {
2609
- const queueUpdates = [...this.acceptedEmailQueueUpdatePromises];
2610
- if (queueUpdates.length === 0) {
2611
- return;
2612
- }
2613
- const settled = await this.waitForPromiseToSettleWithTimeout(
2614
- Promise.allSettled(queueUpdates).then(() => undefined),
2615
- ACCEPTED_EMAIL_STOP_DRAIN_TIMEOUT_MS,
2616
- );
2617
- if (!settled) {
2618
- for (const queueUpdate of queueUpdates) {
2619
- this.acceptedEmailQueueUpdatePromises.delete(queueUpdate);
2620
- }
2621
- logger.log('warn', `Timed out waiting for ${queueUpdates.length} accepted email queue update(s) to settle`);
2622
- }
2623
- }
2624
-
2625
- private async waitForPromiseToSettleWithTimeout(
2626
- promise: Promise<unknown>,
2627
- timeoutMs: number,
2628
- ): Promise<boolean> {
2629
- let timeout: (ReturnType<typeof setTimeout> & { unref?: () => void }) | undefined;
2630
- return await new Promise<boolean>((resolve) => {
2631
- let settled = false;
2632
- const settle = (didSettle: boolean) => {
2633
- if (settled) {
2634
- return;
2635
- }
2636
- settled = true;
2637
- if (timeout) {
2638
- clearTimeout(timeout);
2639
- }
2640
- resolve(didSettle);
2641
- };
2642
- timeout = setTimeout(() => settle(false), timeoutMs) as ReturnType<typeof setTimeout> & { unref?: () => void };
2643
- timeout.unref?.();
2644
- promise.then(
2645
- () => settle(true),
2646
- () => settle(true),
2647
- );
2648
- });
2649
- }
2650
-
2651
- private async recoverQueuedAcceptedEmails(): Promise<void> {
2652
- while (true) {
2653
- const queuedEmails = await CachedEmail.findQueuedForRecovery(ACCEPTED_EMAIL_SPOOL_BATCH_SIZE);
2654
- if (queuedEmails.length === 0) {
2655
- return;
2656
- }
2657
- for (const queuedEmail of queuedEmails) {
2658
- if (this.isCachedEmailTerminal(queuedEmail)) {
2659
- continue;
2660
- }
2661
- queuedEmail.status = 'pending';
2662
- queuedEmail.nextAttempt = new Date();
2663
- await queuedEmail.save();
2664
- }
2665
- if (queuedEmails.length < ACCEPTED_EMAIL_SPOOL_BATCH_SIZE) {
2666
- return;
2667
- }
2668
- }
2669
- }
2670
-
2671
- private isCachedEmailTerminal(cachedEmail: CachedEmail): boolean {
2672
- return cachedEmail.status === 'delivered' || cachedEmail.status === 'failed';
2673
- }
2674
-
2675
- private getCachedEmailIdFromQueueItem(item: TSmartMtaQueueItemLike): string | undefined {
2676
- return this.getHeaderValue(item.processingResult?.headers, DCROUTER_CACHE_ID_HEADER)
2677
- || this.getHeaderValue(item.processingResult?.email?.headers, DCROUTER_CACHE_ID_HEADER);
2678
- }
2679
-
2680
- private getHeaderValue(headers: Record<string, string> | undefined, headerName: string): string | undefined {
2681
- if (!headers) {
2682
- return undefined;
2683
- }
2684
- const normalizedHeaderName = headerName.toLowerCase();
2685
- const matchingHeaderName = Object.keys(headers).find((key) => key.toLowerCase() === normalizedHeaderName);
2686
- return matchingHeaderName ? headers[matchingHeaderName] : undefined;
2687
- }
2688
-
2689
- private removeHeader(headers: Record<string, string>, headerName: string): void {
2690
- const normalizedHeaderName = headerName.toLowerCase();
2691
- for (const key of Object.keys(headers)) {
2692
- if (key.toLowerCase() === normalizedHeaderName) {
2693
- delete headers[key];
2694
- }
2695
- }
2696
- }
2697
-
2698
- private throwIfMessageAcceptanceAborted(abortSignal: AbortSignal | undefined): void {
2699
- if (abortSignal?.aborted) {
2700
- throw new Error('Message acceptance aborted before SMTP success');
2701
- }
2702
- }
2703
-
2704
- /**
2705
- * Update the unified email configuration
2706
- * @param config New email configuration
2707
- */
2708
- public async updateEmailConfig(config: IUnifiedEmailServerOptions): Promise<void> {
2709
- await this.queueEmailLifecycleTask(async () => {
2710
- // Stop existing email components
2711
- await this.stopUnifiedEmailComponents();
2712
-
2713
- // Update configuration
2714
- this.options.emailConfig = config;
2715
- this.emailDomainManager?.setBaseEmailDomains(config.domains as IEmailDomainConfig[] | undefined);
2716
- await this.emailDomainManager?.syncManagedDomainsToRuntime();
2717
-
2718
- // Start email handling with new configuration
2719
- await this.setupUnifiedEmailHandling();
2720
-
2721
- logger.log('info', 'Unified email configuration updated');
2722
- });
2723
- }
2724
-
2725
- public async updateEmailServerSettings(
2726
- settings: TEmailServerSettingsUpdate,
2727
- updatedBy = 'system',
2728
- ): Promise<IEmailServerSettings> {
2729
- return await this.queueEmailLifecycleTask(async () => {
2730
- if (!this.emailSettingsManager) {
2731
- throw new Error('EmailSettingsManager is not initialized');
2732
- }
2733
-
2734
- const updatedSettings = await this.emailSettingsManager.updateSettings(settings, updatedBy);
2735
- this.emailDomainManager?.setBaseEmailDomains(this.options.emailConfig?.domains as IEmailDomainConfig[] | undefined);
2736
- await this.emailDomainManager?.syncManagedDomainsToRuntime();
2737
- this.seedEmailRoutes = this.options.emailConfig
2738
- ? this.generateEmailRoutes(this.options.emailConfig)
2739
- : [];
2740
-
2741
- if (this.routeConfigManager) {
2742
- await this.routeConfigManager.initialize(
2743
- this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
2744
- this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
2745
- this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
2746
- );
2747
- }
2748
-
2749
- if (this.options.emailConfig) {
2750
- if (this.emailServer) {
2751
- await this.stopUnifiedEmailComponents();
2752
- }
2753
- await this.setupUnifiedEmailHandling();
2754
- } else if (this.emailServer) {
2755
- await this.stopUnifiedEmailComponents();
2756
- }
2757
-
2758
- return updatedSettings;
2759
- });
1792
+ return updatedSettings;
1793
+ });
2760
1794
  }
2761
1795
 
2762
1796
  /**
@@ -2768,13 +1802,12 @@ export class DcRouter {
2768
1802
  if (this.emailServer) {
2769
1803
  const emailServer = this.emailServer;
2770
1804
  this.emailServer = undefined;
2771
- this.acceptedEmailSpoolStopping = true;
2772
- this.clearAcceptedEmailSpoolTimer();
1805
+ this.acceptedEmailSpool.beginStop();
2773
1806
  try {
2774
1807
  await emailServer.stop();
2775
1808
  } finally {
2776
- await this.stopAcceptedEmailSpoolProcessor();
2777
- await this.drainAcceptedEmailQueueUpdates();
1809
+ await this.acceptedEmailSpool.stop();
1810
+ await this.acceptedEmailSpool.drainQueueUpdates();
2778
1811
  this.clearEmailEventSubscriptions();
2779
1812
  }
2780
1813
  logger.log('info', 'Unified email server stopped');
@@ -2823,497 +1856,6 @@ export class DcRouter {
2823
1856
  * Register DNS records with the DNS server
2824
1857
  * @param records Array of DNS records to register
2825
1858
  */
2826
- private registerDnsRecords(records: Array<{name: string; type: string; value: string; ttl?: number}>): void {
2827
- if (!this.dnsServer) return;
2828
-
2829
- // Register a separate handler for each record
2830
- // This ensures multiple records of the same type (like NS records) are all served
2831
- for (const record of records) {
2832
- // Register handler for this specific record
2833
- this.dnsServer.registerHandler(record.name, [record.type], (question) => {
2834
- // Check if this handler matches the question
2835
- if (question.name === record.name && question.type === record.type) {
2836
- return {
2837
- name: record.name,
2838
- type: record.type,
2839
- class: 'IN',
2840
- ttl: record.ttl || 300,
2841
- data: this.parseDnsRecordData(record.type, record.value)
2842
- };
2843
- }
2844
-
2845
- return null;
2846
- });
2847
- }
2848
-
2849
- logger.log('info', `Registered ${records.length} DNS handlers (one per record)`);
2850
- }
2851
-
2852
- /**
2853
- * Parse DNS record data based on record type
2854
- * @param type DNS record type
2855
- * @param value DNS record value
2856
- * @returns Parsed data for the DNS response
2857
- */
2858
- private parseDnsRecordData(type: string, value: string): any {
2859
- switch (type) {
2860
- case 'A':
2861
- return value; // IP address as string
2862
- case 'MX':
2863
- const [priority, exchange] = value.split(' ');
2864
- return { priority: parseInt(priority), exchange };
2865
- case 'TXT':
2866
- return value;
2867
- case 'NS':
2868
- return value;
2869
- case 'SOA':
2870
- // SOA format: primary-ns admin-email serial refresh retry expire minimum
2871
- const parts = value.split(' ');
2872
- return {
2873
- mname: parts[0],
2874
- rname: parts[1],
2875
- serial: parseInt(parts[2]),
2876
- refresh: parseInt(parts[3]),
2877
- retry: parseInt(parts[4]),
2878
- expire: parseInt(parts[5]),
2879
- minimum: parseInt(parts[6])
2880
- };
2881
- default:
2882
- return value;
2883
- }
2884
- }
2885
-
2886
- /**
2887
- * Set up DNS server with socket handler for DoH
2888
- */
2889
- private async setupDnsWithSocketHandler(): Promise<void> {
2890
- if (!this.options.dnsNsDomains || this.options.dnsNsDomains.length === 0) {
2891
- throw new Error('dnsNsDomains is required for DNS server setup');
2892
- }
2893
-
2894
- if (!this.options.dnsScopes || this.options.dnsScopes.length === 0) {
2895
- throw new Error('dnsScopes is required for DNS server setup');
2896
- }
2897
-
2898
- const primaryNameserver = this.options.dnsNsDomains[0];
2899
- logger.log('info', `Setting up DNS server with primary nameserver: ${primaryNameserver}`);
2900
-
2901
- // Get VM IP address for UDP binding
2902
- const networkInterfaces = plugins.os.networkInterfaces() as Record<
2903
- string,
2904
- Array<{ internal: boolean; family: string; address: string }> | undefined
2905
- >;
2906
- let vmIpAddress = this.options.dnsBindInterface || '0.0.0.0'; // Default to all interfaces
2907
-
2908
- // Try to find the VM's internal IP address when no explicit bind address is configured.
2909
- if (!this.options.dnsBindInterface) {
2910
- interfaceLoop: for (const [_name, interfaces] of Object.entries(networkInterfaces)) {
2911
- if (interfaces) {
2912
- for (const iface of interfaces) {
2913
- if (!iface.internal && iface.family === 'IPv4') {
2914
- vmIpAddress = iface.address;
2915
- break interfaceLoop;
2916
- }
2917
- }
2918
- }
2919
- }
2920
- }
2921
-
2922
- // Create DNS server instance with manual HTTPS mode
2923
- this.dnsServer = new plugins.smartdns.dnsServerMod.DnsServer({
2924
- udpPort: 53,
2925
- udpBindInterface: vmIpAddress,
2926
- httpsPort: 443, // Required but won't bind due to manual mode
2927
- manualHttpsMode: true, // Enable manual HTTPS socket handling
2928
- dnssecZone: primaryNameserver,
2929
- primaryNameserver: primaryNameserver, // Automatically generates correct SOA records
2930
- // For now, use self-signed cert until we integrate with Let's Encrypt
2931
- httpsKey: '',
2932
- httpsCert: ''
2933
- });
2934
-
2935
- // Start the DNS server (UDP only)
2936
- await this.dnsServer.start();
2937
- logger.log('info', `DNS server started on UDP ${vmIpAddress}:53`);
2938
-
2939
- // Wire DNS query events to MetricsManager and logger with adaptive rate limiting
2940
- if (this.metricsManager && this.dnsServer) {
2941
- const flushDnsBatch = () => {
2942
- if (this.dnsBatchCount > 0) {
2943
- logger.log('info', `DNS: ${this.dnsBatchCount} queries processed (rate limited)`, { zone: 'dns' });
2944
- this.dnsBatchCount = 0;
2945
- }
2946
- this.dnsBatchTimer = null;
2947
- };
2948
-
2949
- this.dnsServer.on('query', (event: plugins.smartdns.dnsServerMod.IDnsQueryCompletedEvent) => {
2950
- // Metrics tracking
2951
- for (const question of event.questions) {
2952
- this.metricsManager?.trackDnsQuery(
2953
- question.type,
2954
- question.name,
2955
- false,
2956
- event.responseTimeMs,
2957
- event.answered,
2958
- );
2959
- }
2960
-
2961
- // Adaptive logging: individual logs up to 2/sec, then batch
2962
- const nowSec = Math.floor(Date.now() / 1000);
2963
- if (nowSec !== this.dnsLogWindowSecond) {
2964
- this.dnsLogWindowSecond = nowSec;
2965
- this.dnsLogWindowCount = 0;
2966
- }
2967
-
2968
- if (this.dnsLogWindowCount < 2) {
2969
- this.dnsLogWindowCount++;
2970
- const summary = event.questions.map(q => `${q.type} ${q.name}`).join(', ');
2971
- logger.log('info', `DNS query: ${summary} (${event.responseTimeMs}ms, ${event.answered ? 'answered' : 'unanswered'})`, { zone: 'dns' });
2972
- } else {
2973
- this.dnsBatchCount++;
2974
- if (!this.dnsBatchTimer) {
2975
- this.dnsBatchTimer = setTimeout(flushDnsBatch, 5000);
2976
- }
2977
- }
2978
- });
2979
- }
2980
-
2981
- // Validate DNS configuration
2982
- await this.validateDnsConfiguration();
2983
-
2984
- // Generate and register authoritative records
2985
- const authoritativeRecords = await this.generateAuthoritativeRecords();
2986
-
2987
- // Generate email DNS records
2988
- const emailDnsRecords = await this.generateEmailDnsRecords();
2989
-
2990
- // Ensure DKIM keys exist for internal-dns domains before generating records.
2991
- await this.initializeDkimForEmailDomains();
2992
-
2993
- // Generate DKIM records directly from smartmta.
2994
- const dkimRecords = await this.loadDkimRecords();
2995
-
2996
- // Combine all records: authoritative, email, DKIM, and user-defined
2997
- const allRecords = [...authoritativeRecords, ...emailDnsRecords, ...dkimRecords];
2998
- if (this.options.dnsRecords && this.options.dnsRecords.length > 0) {
2999
- allRecords.push(...this.options.dnsRecords);
3000
- }
3001
-
3002
- // Apply proxy IP replacement if configured
3003
- await this.applyProxyIpReplacement(allRecords);
3004
-
3005
- // Register all DNS records
3006
- if (allRecords.length > 0) {
3007
- this.registerDnsRecords(allRecords);
3008
- logger.log('info', `Registered ${allRecords.length} DNS records (${authoritativeRecords.length} authoritative, ${emailDnsRecords.length} email, ${dkimRecords.length} DKIM, ${this.options.dnsRecords?.length || 0} user-defined)`);
3009
- }
3010
-
3011
- // Hand the DnsServer to DnsManager so DB-backed local records on
3012
- // dcrouter-hosted domains get registered too.
3013
- if (this.dnsManager && this.dnsServer) {
3014
- await this.dnsManager.attachDnsServer(this.dnsServer);
3015
- }
3016
- }
3017
-
3018
- /**
3019
- * Create DNS socket handler for DoH
3020
- */
3021
- private createDnsSocketHandler(): (socket: plugins.net.Socket) => Promise<void> {
3022
- return async (socket: plugins.net.Socket) => {
3023
- if (!this.dnsServer) {
3024
- logger.log('error', 'DNS socket handler called but DNS server not initialized');
3025
- socket.end();
3026
- return;
3027
- }
3028
-
3029
- // Prevent uncaught exception from socket 'error' events
3030
- socket.on('error', (err) => {
3031
- logger.log('error', `DNS socket error: ${err.message}`);
3032
- if (!socket.destroyed) {
3033
- socket.destroy();
3034
- }
3035
- });
3036
-
3037
- logger.log('debug', 'DNS socket handler: passing socket to DnsServer');
3038
-
3039
- try {
3040
- // Use the built-in socket handler from smartdns
3041
- // This handles HTTP/2, DoH protocol, etc.
3042
- await (this.dnsServer as any).handleHttpsSocket(socket);
3043
- } catch (error: unknown) {
3044
- logger.log('error', `DNS socket handler error: ${(error as Error).message}`);
3045
- if (!socket.destroyed) {
3046
- socket.destroy();
3047
- }
3048
- }
3049
- };
3050
- }
3051
-
3052
- /**
3053
- * Validate DNS configuration
3054
- */
3055
- private async validateDnsConfiguration(): Promise<void> {
3056
- if (!this.options.dnsNsDomains || !this.options.dnsScopes) {
3057
- return;
3058
- }
3059
-
3060
- logger.log('info', 'Validating DNS configuration...');
3061
-
3062
- // Check if email domains with internal-dns are in dnsScopes
3063
- if (this.options.emailConfig?.domains) {
3064
- for (const domainConfig of this.options.emailConfig.domains) {
3065
- if (domainConfig.dnsMode === 'internal-dns' &&
3066
- !this.options.dnsScopes.includes(domainConfig.domain)) {
3067
- logger.log('warn', `Email domain '${domainConfig.domain}' with internal-dns mode is not in dnsScopes. It should be added to dnsScopes.`);
3068
- }
3069
- }
3070
- }
3071
-
3072
- // Validate user-provided DNS records are within scopes
3073
- if (this.options.dnsRecords) {
3074
- for (const record of this.options.dnsRecords) {
3075
- const recordDomain = this.extractDomain(record.name);
3076
- const isInScope = this.options.dnsScopes.some(scope =>
3077
- recordDomain === scope || recordDomain.endsWith(`.${scope}`)
3078
- );
3079
-
3080
- if (!isInScope) {
3081
- logger.log('warn', `DNS record for '${record.name}' is outside defined scopes [${this.options.dnsScopes.join(', ')}]`);
3082
- }
3083
- }
3084
- }
3085
- }
3086
-
3087
- /**
3088
- * Generate email DNS records for domains with internal-dns mode
3089
- */
3090
- private async generateEmailDnsRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
3091
- const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
3092
-
3093
- if (!this.options.emailConfig?.domains) {
3094
- return records;
3095
- }
3096
-
3097
- // Filter domains with internal-dns mode
3098
- const internalDnsDomains = this.options.emailConfig.domains.filter(
3099
- domain => domain.dnsMode === 'internal-dns'
3100
- );
3101
-
3102
- for (const domainConfig of internalDnsDomains) {
3103
- const domain = domainConfig.domain;
3104
- const ttl = domainConfig.dns?.internal?.ttl || 3600;
3105
- const requiredRecords = buildEmailDnsRecords({
3106
- domain,
3107
- hostname: this.options.emailConfig.hostname,
3108
- mxPriority: domainConfig.dns?.internal?.mxPriority,
3109
- }).filter((record) => !record.name.includes('._domainkey.'));
3110
-
3111
- for (const record of requiredRecords) {
3112
- records.push({
3113
- name: record.name,
3114
- type: record.type,
3115
- value: record.value,
3116
- ttl,
3117
- });
3118
- }
3119
- }
3120
-
3121
- logger.log('info', `Generated ${records.length} email DNS records for ${internalDnsDomains.length} internal-dns domains`);
3122
- return records;
3123
- }
3124
-
3125
- /**
3126
- * Generate DKIM DNS records for internal-dns domains from smartmta's selector-aware DKIM state.
3127
- */
3128
- private async loadDkimRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
3129
- const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
3130
- if (!this.options.emailConfig?.domains || !this.emailServer?.dkimCreator) {
3131
- return records;
3132
- }
3133
-
3134
- for (const domainConfig of this.options.emailConfig.domains) {
3135
- if (domainConfig.dnsMode !== 'internal-dns') {
3136
- continue;
3137
- }
3138
- const selector = domainConfig.dkim?.selector || 'default';
3139
- try {
3140
- const dkimRecord = await this.emailServer.dkimCreator.getDNSRecordForDomain(domainConfig.domain, selector);
3141
- records.push({
3142
- name: dkimRecord.name,
3143
- type: 'TXT',
3144
- value: dkimRecord.value,
3145
- ttl: domainConfig.dns?.internal?.ttl || 3600,
3146
- });
3147
- } catch (error: unknown) {
3148
- logger.log('error', `Failed to generate DKIM record for ${domainConfig.domain}: ${(error as Error).message}`);
3149
- }
3150
- }
3151
-
3152
- return records;
3153
- }
3154
-
3155
- /**
3156
- * Initialize DKIM keys for all configured email domains
3157
- * This ensures DKIM records are available immediately at startup
3158
- */
3159
- private async initializeDkimForEmailDomains(): Promise<void> {
3160
- if (!this.options.emailConfig?.domains || !this.emailServer) {
3161
- return;
3162
- }
3163
-
3164
- logger.log('info', 'Initializing DKIM keys for email domains...');
3165
-
3166
- // Get DKIMCreator instance from email server (public in smartmta)
3167
- const dkimCreator = this.emailServer.dkimCreator;
3168
- if (!dkimCreator) {
3169
- logger.log('warn', 'DKIMCreator not available, skipping DKIM initialization');
3170
- return;
3171
- }
3172
-
3173
- // Ensure necessary directories exist
3174
- paths.ensureDataDirectories(this.resolvedPaths);
3175
-
3176
- // Generate DKIM keys for each internal-dns email domain using the configured selector.
3177
- for (const domainConfig of this.options.emailConfig.domains) {
3178
- if (domainConfig.dnsMode !== 'internal-dns') {
3179
- continue;
3180
- }
3181
- try {
3182
- await dkimCreator.handleDKIMKeysForSelector(
3183
- domainConfig.domain,
3184
- domainConfig.dkim?.selector || 'default',
3185
- domainConfig.dkim?.keySize || 2048,
3186
- );
3187
- logger.log('info', `DKIM keys initialized for ${domainConfig.domain}`);
3188
- } catch (error: unknown) {
3189
- logger.log('error', `Failed to initialize DKIM for ${domainConfig.domain}: ${(error as Error).message}`);
3190
- }
3191
- }
3192
-
3193
- logger.log('info', 'DKIM initialization complete');
3194
- }
3195
-
3196
- /**
3197
- * Generate authoritative DNS records (NS only) for all domains in dnsScopes
3198
- * SOA records are now automatically generated by smartdns with primaryNameserver setting
3199
- */
3200
- private async generateAuthoritativeRecords(): Promise<Array<{name: string; type: string; value: string; ttl?: number}>> {
3201
- const records: Array<{name: string; type: string; value: string; ttl?: number}> = [];
3202
-
3203
- if (!this.options.dnsNsDomains || !this.options.dnsScopes) {
3204
- return records;
3205
- }
3206
-
3207
- // Determine the public IP for nameserver A records
3208
- let publicIp: string | null = null;
3209
-
3210
- // Use proxy IPs if configured (these should be public IPs)
3211
- if (this.options.proxyIps && this.options.proxyIps.length > 0) {
3212
- publicIp = this.options.proxyIps[0]; // Use first proxy IP
3213
- logger.log('info', `Using proxy IP for nameserver A records: ${publicIp}`);
3214
- } else if (this.options.publicIp) {
3215
- // Use explicitly configured public IP
3216
- publicIp = this.options.publicIp;
3217
- this.detectedPublicIp = publicIp;
3218
- logger.log('info', `Using configured public IP for nameserver A records: ${publicIp}`);
3219
- } else {
3220
- // Auto-discover public IP using smartnetwork
3221
- try {
3222
- logger.log('info', 'Auto-discovering public IP address...');
3223
- const smartNetwork = new plugins.smartnetwork.SmartNetwork();
3224
- const publicIps = await smartNetwork.getPublicIps();
3225
-
3226
- if (publicIps.v4) {
3227
- publicIp = publicIps.v4;
3228
- this.detectedPublicIp = publicIp;
3229
- logger.log('info', `Auto-discovered public IPv4: ${publicIp}`);
3230
- } else {
3231
- logger.log('warn', 'Could not auto-discover public IPv4 address');
3232
- }
3233
- } catch (error: unknown) {
3234
- logger.log('error', `Failed to auto-discover public IP: ${(error as Error).message}`);
3235
- }
3236
-
3237
- if (!publicIp) {
3238
- logger.log('warn', 'No public IP available. Nameserver A records require either proxyIps, publicIp, or successful auto-discovery.');
3239
- }
3240
- }
3241
-
3242
- // Generate A records for nameservers if we have a public IP
3243
- if (publicIp) {
3244
- for (const nsDomain of this.options.dnsNsDomains) {
3245
- records.push({
3246
- name: nsDomain,
3247
- type: 'A',
3248
- value: publicIp,
3249
- ttl: 3600
3250
- });
3251
- }
3252
- logger.log('info', `Generated A records for ${this.options.dnsNsDomains.length} nameservers`);
3253
- }
3254
-
3255
- // Generate NS records for each domain in scopes
3256
- for (const domain of this.options.dnsScopes) {
3257
- // Add NS records for all nameservers
3258
- for (const nsDomain of this.options.dnsNsDomains) {
3259
- records.push({
3260
- name: domain,
3261
- type: 'NS',
3262
- value: nsDomain,
3263
- ttl: 3600
3264
- });
3265
- }
3266
-
3267
- // SOA records are now automatically generated by smartdns DnsServer
3268
- // with the primaryNameserver configuration option
3269
- }
3270
-
3271
- logger.log('info', `Generated ${records.length} total records (A + NS) for ${this.options.dnsScopes.length} domains`);
3272
- return records;
3273
- }
3274
-
3275
- /**
3276
- * Extract the base domain from a DNS record name
3277
- */
3278
- private extractDomain(recordName: string): string {
3279
- // Handle wildcards
3280
- if (recordName.startsWith('*.')) {
3281
- recordName = recordName.substring(2);
3282
- }
3283
- return recordName;
3284
- }
3285
-
3286
- /**
3287
- * Apply proxy IP replacement logic to DNS records
3288
- */
3289
- private async applyProxyIpReplacement(records: Array<{name: string; type: string; value: string; ttl?: number; useIngressProxy?: boolean}>): Promise<void> {
3290
- if (!this.options.proxyIps || this.options.proxyIps.length === 0) {
3291
- return; // No proxy IPs configured, skip replacement
3292
- }
3293
-
3294
- // Get server's public IP
3295
- const serverIp = await this.detectServerPublicIp();
3296
- if (!serverIp) {
3297
- logger.log('warn', 'Could not detect server public IP, skipping proxy IP replacement');
3298
- return;
3299
- }
3300
-
3301
- logger.log('info', `Applying proxy IP replacement. Server IP: ${serverIp}, Proxy IPs: ${this.options.proxyIps.join(', ')}`);
3302
-
3303
- let proxyIndex = 0;
3304
- for (const record of records) {
3305
- if (record.type === 'A' &&
3306
- record.value === serverIp &&
3307
- record.useIngressProxy !== false) {
3308
- // Round-robin through proxy IPs
3309
- const proxyIp = this.options.proxyIps[proxyIndex % this.options.proxyIps.length];
3310
- logger.log('info', `Replacing A record for ${record.name}: ${record.value} → ${proxyIp}`);
3311
- record.value = proxyIp;
3312
- proxyIndex++;
3313
- }
3314
- }
3315
- }
3316
-
3317
1859
  private addEmailEventSubscription(
3318
1860
  emitter: {
3319
1861
  on(eventName: string, listener: (...args: any[]) => void): void;
@@ -3333,88 +1875,10 @@ export class DcRouter {
3333
1875
  this.emailEventSubscriptions = [];
3334
1876
  }
3335
1877
 
3336
- /**
3337
- * Detect the server's public IP address
3338
- */
3339
- private async detectServerPublicIp(): Promise<string | null> {
3340
- try {
3341
- const smartNetwork = new plugins.smartnetwork.SmartNetwork();
3342
- const publicIps = await smartNetwork.getPublicIps();
3343
-
3344
- if (publicIps.v4) {
3345
- return publicIps.v4;
3346
- }
3347
-
3348
- return null;
3349
- } catch (error: unknown) {
3350
- logger.log('warn', `Failed to detect public IP: ${(error as Error).message}`);
3351
- return null;
3352
- }
3353
- }
3354
1878
 
3355
1879
  /**
3356
1880
  * Set up Remote Ingress hub for edge tunnel connections
3357
1881
  */
3358
- private async setupRemoteIngress(): Promise<void> {
3359
- const remoteIngressManager = this.remoteIngressManager;
3360
- if (!remoteIngressManager) {
3361
- return;
3362
- }
3363
-
3364
- const hubSettings = remoteIngressManager.getHubSettings();
3365
- if (!hubSettings.enabled) {
3366
- logger.log('info', 'Remote Ingress hub is disabled in DB settings');
3367
- return;
3368
- }
3369
-
3370
- logger.log('info', 'Setting up Remote Ingress hub...');
3371
- this.remoteIngressHubStopping = false;
3372
- const generation = ++this.remoteIngressHubGeneration;
3373
-
3374
- const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
3375
- if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
3376
- return;
3377
- }
3378
- remoteIngressManager.setFirewallConfig(firewallConfig);
3379
-
3380
- // Pass current bootstrap routes so the manager can derive edge ports initially.
3381
- // Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
3382
- // will push the complete merged routes here.
3383
- const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
3384
- remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
3385
-
3386
- // If ConfigManagers finished before us, re-apply routes
3387
- // so the callback delivers the full DB set to our newly-created remoteIngressManager.
3388
- if (this.routeConfigManager) {
3389
- await this.routeConfigManager.applyRoutes();
3390
- }
3391
- if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
3392
- return;
3393
- }
3394
-
3395
- await this.queueRemoteIngressHubTask(async () => {
3396
- await this.startRemoteIngressTunnelHubLocked(generation);
3397
- });
3398
- if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
3399
- return;
3400
- }
3401
-
3402
- const edgeCount = remoteIngressManager.getAllEdges().length;
3403
- logger.log('info', `Remote Ingress hub started on port ${hubSettings.tunnelPort} with ${edgeCount} registered edge(s)`);
3404
- }
3405
-
3406
- private isRemoteIngressHubGenerationCurrent(generation: number, manager: RemoteIngressManager): boolean {
3407
- return !this.remoteIngressHubStopping
3408
- && generation === this.remoteIngressHubGeneration
3409
- && this.remoteIngressManager === manager;
3410
- }
3411
-
3412
- private queueRemoteIngressHubTask<T>(task: () => Promise<T>): Promise<T> {
3413
- const run = this.remoteIngressHubLifecycleChain.then(task);
3414
- this.remoteIngressHubLifecycleChain = run.then(() => undefined, () => undefined);
3415
- return run;
3416
- }
3417
-
3418
1882
  private queueSmartProxyLifecycleTask<T>(task: () => Promise<T>): Promise<T> {
3419
1883
  const run = this.smartProxyLifecycleChain.then(task);
3420
1884
  this.smartProxyLifecycleChain = run.then(() => undefined, () => undefined);
@@ -3427,85 +1891,23 @@ export class DcRouter {
3427
1891
  return run;
3428
1892
  }
3429
1893
 
3430
- private async stopRemoteIngress(): Promise<void> {
3431
- this.remoteIngressHubStopping = true;
3432
- this.remoteIngressHubGeneration++;
3433
- await this.queueRemoteIngressHubTask(async () => {
3434
- const currentTunnelManager = this.tunnelManager;
3435
- if (currentTunnelManager) {
3436
- await currentTunnelManager.stop();
3437
- if (this.tunnelManager === currentTunnelManager) {
3438
- this.tunnelManager = undefined;
3439
- }
3440
- }
3441
- });
3442
- }
3443
-
1894
+ /** Serialized edge mutation on the RemoteIngress hub (delegates to the hub lifecycle). */
3444
1895
  public async mutateRemoteIngressEdges<T>(
3445
1896
  mutation: (manager: RemoteIngressManager) => Promise<T>,
3446
1897
  syncAllowedEdges = true,
3447
1898
  ): Promise<T> {
3448
- return await this.queueRemoteIngressHubTask(async () => {
3449
- if (this.remoteIngressHubStopping) {
3450
- throw new Error('RemoteIngress is stopping');
3451
- }
3452
- const manager = this.remoteIngressManager;
3453
- if (!manager) {
3454
- throw new Error('RemoteIngress not configured');
3455
- }
3456
- const result = await mutation(manager);
3457
- if (syncAllowedEdges && this.tunnelManager) {
3458
- await this.tunnelManager.syncAllowedEdges();
3459
- }
3460
- return result;
3461
- });
3462
- }
3463
-
3464
- private async updateRemoteIngressRoutes(routes: IDcRouterRouteConfig[]): Promise<void> {
3465
- await this.queueRemoteIngressHubTask(async () => {
3466
- if (this.remoteIngressHubStopping) return;
3467
- if (this.remoteIngressManager) {
3468
- this.remoteIngressManager.setRoutes(routes);
3469
- }
3470
- if (this.tunnelManager) {
3471
- await this.tunnelManager.syncAllowedEdges();
3472
- }
3473
- });
1899
+ return await this.remoteIngressHubLifecycle.mutateEdges(mutation, syncAllowedEdges);
3474
1900
  }
3475
1901
 
3476
1902
  public async updateRemoteIngressHubSettings(
3477
1903
  updates: TRemoteIngressHubSettingsUpdate,
3478
1904
  updatedBy: string,
3479
1905
  ): Promise<IRemoteIngressHubSettings> {
3480
- const manager = this.remoteIngressManager;
3481
- if (!manager) {
3482
- throw new Error('RemoteIngress is not configured');
3483
- }
3484
-
3485
- const previousSettings = manager.getHubSettings();
3486
- const settings = await manager.updateHubSettings(updates, updatedBy);
3487
- const enabledChanged = previousSettings.enabled !== settings.enabled;
3488
-
3489
- if (!settings.enabled) {
3490
- await this.queueRemoteIngressHubTask(async () => {
3491
- await this.stopRemoteIngressTunnelHubLocked();
3492
- });
3493
- }
3494
-
3495
- if (enabledChanged) {
3496
- await this.restartSmartProxyForRemoteIngressSettings();
3497
- }
3498
-
3499
- if (settings.enabled) {
3500
- await this.queueRemoteIngressHubTask(async () => {
3501
- await this.restartRemoteIngressTunnelHubLocked();
3502
- });
3503
- }
3504
-
3505
- return settings;
1906
+ return await this.remoteIngressHubLifecycle.updateHubSettings(updates, updatedBy);
3506
1907
  }
3507
1908
 
3508
- private async restartSmartProxyForRemoteIngressSettings(): Promise<void> {
1909
+ /** Restart SmartProxy after RemoteIngress hub settings changed listener wiring. Called by RemoteIngressHubLifecycle. */
1910
+ public async restartSmartProxyForRemoteIngressSettings(): Promise<void> {
3509
1911
  await this.queueSmartProxyLifecycleTask(async () => {
3510
1912
  const restartSmartProxy = async () => {
3511
1913
  try {
@@ -3518,7 +1920,7 @@ export class DcRouter {
3518
1920
  }
3519
1921
  }
3520
1922
  } finally {
3521
- await this.stopSmartAcme();
1923
+ await this.smartAcmeLifecycle.stop();
3522
1924
  }
3523
1925
  await this.setupSmartProxy();
3524
1926
  };
@@ -3540,144 +1942,14 @@ export class DcRouter {
3540
1942
  });
3541
1943
  }
3542
1944
 
3543
- private async stopRemoteIngressTunnelHubLocked(): Promise<void> {
3544
- this.remoteIngressHubGeneration++;
3545
- const currentTunnelManager = this.tunnelManager;
3546
- if (currentTunnelManager) {
3547
- await currentTunnelManager.stop();
3548
- if (this.tunnelManager === currentTunnelManager) {
3549
- this.tunnelManager = undefined;
3550
- }
3551
- }
3552
- }
3553
-
3554
- private async restartRemoteIngressTunnelHubLocked(): Promise<void> {
3555
- const generation = ++this.remoteIngressHubGeneration;
3556
- const hubSettings = this.remoteIngressManager?.getHubSettings();
3557
- if (!this.remoteIngressManager || !hubSettings?.enabled || this.remoteIngressHubStopping) {
3558
- return;
3559
- }
3560
-
3561
- const currentTunnelManager = this.tunnelManager;
3562
- if (currentTunnelManager) {
3563
- await currentTunnelManager.stop();
3564
- if (this.tunnelManager === currentTunnelManager) {
3565
- this.tunnelManager = undefined;
3566
- }
3567
- }
3568
-
3569
- if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
3570
- return;
3571
- }
3572
- await this.startRemoteIngressTunnelHubLocked(generation);
3573
- }
3574
-
3575
- private async startRemoteIngressTunnelHubLocked(generation: number): Promise<void> {
3576
- const manager = this.remoteIngressManager;
3577
- const hubSettings = manager?.getHubSettings();
3578
- if (!manager || !hubSettings?.enabled || this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
3579
- return;
3580
- }
3581
-
3582
- const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
3583
- if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
3584
- return;
3585
- }
3586
- manager.setFirewallConfig(firewallConfig);
3587
-
3588
- const tlsConfig = await this.resolveRemoteIngressTlsConfig(hubSettings.hubDomain);
3589
- if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
3590
- return;
3591
- }
3592
-
3593
- const tunnelManager = new TunnelManager(manager, {
3594
- tunnelPort: hubSettings.tunnelPort,
3595
- targetHost: '127.0.0.1',
3596
- tls: tlsConfig,
3597
- performance: manager.getHubPerformanceConfig(),
3598
- });
3599
- try {
3600
- await tunnelManager.start();
3601
- } catch (err) {
3602
- await tunnelManager.stop().catch(() => {});
3603
- throw err;
3604
- }
3605
-
3606
- if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
3607
- await tunnelManager.stop().catch((err) => {
3608
- logger.log('warn', `Failed to stop stale RemoteIngress tunnel hub: ${(err as Error).message}`);
3609
- });
3610
- return;
3611
- }
3612
- this.tunnelManager = tunnelManager;
3613
- }
3614
-
3615
- private async resolveRemoteIngressTlsConfig(
3616
- hubDomain?: string,
3617
- ): Promise<{ certPem: string; keyPem: string } | undefined> {
3618
- // Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
3619
- let tlsConfig: { certPem: string; keyPem: string } | undefined;
3620
-
3621
- // Priority 1: Explicit cert/key file paths
3622
- const explicitTls = this.options.remoteIngressConfig?.tls;
3623
- if (explicitTls?.certPath && explicitTls?.keyPath) {
3624
- try {
3625
- const certPem = plugins.fs.readFileSync(explicitTls.certPath, 'utf8');
3626
- const keyPem = plugins.fs.readFileSync(explicitTls.keyPath, 'utf8');
3627
- tlsConfig = { certPem, keyPem };
3628
- logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
3629
- } catch (err: unknown) {
3630
- logger.log('warn', `Failed to read RemoteIngress TLS cert/key files: ${(err as Error).message}`);
3631
- }
3632
- }
3633
-
3634
- // Priority 2: Existing cert from SmartProxy cert store for hubDomain
3635
- if (!tlsConfig && hubDomain) {
3636
- try {
3637
- const stored = await ProxyCertDoc.findByDomain(hubDomain);
3638
- if (stored?.publicKey && stored?.privateKey) {
3639
- tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
3640
- logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${hubDomain}`);
3641
- }
3642
- } catch { /* no stored cert, fall through */ }
3643
- }
3644
-
3645
- if (!tlsConfig) {
3646
- logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
3647
- }
3648
-
3649
- return tlsConfig;
1945
+ /** Bootstrap routes the RemoteIngress hub uses to derive edge ports before the DB route set is applied. */
1946
+ public getRemoteIngressBootstrapRoutes(): plugins.smartproxy.IRouteConfig[] {
1947
+ return [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
3650
1948
  }
3651
1949
 
3652
1950
  /**
3653
1951
  * Set up VPN server for VPN-based route access control.
3654
1952
  */
3655
- private createVpnClientAccessResolver(): ((
3656
- route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
3657
- routeId?: string,
3658
- ) => TVpnClientAllowEntry[]) | undefined {
3659
- if (!this.options.vpnConfig?.enabled) {
3660
- return undefined;
3661
- }
3662
-
3663
- return (
3664
- route: import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig,
3665
- routeId?: string,
3666
- ) => {
3667
- if (!this.vpnManager || !this.targetProfileManager) {
3668
- // VPN not ready yet — deny all until re-apply after VPN starts.
3669
- return [];
3670
- }
3671
-
3672
- return this.targetProfileManager.getMatchingVpnClients(
3673
- route,
3674
- routeId,
3675
- this.vpnManager.listClients(),
3676
- this.routeConfigManager?.getRoutes() || new Map(),
3677
- );
3678
- };
3679
- }
3680
-
3681
1953
  private async setupVpnServer(): Promise<void> {
3682
1954
  if (!this.options.vpnConfig?.enabled) {
3683
1955
  return;
@@ -3719,38 +1991,8 @@ export class DcRouter {
3719
1991
  if (!this.targetProfileManager) return [];
3720
1992
  return this.targetProfileManager.getDirectTargetIps(targetProfileIds);
3721
1993
  },
3722
- getClientAllowedIPs: async (targetProfileIds: string[], clientId?: string, _sourceIp?: string) => {
3723
- const subnet = this.options.vpnConfig?.subnet || '10.8.0.0/24';
3724
- const ips = new Set<string>([subnet]);
3725
-
3726
- if (!this.targetProfileManager) return [...ips];
3727
-
3728
- const allRoutes = this.routeConfigManager?.getRoutes() || new Map();
3729
-
3730
- const { domains, targetIps } = this.targetProfileManager.getClientAccessSpec(
3731
- targetProfileIds,
3732
- allRoutes,
3733
- );
3734
-
3735
- // Add target IPs directly
3736
- for (const ip of targetIps) {
3737
- ips.add(`${ip}/32`);
3738
- }
3739
-
3740
- // Resolve DNS A records for matched domains (with caching)
3741
- for (const domain of domains) {
3742
- if (this.isWildcardVpnDomain(domain)) {
3743
- this.logSkippedWildcardAllowedIp(domain);
3744
- continue;
3745
- }
3746
- const resolvedIps = await this.resolveVpnDomainIPs(domain);
3747
- for (const ip of resolvedIps) {
3748
- ips.add(`${ip}/32`);
3749
- }
3750
- }
3751
-
3752
- return [...ips];
3753
- },
1994
+ getClientAllowedIPs: async (targetProfileIds: string[], _clientId?: string, _sourceIp?: string) =>
1995
+ await this.vpnAccessResolver.getClientAllowedIPs(targetProfileIds),
3754
1996
  });
3755
1997
 
3756
1998
  await this.vpnManager.start();
@@ -3760,48 +2002,6 @@ export class DcRouter {
3760
2002
  await this.routeConfigManager?.applyRoutes();
3761
2003
  }
3762
2004
 
3763
- /** Cache for DNS-resolved IPs of VPN-gated domains. TTL: 5 minutes. */
3764
- private vpnDomainIpCache = new Map<string, { ips: string[]; expiresAt: number }>();
3765
- /** Deduplicate wildcard-resolution warnings for WireGuard AllowedIPs generation. */
3766
- private warnedWildcardVpnDomains = new Set<string>();
3767
-
3768
- /**
3769
- * Resolve a domain's A record(s) for VPN AllowedIPs, with a 5-minute cache.
3770
- */
3771
- private async resolveVpnDomainIPs(domain: string): Promise<string[]> {
3772
- const cached = this.vpnDomainIpCache.get(domain);
3773
- if (cached && cached.expiresAt > Date.now()) {
3774
- return cached.ips;
3775
- }
3776
- try {
3777
- const { promises: dnsPromises } = await import('dns');
3778
- const ips = await dnsPromises.resolve4(domain);
3779
- this.vpnDomainIpCache.set(domain, { ips, expiresAt: Date.now() + 5 * 60 * 1000 });
3780
- // Evict oldest entries if cache exceeds 1000 entries
3781
- if (this.vpnDomainIpCache.size > 1000) {
3782
- const firstKey = this.vpnDomainIpCache.keys().next().value;
3783
- if (firstKey) this.vpnDomainIpCache.delete(firstKey);
3784
- }
3785
- return ips;
3786
- } catch (err) {
3787
- logger.log('warn', `VPN: Failed to resolve ${domain} for AllowedIPs: ${(err as Error).message}`);
3788
- return cached?.ips || []; // Return stale cache on failure, or empty
3789
- }
3790
- }
3791
-
3792
- private isWildcardVpnDomain(domain: string): boolean {
3793
- return domain.includes('*');
3794
- }
3795
-
3796
- private logSkippedWildcardAllowedIp(domain: string): void {
3797
- if (this.warnedWildcardVpnDomains.has(domain)) return;
3798
- this.warnedWildcardVpnDomains.add(domain);
3799
- logger.log(
3800
- 'warn',
3801
- `VPN: Skipping wildcard domain '${domain}' for WireGuard AllowedIPs; wildcard patterns must be resolved to concrete hostnames by matching routes.`,
3802
- );
3803
- }
3804
-
3805
2005
  // VPN security injection is now handled dynamically by RouteConfigManager.applyRoutes()
3806
2006
  // via the getVpnAllowList callback — no longer a separate method here.
3807
2007
 
@@ -3850,9 +2050,8 @@ export class DcRouter {
3850
2050
  }
3851
2051
 
3852
2052
  this.options.vpnConfig = config;
3853
- this.vpnDomainIpCache.clear();
3854
- this.warnedWildcardVpnDomains.clear();
3855
- this.routeConfigManager?.setVpnClientAccessResolver(this.createVpnClientAccessResolver());
2053
+ this.vpnAccessResolver.reset();
2054
+ this.routeConfigManager?.setVpnClientAccessResolver(this.vpnAccessResolver.createRouteAllowResolver());
3856
2055
 
3857
2056
  if (this.options.vpnConfig?.enabled) {
3858
2057
  await this.setupVpnServer();