Compare commits

...

7 Commits

Author SHA1 Message Date
jkunz 2ec647cd6c v13.44.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m24s
2026-06-04 10:18:40 +00:00
jkunz 01267cfeb5 fix(db): use smartdata cached document support 2026-06-04 10:13:57 +00:00
jkunz eef053bd66 v13.44.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 18m44s
2026-06-04 03:59:48 +00:00
jkunz ccb4dea91e test(migrations): use example IP in fixtures 2026-06-04 03:54:32 +00:00
jkunz b0b480873f feat(settings): add DB-backed email and RemoteIngress hub settings 2026-06-04 03:46:31 +00:00
jkunz 496dba94b1 v13.43.5
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m46s
2026-06-03 16:10:17 +00:00
jkunz 69dbc29662 fix(deps): bump @serve.zone/catalog to ^2.12.8 2026-06-03 16:06:29 +00:00
41 changed files with 1760 additions and 324 deletions
+29
View File
@@ -5,6 +5,35 @@
## 2026-06-04 - 13.44.1
### Fixes
- use smartdata cached document support (db)
- Migrate cached email and IP reputation documents to SmartdataCachedDocument and shared smartdata TTL values.
- Remove the local cached document base class and TTL export.
- Bump @push.rocks/smartdata to ^7.2.0.
## 2026-06-04 - 13.44.0
### Features
- add DB-backed email and RemoteIngress hub settings (settings)
- Add persisted email server settings with ops API handlers and web UI controls.
- Extend RemoteIngress hub settings to manage enabled state, tunnel port, hub domain, and performance from the database.
- Backfill email and RemoteIngress singleton settings from legacy bootstrap configuration during migrations.
- Serialize SmartProxy, RemoteIngress, and email lifecycle updates to avoid overlapping runtime reconfiguration.
## 2026-06-03 - 13.43.5
### Fixes
- bump @serve.zone/catalog to ^2.12.8 (deps)
- Updated @serve.zone/catalog from ^2.12.7 to ^2.12.8.
## 2026-06-03 - 13.43.4
### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.43.4",
"version": "13.44.1",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.43.4",
"version": "13.44.1",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -48,7 +48,7 @@
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdata": "^7.2.0",
"@push.rocks/smartdb": "^2.10.2",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
@@ -69,7 +69,7 @@
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.7",
"@serve.zone/catalog": "^2.12.8",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
+17 -21
View File
@@ -45,8 +45,8 @@ importers:
specifier: ^9.5.0
version: 9.5.0(socks@2.8.8)
'@push.rocks/smartdata':
specifier: ^7.1.7
version: 7.1.7(socks@2.8.8)
specifier: ^7.2.0
version: 7.2.0(socks@2.8.8)
'@push.rocks/smartdb':
specifier: ^2.10.2
version: 2.10.2(@tiptap/pm@2.27.2)(socks@2.8.8)
@@ -70,7 +70,7 @@ importers:
version: 3.0.3
'@push.rocks/smartmigration':
specifier: 1.4.1
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))
version: 1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.2.0(socks@2.8.8))
'@push.rocks/smartmta':
specifier: ^5.3.3
version: 5.3.3
@@ -108,8 +108,8 @@ importers:
specifier: ^8.0.2
version: 8.0.2
'@serve.zone/catalog':
specifier: ^2.12.7
version: 2.12.7(@tiptap/pm@2.27.2)
specifier: ^2.12.8
version: 2.12.8(@tiptap/pm@2.27.2)
'@serve.zone/interfaces':
specifier: ^6.2.1
version: 6.2.1
@@ -1283,8 +1283,8 @@ packages:
'@push.rocks/smartdata@7.1.5':
resolution: {integrity: sha512-7x7VedEg6RocWndqUPuTbY2Bh85Q/x0LOVHL4o/NVXyh3IGNtiVQ8ple4WR0qYqlHRAojX4eDSBPMiYzIasqAg==}
'@push.rocks/smartdata@7.1.7':
resolution: {integrity: sha512-HDI/Q9dKybfsJ68oCzlE+S63Xpij9qXnMfi28yznKP0Li1ECVZZMDDGIW5IjsXlHjO+Q+RJMcVd72Pjt3QLY5Q==}
'@push.rocks/smartdata@7.2.0':
resolution: {integrity: sha512-pk1o/No8OHT/bwOZu/Ivy3WgQsZoRtEpk/6HzWHi5KflLoWYKB+qjjOqBaDFhAEdgddfJH9qtN23zTtpGImmUA==}
'@push.rocks/smartdb@2.10.2':
resolution: {integrity: sha512-nH8GfKPviQho2n6bQxKCDbjTspUBtoyL/BPVA04lbA34dYM/y0+nTdTWa93Vt4TJYfUqpdh4zNu4y60zZNU40g==}
@@ -1689,8 +1689,8 @@ packages:
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@serve.zone/catalog@2.12.7':
resolution: {integrity: sha512-cYl1N32ORzyrWmH2LYL9dGWdVbtf7Fr0Os7OjTU2Uxn6sGna65vIeSIhxXZ6R9DF6UDP36yxaYvDV3aWNI01CQ==}
'@serve.zone/catalog@2.12.8':
resolution: {integrity: sha512-TBclzYbDH3OJlbLkWpLrBij2MU4eFpBs5MIJU7njBMZZaQ37IVYftG+vn6N4W2E2WfuTxaPVshN7MV3A/oR81g==}
'@serve.zone/interfaces@6.2.1':
resolution: {integrity: sha512-t2wrpBmd8zDdnyeeY/LG2hfjCXdm/uTHB6oovJ/xHgOws1E2VimYJPFiN7zqs1aEJAmFukfgOq79+eZeq3hfWw==}
@@ -5298,7 +5298,7 @@ snapshots:
'@api.global/typedrequest': 3.3.2
'@api.global/typedsocket': 4.1.4(@push.rocks/smartserve@2.0.4)
'@idp.global/interfaces': 1.1.0
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartjson': 6.0.1
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrx': 3.0.10
@@ -6020,7 +6020,7 @@ snapshots:
'@apiclient.xyz/cloudflare': 7.1.0
'@peculiar/x509': 2.0.0
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartlog': 3.2.2
@@ -6173,12 +6173,11 @@ snapshots:
- supports-color
- vue
'@push.rocks/smartdata@7.1.7(socks@2.8.8)':
'@push.rocks/smartdata@7.2.0(socks@2.8.8)':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 5.1.1(socks@2.8.8)
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrx': 3.0.10
'@push.rocks/smartstring': 4.1.1
@@ -6191,13 +6190,10 @@ snapshots:
- '@aws-sdk/credential-providers'
- '@mongodb-js/zstd'
- '@nuxt/kit'
- bare-abort-controller
- bare-buffer
- gcp-metadata
- kerberos
- mongodb-client-encryption
- react
- react-native-b4a
- snappy
- socks
- supports-color
@@ -6418,13 +6414,13 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.1.7(socks@2.8.8))':
'@push.rocks/smartmigration@1.4.1(@push.rocks/smartbucket@4.6.1)(@push.rocks/smartdata@7.2.0(socks@2.8.8))':
dependencies:
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartversion': 3.1.0
optionalDependencies:
'@push.rocks/smartbucket': 4.6.1
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartmime@2.0.4':
dependencies:
@@ -6435,7 +6431,7 @@ snapshots:
'@push.rocks/smartmongo@5.1.1(socks@2.8.8)':
dependencies:
'@push.rocks/mongodump': 1.1.1(socks@2.8.8)
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
@@ -6462,7 +6458,7 @@ snapshots:
'@push.rocks/smartmongo@7.0.0(socks@2.8.8)':
dependencies:
'@push.rocks/mongodump': 1.1.1(socks@2.8.8)
'@push.rocks/smartdata': 7.1.7(socks@2.8.8)
'@push.rocks/smartdata': 7.2.0(socks@2.8.8)
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
mongodb-memory-server: 11.1.0(socks@2.8.8)
@@ -6919,7 +6915,7 @@ snapshots:
domhandler: 5.0.3
selderee: 0.11.0
'@serve.zone/catalog@2.12.7(@tiptap/pm@2.27.2)':
'@serve.zone/catalog@2.12.8(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.83.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.6
+73
View File
@@ -110,6 +110,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
expect(customPortRoute).toBeTruthy();
expect(customPortRoute?.name).toEqual('custom-smtp-route');
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
expect(customPortRoute?.remoteIngress).toBeUndefined();
// Check standard port mappings
const smtpRoute = routes.find((r: any) => {
@@ -126,6 +127,30 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
}
});
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
const emailConfig: IUnifiedEmailServerOptions = {
ports: [25, 587, 465],
hostname: 'mail.example.com',
domains: [],
routes: [],
};
const router = new DcRouter({
emailConfig,
remoteIngressConfig: {
enabled: true,
tunnelPort: 8443,
hubDomain: 'ingress.example.com',
},
});
const routes = (router as any)['generateEmailRoutes'](emailConfig);
expect(routes.length).toEqual(3);
for (const route of routes) {
expect(route.remoteIngress).toEqual({ enabled: true });
}
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
const opsServerPort = await getFreePort();
// Create a basic email configuration
@@ -164,6 +189,54 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
await router.stop();
});
tap.test('DcRouter class - Email config updates are serialized', async () => {
const router = new DcRouter({
tls: {
contactEmail: 'test@example.com',
},
});
const delay = async () => await new Promise<void>((resolve) => setTimeout(resolve, 10));
let activeLifecycleSteps = 0;
let overlapped = false;
const enterLifecycleStep = async () => {
activeLifecycleSteps++;
if (activeLifecycleSteps > 1) {
overlapped = true;
}
await delay();
activeLifecycleSteps--;
};
(router as any).stopUnifiedEmailComponents = async () => {
await enterLifecycleStep();
};
(router as any).setupUnifiedEmailHandling = async () => {
await enterLifecycleStep();
};
const firstConfig: IUnifiedEmailServerOptions = {
ports: [2525],
hostname: 'first.mail.example.com',
domains: [],
routes: [],
};
const secondConfig: IUnifiedEmailServerOptions = {
ports: [2526],
hostname: 'second.mail.example.com',
domains: [],
routes: [],
};
await Promise.all([
router.updateEmailConfig(firstConfig),
router.updateEmailConfig(secondConfig),
]);
expect(overlapped).toEqual(false);
expect(router.options.emailConfig?.hostname).toEqual('second.mail.example.com');
});
// Final clean-up test
tap.test('clean up after tests', async () => {
// No-op
+44
View File
@@ -183,6 +183,50 @@ tap.test('EmailDomainManager start merges persisted managed domains after restar
expect(managedDomain?.dnsMode).toEqual('internal-dns');
});
tap.test('EmailDomainManager can resync managed domains after email settings replace runtime config', async () => {
await testDbPromise;
await clearTestState();
const linkedDomain = await createDomainDoc('resync-domain', 'resync.example.com', 'provider');
const stored = new EmailDomainDoc();
stored.id = 'resync-email-domain';
stored.domain = 'mail.resync.example.com';
stored.linkedDomainId = linkedDomain.id;
stored.subdomain = 'mail';
stored.dkim = {
selector: 'default',
keySize: 2048,
rotateKeys: false,
rotationIntervalDays: 90,
};
stored.dnsStatus = {
mx: 'unchecked',
spf: 'unchecked',
dkim: 'unchecked',
dmarc: 'unchecked',
};
stored.createdAt = new Date().toISOString();
stored.updatedAt = new Date().toISOString();
await stored.save();
const dcRouterStub = {
options: {
emailConfig: createBaseEmailConfig(),
},
};
const manager = new EmailDomainManager(dcRouterStub);
await manager.start();
expect(dcRouterStub.options.emailConfig.domains.some((domain) => domain.domain === 'mail.resync.example.com')).toEqual(true);
dcRouterStub.options.emailConfig = createBaseEmailConfig();
manager.setBaseEmailDomains(dcRouterStub.options.emailConfig.domains);
await manager.syncManagedDomainsToRuntime();
const resyncedDomains = dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain).sort();
expect(resyncedDomains).toEqual(['mail.resync.example.com', 'static.example.com']);
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearTestState();
+135
View File
@@ -0,0 +1,135 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import { DcRouterDb, EmailServerSettingsDoc } from '../ts/db/index.js';
import { EmailSettingsManager } from '../ts/email/index.js';
import type { IDcRouterOptions } from '../ts/classes.dcrouter.js';
const createTestDb = async () => {
const storagePath = plugins.path.join(
plugins.os.tmpdir(),
`dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
DcRouterDb.resetInstance();
const db = DcRouterDb.getInstance({
storagePath,
dbName: `dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
});
await db.start();
await db.getDb().mongoDb.createCollection('__test_init');
return {
async cleanup() {
await db.stop();
DcRouterDb.resetInstance();
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
},
};
};
const testDbPromise = createTestDb();
const clearSettings = async () => {
for (const doc of await EmailServerSettingsDoc.findAll()) {
await doc.delete();
}
};
tap.test('EmailSettingsManager does not backfill from legacy constructor options', async () => {
await testDbPromise;
await clearSettings();
const options: IDcRouterOptions = {
emailConfig: {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
maxMessageSize: 1024,
},
emailPortConfig: {
portMapping: { 25: 10025, 587: 10587 },
},
};
const manager = new EmailSettingsManager(options);
await manager.start();
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(manager.getPublicSettings().hostname).toEqual(null);
expect(options.emailConfig).toBeUndefined();
expect(options.emailPortConfig).toBeUndefined();
await clearSettings();
const migratedDoc = new EmailServerSettingsDoc();
migratedDoc.settingsId = 'email-server-settings';
migratedDoc.enabled = true;
migratedDoc.emailConfig = {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
maxMessageSize: 1024,
};
migratedDoc.emailPortConfig = {
portMapping: { 25: 10025, 587: 10587 },
};
migratedDoc.updatedAt = Date.now();
migratedDoc.updatedBy = 'migration';
await migratedDoc.save();
const secondOptions: IDcRouterOptions = {
emailConfig: {
hostname: 'ignored.example.com',
ports: [2525],
domains: [],
routes: [],
},
};
const secondManager = new EmailSettingsManager(secondOptions);
await secondManager.start();
expect(secondManager.getPublicSettings().hostname).toEqual('mail.example.com');
expect(secondOptions.emailConfig?.hostname).toEqual('mail.example.com');
});
tap.test('EmailSettingsManager updates redacted mutable server settings', async () => {
await testDbPromise;
await clearSettings();
const options: IDcRouterOptions = {};
const manager = new EmailSettingsManager(options);
await manager.start();
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(options.emailConfig).toBeUndefined();
const settings = await manager.updateSettings(
{
enabled: true,
hostname: 'smtp.example.com',
ports: [587, 25, 587],
portMapping: { 25: 10025, 587: 10587 },
maxMessageSize: 2048,
},
'tester',
);
expect(settings.enabled).toEqual(true);
expect(settings.ports).toEqual([25, 587]);
expect(settings.portMapping?.[587]).toEqual(10587);
expect(options.emailConfig?.hostname).toEqual('smtp.example.com');
expect(options.emailConfig?.maxMessageSize).toEqual(2048);
await manager.updateSettings({ enabled: false }, 'tester');
expect(manager.getPublicSettings().enabled).toEqual(false);
expect(options.emailConfig).toBeUndefined();
});
tap.test('cleanup', async () => {
const testDb = await testDbPromise;
await clearSettings();
await testDb.cleanup();
await tap.stopForcefully();
});
export default tap.start();
+96
View File
@@ -319,4 +319,100 @@ tap.test('migration runner converts legacy route access metadata to source bindi
expect(routes[1].route.security.basicAuth.username).toEqual('user');
});
tap.test('migration runner backfills RemoteIngress hub settings from legacy config seed', async () => {
const hubSettingsDocs: Array<Record<string, any>> = [
{
_id: 'remote-ingress-settings-1',
settingsId: 'remote-ingress-hub-settings',
performance: undefined,
updatedAt: 1,
updatedBy: '',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.5', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
'13.43.6',
{
remoteIngressHubSettings: {
enabled: true,
tunnelPort: 29443,
hubDomain: '203.0.113.10',
performance: {
profile: 'balanced',
maxStreamsPerEdge: 10000,
},
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(hubSettingsDocs[0].enabled).toEqual(true);
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
expect(hubSettingsDocs[0].hubDomain).toEqual('203.0.113.10');
expect(hubSettingsDocs[0].performance.profile).toEqual('balanced');
expect(hubSettingsDocs[0].performance.maxStreamsPerEdge).toEqual(10000);
expect(hubSettingsDocs[0].updatedAt).not.toEqual(1);
});
tap.test('migration runner backfills RemoteIngress hub settings at current package target', async () => {
const hubSettingsDocs: Array<Record<string, any>> = [
{
_id: 'remote-ingress-settings-current',
settingsId: 'remote-ingress-hub-settings',
updatedAt: 1,
updatedBy: '',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.2', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
'13.43.5',
{
remoteIngressHubSettings: {
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(hubSettingsDocs[0].enabled).toEqual(true);
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
expect(hubSettingsDocs[0].hubDomain).toEqual('ingress.example.com');
});
tap.test('migration runner backfills Email server settings from legacy config seed', async () => {
const emailSettingsDocs: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.43.2', { EmailServerSettingsDoc: emailSettingsDocs }),
'13.43.5',
{
emailServerSettings: {
enabled: true,
emailConfig: {
hostname: 'mail.example.com',
ports: [25, 587],
domains: [],
routes: [],
},
emailPortConfig: {
portMapping: { 25: 10025, 587: 10587 },
},
},
},
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(emailSettingsDocs).toHaveLength(1);
expect(emailSettingsDocs[0].enabled).toEqual(true);
expect(emailSettingsDocs[0].emailConfig.hostname).toEqual('mail.example.com');
expect(emailSettingsDocs[0].emailPortConfig.portMapping[25]).toEqual(10025);
});
export default tap.start();
+71
View File
@@ -0,0 +1,71 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { RemoteIngressManager } from '../ts/remoteingress/index.js';
import { RemoteIngressHubSettingsDoc } from '../ts/db/index.js';
tap.test('RemoteIngressManager preserves omitted hub settings on partial update', async () => {
const originalLoad = RemoteIngressHubSettingsDoc.load;
const fakeDoc: any = {
settingsId: 'remote-ingress-hub-settings',
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
performance: {
totalWindowBudgetBytes: 134217728,
},
updatedAt: 1,
updatedBy: 'seed',
save: async () => undefined,
};
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
try {
const manager = new RemoteIngressManager();
const settings = await manager.updateHubSettings({
performance: {
maxStreamsPerEdge: 10000,
},
}, 'test-user');
expect(settings.enabled).toEqual(true);
expect(settings.tunnelPort).toEqual(29443);
expect(settings.hubDomain).toEqual('ingress.example.com');
expect(settings.performance?.maxStreamsPerEdge).toEqual(10000);
} finally {
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
}
});
tap.test('RemoteIngressManager clears optional hub settings explicitly', async () => {
const originalLoad = RemoteIngressHubSettingsDoc.load;
const fakeDoc: any = {
settingsId: 'remote-ingress-hub-settings',
enabled: true,
tunnelPort: 29443,
hubDomain: 'ingress.example.com',
performance: {
maxStreamsPerEdge: 10000,
},
updatedAt: 1,
updatedBy: 'seed',
save: async () => undefined,
};
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
try {
const manager = new RemoteIngressManager();
const settings = await manager.updateHubSettings({
hubDomain: null,
performance: null,
}, 'test-user');
expect(settings.enabled).toEqual(true);
expect(settings.tunnelPort).toEqual(29443);
expect(settings.hubDomain).toBeUndefined();
expect(settings.performance).toBeUndefined();
} finally {
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
}
});
export default tap.start();
+3 -1
View File
@@ -179,10 +179,12 @@ tap.test('WorkHosterHandler exposes capabilities and managed domains with workho
scopes: ['workhosters:read'],
dcRouterRef: {
options: {
remoteIngressConfig: { enabled: true },
dnsScopes: ['example.com'],
http3: { enabled: false },
},
remoteIngressManager: {
getHubSettings: () => ({ enabled: true }),
},
routeConfigManager: {
getMergedRoutes: () => ({ routes: [] }),
},
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.43.4',
version: '13.44.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+361 -100
View File
@@ -31,9 +31,10 @@ import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyMana
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
import { DnsManager } from './dns/manager.dns.js';
import { AcmeConfigManager } from './acme/manager.acme-config.js';
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
import { EmailDomainManager, EmailSettingsManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
import type { IRoute } from '../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig } from '../ts_interfaces/data/remoteingress.js';
import type { IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
export interface IDcRouterOptions {
@@ -57,14 +58,7 @@ export interface IDcRouterOptions {
* Allows configuring specific ports for email handling
* This overrides the default port mapping in the emailConfig
*/
emailPortConfig?: {
/** External to internal port mapping */
portMapping?: Record<number, number>;
/** Custom port configuration for specific ports */
portSettings?: Record<number, any>;
/** Path to store received emails */
receivedEmailsPath?: string;
};
emailPortConfig?: IEmailPortConfig;
/** TLS/certificate configuration */
tls?: {
@@ -282,6 +276,8 @@ export class DcRouter {
public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager;
private remoteIngressHubLifecycleChain: Promise<void> = Promise.resolve();
private smartProxyLifecycleChain: Promise<void> = Promise.resolve();
private emailLifecycleChain: Promise<void> = Promise.resolve();
private remoteIngressHubStopping = false;
private remoteIngressHubGeneration = 0;
@@ -300,6 +296,7 @@ export class DcRouter {
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
public acmeConfigManager?: AcmeConfigManager;
public emailSettingsManager?: EmailSettingsManager;
public emailDomainManager?: EmailDomainManager;
public workAppMailManager: WorkAppMailManager;
public securityPolicyManager?: SecurityPolicyManager;
@@ -341,7 +338,7 @@ export class DcRouter {
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private seedEmailRoutes: IDcRouterRouteConfig[] = [];
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
@@ -482,7 +479,7 @@ export class DcRouter {
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailDomainManager')
.optional()
.dependsOn('DcRouterDb')
.dependsOn('DcRouterDb', 'EmailSettingsManager')
.withStart(async () => {
this.emailDomainManager = new EmailDomainManager(this);
await this.emailDomainManager.start();
@@ -496,6 +493,28 @@ export class DcRouter {
);
}
// EmailSettingsManager: optional, depends on DcRouterDb — owns the DB-backed
// singleton email server config and projects it into runtime options before
// SmartProxy and EmailDomainManager read email settings.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('EmailSettingsManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.emailSettingsManager = new EmailSettingsManager(this.options);
await this.emailSettingsManager.start();
})
.withStop(async () => {
if (this.emailSettingsManager) {
await this.emailSettingsManager.stop();
this.emailSettingsManager = undefined;
}
})
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
);
}
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
// and compiles the global block policy for SmartProxy and remote ingress edges.
if (this.options.dbConfig?.enabled !== false) {
@@ -519,13 +538,34 @@ export class DcRouter {
);
}
// RemoteIngressManager: optional, depends on DcRouterDb — owns DB-backed
// hub settings and edge registrations. It starts before SmartProxy so
// SmartProxy can use the DB-backed enabled flag for PROXY protocol setup.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('RemoteIngressManager')
.optional()
.dependsOn('DcRouterDb')
.withStart(async () => {
this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize();
})
.withStop(async () => {
this.remoteIngressManager = undefined;
})
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
);
}
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
const smartProxyDeps: string[] = [];
if (this.options.dbConfig?.enabled !== false) {
smartProxyDeps.push('DcRouterDb');
smartProxyDeps.push('DnsManager');
smartProxyDeps.push('AcmeConfigManager');
smartProxyDeps.push('EmailSettingsManager');
smartProxyDeps.push('SecurityPolicyManager');
smartProxyDeps.push('RemoteIngressManager');
}
this.serviceManager.addService(
new plugins.taskbuffer.Service('SmartProxy')
@@ -535,11 +575,20 @@ export class DcRouter {
await this.setupSmartProxy();
})
.withStop(async () => {
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
await this.smartProxy.stop();
this.smartProxy = undefined;
}
await this.queueSmartProxyLifecycleTask(async () => {
try {
if (this.smartProxy) {
const existingSmartProxy = this.smartProxy;
existingSmartProxy.removeAllListeners();
await existingSmartProxy.stop();
if (this.smartProxy === existingSmartProxy) {
this.smartProxy = undefined;
}
}
} finally {
await this.stopSmartAcme();
}
});
})
.withRetry({ maxRetries: 0 }),
);
@@ -630,7 +679,7 @@ export class DcRouter {
}
// Email Server: optional, depends on SmartProxy
if (this.options.emailConfig) {
if (this.options.dbConfig?.enabled !== false || this.options.emailConfig) {
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
if (this.options.dbConfig?.enabled !== false) {
emailServiceDeps.push('EmailDomainManager');
@@ -640,14 +689,18 @@ export class DcRouter {
.optional()
.dependsOn(...emailServiceDeps)
.withStart(async () => {
await this.setupUnifiedEmailHandling();
await this.queueEmailLifecycleTask(async () => {
if (!this.options.emailConfig) {
logger.log('info', 'EmailServer: no email settings configured, skipping startup');
return;
}
await this.setupUnifiedEmailHandling();
});
})
.withStop(async () => {
if (this.emailServer) {
this.clearEmailEventSubscriptions();
await this.emailServer.stop();
this.emailServer = undefined;
}
await this.queueEmailLifecycleTask(async () => {
await this.stopUnifiedEmailComponents();
});
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
@@ -658,7 +711,7 @@ export class DcRouter {
this.serviceManager.addService(
new plugins.taskbuffer.Service('DnsServer')
.optional()
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
.dependsOn('SmartProxy', ...((this.options.dbConfig?.enabled !== false || this.options.emailConfig) ? ['EmailServer'] : []))
.withStart(async () => {
await this.setupDnsWithSocketHandler();
})
@@ -702,12 +755,14 @@ export class DcRouter {
);
}
// Remote Ingress: optional, depends on SmartProxy
if (this.options.remoteIngressConfig?.enabled) {
// Remote Ingress: optional, depends on SmartProxy and DB-backed settings.
// The service starts as a no-op when the DB setting is disabled, so the UI
// can still manage edge registrations and hub settings.
if (this.options.dbConfig?.enabled !== false) {
this.serviceManager.addService(
new plugins.taskbuffer.Service('RemoteIngress')
.optional()
.dependsOn('SmartProxy')
.dependsOn('SmartProxy', 'RemoteIngressManager')
.withStart(async () => {
await this.setupRemoteIngress();
})
@@ -752,6 +807,42 @@ export class DcRouter {
});
}
private isRemoteIngressHubEnabled(): boolean {
return this.remoteIngressManager?.getHubSettings().enabled
?? this.options.remoteIngressConfig?.enabled
?? false;
}
private getRemoteIngressHubSettingsLegacySeed(): TRemoteIngressHubSettingsUpdate {
const remoteIngressConfig = this.options.remoteIngressConfig;
const seed: TRemoteIngressHubSettingsUpdate = {};
if (remoteIngressConfig?.enabled !== undefined) {
seed.enabled = remoteIngressConfig.enabled;
}
if (remoteIngressConfig?.tunnelPort !== undefined) {
seed.tunnelPort = remoteIngressConfig.tunnelPort;
}
if (remoteIngressConfig?.hubDomain !== undefined) {
seed.hubDomain = remoteIngressConfig.hubDomain;
}
if (remoteIngressConfig?.performance !== undefined) {
seed.performance = remoteIngressConfig.performance;
}
return seed;
}
private getEmailSettingsLegacySeed(): IEmailServerSettingsSeed {
const seed: IEmailServerSettingsSeed = {};
if (this.options.emailConfig) {
seed.enabled = true;
seed.emailConfig = JSON.parse(JSON.stringify(this.options.emailConfig));
}
if (this.options.emailPortConfig) {
seed.emailPortConfig = JSON.parse(JSON.stringify(this.options.emailPortConfig));
}
return seed;
}
private startSmartAcmeInBackground(): void {
if (!this.smartAcme) {
this.smartAcmeReady = false;
@@ -965,10 +1056,11 @@ export class DcRouter {
}
// Remote Ingress summary
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
const remoteIngressHubSettings = this.remoteIngressManager?.getHubSettings();
if (this.tunnelManager && remoteIngressHubSettings?.enabled) {
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
const connectedCount = this.tunnelManager.getConnectedCount();
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
logger.log('info', `Remote Ingress: tunnel port=${remoteIngressHubSettings.tunnelPort}, edges=${edgeCount} registered/${connectedCount} connected`);
}
// Database summary
@@ -1013,7 +1105,10 @@ export class DcRouter {
// Run any pending data migrations before anything else reads from the DB.
// This must complete before ConfigManagers loads profiles.
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version);
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, {
remoteIngressHubSettings: this.getRemoteIngressHubSettingsLegacySeed(),
emailServerSettings: this.getEmailSettingsLegacySeed(),
});
const migrationResult = await migration.run();
if (migrationResult.stepsApplied.length > 0) {
logger.log('info',
@@ -1043,8 +1138,16 @@ export class DcRouter {
// Clean up any existing SmartProxy instance (e.g. from a retry)
if (this.smartProxy) {
this.smartProxy.removeAllListeners();
this.smartProxy = undefined;
const existingSmartProxy = this.smartProxy;
try {
existingSmartProxy.removeAllListeners();
await existingSmartProxy.stop();
if (this.smartProxy === existingSmartProxy) {
this.smartProxy = undefined;
}
} finally {
await this.stopSmartAcme();
}
}
// Assemble serializable seed routes from constructor config — these will be seeded into DB
@@ -1279,7 +1382,7 @@ export class DcRouter {
// When remoteIngress is enabled, the hub binary forwards tunneled connections
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
if (this.options.remoteIngressConfig?.enabled) {
if (this.isRemoteIngressHubEnabled()) {
smartProxyConfig.acceptProxyProtocol = true;
if (!smartProxyConfig.proxyIPs) {
smartProxyConfig.proxyIPs = [];
@@ -1303,16 +1406,17 @@ export class DcRouter {
// Create SmartProxy instance
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
const smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
this.smartProxy = smartProxy;
// Set up event listeners
this.smartProxy.on('error', (err) => {
smartProxy.on('error', (err) => {
logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack });
});
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
// Events are keyed by domain for domain-centric certificate tracking
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
const routeNames = this.findRouteNamesForDomain(event.domain);
this.certificateStatusMap.set(event.domain, {
@@ -1326,7 +1430,7 @@ export class DcRouter {
// Renewals come through 'certificate-issued' (with optional isRenewal? in the payload).
// The vestigial 'certificate-renewed' event from common-types.ts is never emitted.
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
const routeNames = this.findRouteNamesForDomain(event.domain);
this.certificateStatusMap.set(event.domain, {
@@ -1337,7 +1441,23 @@ export class DcRouter {
// Start SmartProxy
logger.log('info', 'Starting SmartProxy...');
await this.smartProxy.start();
try {
await smartProxy.start();
} catch (err) {
smartProxy.removeAllListeners();
if (this.smartProxy === smartProxy) {
this.smartProxy = undefined;
}
await this.stopSmartAcme();
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
this.certProvisionScheduler = undefined;
}
await smartProxy.stop().catch((stopErr) => {
logger.log('warn', `Failed to clean up SmartProxy after startup failure: ${(stopErr as Error).message}`);
});
throw err;
}
logger.log('info', 'SmartProxy started successfully');
// Populate certificateStatusMap for certs loaded from store at startup
@@ -1460,8 +1580,8 @@ export class DcRouter {
/**
* Generate SmartProxy routes for email configuration
*/
private generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): plugins.smartproxy.IRouteConfig[] {
const emailRoutes: plugins.smartproxy.IRouteConfig[] = [];
private generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): IDcRouterRouteConfig[] {
const emailRoutes: IDcRouterRouteConfig[] = [];
// Create routes for each email port
for (const port of emailConfig.ports) {
@@ -1535,13 +1655,17 @@ export class DcRouter {
}
// Create the route configuration
const routeConfig: plugins.smartproxy.IRouteConfig = {
const routeConfig: IDcRouterRouteConfig = {
name: routeName,
match: {
ports: [port]
},
action: action
};
if (this.isRemoteIngressHubEnabled()) {
routeConfig.remoteIngress = { enabled: true };
}
// Add the route to our list
emailRoutes.push(routeConfig);
@@ -1768,19 +1892,33 @@ export class DcRouter {
});
// Create unified email server
this.emailServer = new UnifiedEmailServer(this, emailConfig);
const emailServer = new UnifiedEmailServer(this, emailConfig);
this.emailServer = emailServer;
this.clearEmailEventSubscriptions();
// Set up error handling
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
this.addEmailEventSubscription(emailServer, 'error', (err: Error) => {
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
});
// Start the server
await this.emailServer.start();
try {
await emailServer.start();
} catch (error: unknown) {
this.clearEmailEventSubscriptions();
try {
await emailServer.stop();
} catch (stopError: unknown) {
logger.log('warn', `Error cleaning up failed UnifiedEmailServer start: ${(stopError as Error).message}`);
}
if (this.emailServer === emailServer) {
this.emailServer = undefined;
}
throw error;
}
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
if (this.metricsManager && this.emailServer) {
if (this.metricsManager) {
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
const emailLike = item?.processingResult;
const from = emailLike?.from || emailLike?.email?.from || '';
@@ -1795,34 +1933,34 @@ export class DcRouter {
};
};
const updateQueueSize = () => {
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
this.metricsManager!.updateQueueSize(emailServer.getQueueStats().queueSize);
};
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailReceived(envelope.from);
updateQueueSize();
logger.log('info', `Email queued: ${envelope.from}${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
updateQueueSize();
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: any) => {
const envelope = getEnvelope(item);
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
updateQueueSize();
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', () => {
updateQueueSize();
});
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemRemoved', () => {
updateQueueSize();
});
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
this.addEmailEventSubscription(emailServer, 'bounceProcessed', () => {
this.metricsManager!.trackEmailBounced();
logger.log('warn', 'Email bounce processed', { zone: 'email' });
});
@@ -1837,16 +1975,57 @@ export class DcRouter {
* @param config New email configuration
*/
public async updateEmailConfig(config: IUnifiedEmailServerOptions): Promise<void> {
// Stop existing email components
await this.stopUnifiedEmailComponents();
// Update configuration
this.options.emailConfig = config;
// Start email handling with new configuration
await this.setupUnifiedEmailHandling();
logger.log('info', 'Unified email configuration updated');
await this.queueEmailLifecycleTask(async () => {
// Stop existing email components
await this.stopUnifiedEmailComponents();
// Update configuration
this.options.emailConfig = config;
this.emailDomainManager?.setBaseEmailDomains(config.domains as IEmailDomainConfig[] | undefined);
await this.emailDomainManager?.syncManagedDomainsToRuntime();
// Start email handling with new configuration
await this.setupUnifiedEmailHandling();
logger.log('info', 'Unified email configuration updated');
});
}
public async updateEmailServerSettings(
settings: TEmailServerSettingsUpdate,
updatedBy = 'system',
): Promise<IEmailServerSettings> {
return await this.queueEmailLifecycleTask(async () => {
if (!this.emailSettingsManager) {
throw new Error('EmailSettingsManager is not initialized');
}
const updatedSettings = await this.emailSettingsManager.updateSettings(settings, updatedBy);
this.emailDomainManager?.setBaseEmailDomains(this.options.emailConfig?.domains as IEmailDomainConfig[] | undefined);
await this.emailDomainManager?.syncManagedDomainsToRuntime();
this.seedEmailRoutes = this.options.emailConfig
? this.generateEmailRoutes(this.options.emailConfig)
: [];
if (this.routeConfigManager) {
await this.routeConfigManager.initialize(
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
);
}
if (this.options.emailConfig) {
if (this.emailServer) {
await this.stopUnifiedEmailComponents();
}
await this.setupUnifiedEmailHandling();
} else if (this.emailServer) {
await this.stopUnifiedEmailComponents();
}
return updatedSettings;
});
}
/**
@@ -2438,7 +2617,14 @@ export class DcRouter {
* Set up Remote Ingress hub for edge tunnel connections
*/
private async setupRemoteIngress(): Promise<void> {
if (!this.options.remoteIngressConfig?.enabled) {
const remoteIngressManager = this.remoteIngressManager;
if (!remoteIngressManager) {
return;
}
const hubSettings = remoteIngressManager.getHubSettings();
if (!hubSettings.enabled) {
logger.log('info', 'Remote Ingress hub is disabled in DB settings');
return;
}
@@ -2446,14 +2632,6 @@ export class DcRouter {
this.remoteIngressHubStopping = false;
const generation = ++this.remoteIngressHubGeneration;
// Initialize the edge registration manager
const remoteIngressManager = new RemoteIngressManager(this.options.remoteIngressConfig.performance);
this.remoteIngressManager = remoteIngressManager;
await remoteIngressManager.initialize();
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
return;
}
const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
return;
@@ -2483,7 +2661,7 @@ export class DcRouter {
}
const edgeCount = remoteIngressManager.getAllEdges().length;
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
logger.log('info', `Remote Ingress hub started on port ${hubSettings.tunnelPort} with ${edgeCount} registered edge(s)`);
}
private isRemoteIngressHubGenerationCurrent(generation: number, manager: RemoteIngressManager): boolean {
@@ -2498,17 +2676,30 @@ export class DcRouter {
return run;
}
private queueSmartProxyLifecycleTask<T>(task: () => Promise<T>): Promise<T> {
const run = this.smartProxyLifecycleChain.then(task);
this.smartProxyLifecycleChain = run.then(() => undefined, () => undefined);
return run;
}
private queueEmailLifecycleTask<T>(task: () => Promise<T>): Promise<T> {
const run = this.emailLifecycleChain.then(task);
this.emailLifecycleChain = run.then(() => undefined, () => undefined);
return run;
}
private async stopRemoteIngress(): Promise<void> {
this.remoteIngressHubStopping = true;
this.remoteIngressHubGeneration++;
await this.queueRemoteIngressHubTask(async () => {
const currentTunnelManager = this.tunnelManager;
this.tunnelManager = undefined;
if (currentTunnelManager) {
await currentTunnelManager.stop();
if (this.tunnelManager === currentTunnelManager) {
this.tunnelManager = undefined;
}
}
});
this.remoteIngressManager = undefined;
}
public async mutateRemoteIngressEdges<T>(
@@ -2544,35 +2735,96 @@ export class DcRouter {
}
public async updateRemoteIngressHubSettings(
updates: { performance?: IRemoteIngressPerformanceConfig },
updates: TRemoteIngressHubSettingsUpdate,
updatedBy: string,
): Promise<IRemoteIngressHubSettings> {
return await this.queueRemoteIngressHubTask(async () => {
if (this.remoteIngressHubStopping) {
throw new Error('RemoteIngress is stopping');
}
if (!this.remoteIngressManager) {
throw new Error('RemoteIngress is not configured');
const manager = this.remoteIngressManager;
if (!manager) {
throw new Error('RemoteIngress is not configured');
}
const previousSettings = manager.getHubSettings();
const settings = await manager.updateHubSettings(updates, updatedBy);
const enabledChanged = previousSettings.enabled !== settings.enabled;
if (!settings.enabled) {
await this.queueRemoteIngressHubTask(async () => {
await this.stopRemoteIngressTunnelHubLocked();
});
}
if (enabledChanged) {
await this.restartSmartProxyForRemoteIngressSettings();
}
if (settings.enabled) {
await this.queueRemoteIngressHubTask(async () => {
await this.restartRemoteIngressTunnelHubLocked();
});
}
return settings;
}
private async restartSmartProxyForRemoteIngressSettings(): Promise<void> {
await this.queueSmartProxyLifecycleTask(async () => {
const restartSmartProxy = async () => {
try {
if (this.smartProxy) {
const existingSmartProxy = this.smartProxy;
existingSmartProxy.removeAllListeners();
await existingSmartProxy.stop();
if (this.smartProxy === existingSmartProxy) {
this.smartProxy = undefined;
}
}
} finally {
await this.stopSmartAcme();
}
await this.setupSmartProxy();
};
if (this.routeConfigManager) {
await this.routeConfigManager.runExclusiveRouteUpdate(restartSmartProxy);
} else {
await restartSmartProxy();
}
const settings = await this.remoteIngressManager.updateHubSettings(updates, updatedBy);
if (this.options.remoteIngressConfig?.enabled) {
await this.restartRemoteIngressTunnelHubLocked();
if (!this.routeConfigManager) {
return;
}
return settings;
await this.routeConfigManager.initialize(
this.seedConfigRoutes as IDcRouterRouteConfig[],
this.seedEmailRoutes as IDcRouterRouteConfig[],
this.seedDnsRoutes as IDcRouterRouteConfig[],
);
});
}
private async stopRemoteIngressTunnelHubLocked(): Promise<void> {
this.remoteIngressHubGeneration++;
const currentTunnelManager = this.tunnelManager;
if (currentTunnelManager) {
await currentTunnelManager.stop();
if (this.tunnelManager === currentTunnelManager) {
this.tunnelManager = undefined;
}
}
}
private async restartRemoteIngressTunnelHubLocked(): Promise<void> {
const generation = ++this.remoteIngressHubGeneration;
if (!this.remoteIngressManager || !this.options.remoteIngressConfig?.enabled || this.remoteIngressHubStopping) {
const hubSettings = this.remoteIngressManager?.getHubSettings();
if (!this.remoteIngressManager || !hubSettings?.enabled || this.remoteIngressHubStopping) {
return;
}
const currentTunnelManager = this.tunnelManager;
this.tunnelManager = undefined;
if (currentTunnelManager) {
await currentTunnelManager.stop();
if (this.tunnelManager === currentTunnelManager) {
this.tunnelManager = undefined;
}
}
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
@@ -2582,19 +2834,25 @@ export class DcRouter {
}
private async startRemoteIngressTunnelHubLocked(generation: number): Promise<void> {
const riCfg = this.options.remoteIngressConfig;
const manager = this.remoteIngressManager;
if (!riCfg?.enabled || !manager || this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
const hubSettings = manager?.getHubSettings();
if (!manager || !hubSettings?.enabled || this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
return;
}
const tlsConfig = await this.resolveRemoteIngressTlsConfig(riCfg);
const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
return;
}
manager.setFirewallConfig(firewallConfig);
const tlsConfig = await this.resolveRemoteIngressTlsConfig(hubSettings.hubDomain);
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
return;
}
const tunnelManager = new TunnelManager(manager, {
tunnelPort: riCfg.tunnelPort ?? 8443,
tunnelPort: hubSettings.tunnelPort,
targetHost: '127.0.0.1',
tls: tlsConfig,
performance: manager.getHubPerformanceConfig(),
@@ -2607,23 +2865,26 @@ export class DcRouter {
}
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
await tunnelManager.stop();
await tunnelManager.stop().catch((err) => {
logger.log('warn', `Failed to stop stale RemoteIngress tunnel hub: ${(err as Error).message}`);
});
return;
}
this.tunnelManager = tunnelManager;
}
private async resolveRemoteIngressTlsConfig(
riCfg: NonNullable<IDcRouterOptions['remoteIngressConfig']>,
hubDomain?: string,
): Promise<{ certPem: string; keyPem: string } | undefined> {
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
let tlsConfig: { certPem: string; keyPem: string } | undefined;
// Priority 1: Explicit cert/key file paths
if (riCfg.tls?.certPath && riCfg.tls?.keyPath) {
const explicitTls = this.options.remoteIngressConfig?.tls;
if (explicitTls?.certPath && explicitTls?.keyPath) {
try {
const certPem = plugins.fs.readFileSync(riCfg.tls.certPath, 'utf8');
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
const certPem = plugins.fs.readFileSync(explicitTls.certPath, 'utf8');
const keyPem = plugins.fs.readFileSync(explicitTls.keyPath, 'utf8');
tlsConfig = { certPem, keyPem };
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
} catch (err: unknown) {
@@ -2632,12 +2893,12 @@ export class DcRouter {
}
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
if (!tlsConfig && riCfg.hubDomain) {
if (!tlsConfig && hubDomain) {
try {
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
const stored = await ProxyCertDoc.findByDomain(hubDomain);
if (stored?.publicKey && stored?.privateKey) {
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${hubDomain}`);
}
} catch { /* no stored cert, fall through */ }
}
@@ -85,6 +85,10 @@ export class RouteConfigManager {
this.getVpnClientAccessForRoute = resolver;
}
public async runExclusiveRouteUpdate<T>(fn: () => Promise<T>): Promise<T> {
return await this.routeUpdateMutex.runExclusive(fn);
}
/**
* Load persisted routes, seed serializable config/email/dns routes,
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
-111
View File
@@ -1,111 +0,0 @@
import * as plugins from '../plugins.js';
/**
* Base class for all cached documents with TTL support
*
* Extends smartdata's SmartDataDbDoc to add:
* - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt)
* - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/
public createdAt: Date = new Date();
/**
* Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/
public expiresAt!: Date;
/**
* Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/
public lastAccessedAt: Date = new Date();
/**
* Set the TTL (time to live) for this document
* @param ttlMs Time to live in milliseconds
*/
public setTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set TTL using days
* @param days Number of days until expiration
*/
public setTTLDays(days: number): void {
this.setTTL(days * 24 * 60 * 60 * 1000);
}
/**
* Set TTL using hours
* @param hours Number of hours until expiration
*/
public setTTLHours(hours: number): void {
this.setTTL(hours * 60 * 60 * 1000);
}
/**
* Check if this document has expired
*/
public isExpired(): boolean {
if (!this.expiresAt) {
return false; // No expiration set
}
return new Date() > this.expiresAt;
}
/**
* Update the lastAccessedAt timestamp
*/
public touch(): void {
this.lastAccessedAt = new Date();
}
/**
* Get remaining TTL in milliseconds
* Returns 0 if expired, -1 if no expiration set
*/
public getRemainingTTL(): number {
if (!this.expiresAt) {
return -1;
}
const remaining = this.expiresAt.getTime() - Date.now();
return remaining > 0 ? remaining : 0;
}
/**
* Extend the TTL by the specified milliseconds from now
* @param ttlMs Additional time to live in milliseconds
*/
public extendTTL(ttlMs: number): void {
this.expiresAt = new Date(Date.now() + ttlMs);
}
/**
* Set the document to never expire (100 years in the future)
*/
public setNeverExpires(): void {
this.expiresAt = new Date(Date.now() + 100 * 365 * 24 * 60 * 60 * 1000);
}
}
/**
* TTL constants in milliseconds
*/
export const TTL = {
HOURS_1: 1 * 60 * 60 * 1000,
HOURS_24: 24 * 60 * 60 * 1000,
DAYS_7: 7 * 24 * 60 * 60 * 1000,
DAYS_30: 30 * 24 * 60 * 60 * 1000,
DAYS_90: 90 * 24 * 60 * 60 * 1000,
} as const;
+3 -12
View File
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Email status in the cache
*/
@@ -19,17 +20,7 @@ const getDb = () => DcRouterDb.getInstance().getDb();
* and maintaining email history for the configured TTL period.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedEmail extends plugins.smartdata.SmartdataCachedDocument<CachedEmail> {
/**
* Unique identifier for this email
*/
@@ -1,7 +1,8 @@
import * as plugins from '../../plugins.js';
import { CachedDocument, TTL } from '../classes.cached.document.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
const TTL = plugins.smartdata.smartdataTtlValues;
/**
* Helper to get the smartdata database instance
*/
@@ -29,17 +30,7 @@ export interface IIPReputationData {
* external API calls. Default TTL is 24 hours.
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
export class CachedIPReputation extends plugins.smartdata.SmartdataCachedDocument<CachedIPReputation> {
/**
* IP address (unique identifier)
*/
@@ -0,0 +1,40 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IEmailPortConfig } from '../../../ts_interfaces/data/email-settings.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<EmailServerSettingsDoc, EmailServerSettingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public settingsId: string = 'email-server-settings';
@plugins.smartdata.svDb()
public enabled: boolean = false;
@plugins.smartdata.svDb()
public emailConfig?: IUnifiedEmailServerOptions;
@plugins.smartdata.svDb()
public emailPortConfig?: IEmailPortConfig;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<EmailServerSettingsDoc | null> {
return await EmailServerSettingsDoc.getInstance({ settingsId: 'email-server-settings' });
}
public static async findAll(): Promise<EmailServerSettingsDoc[]> {
return await EmailServerSettingsDoc.getInstances({});
}
}
@@ -10,6 +10,15 @@ export class RemoteIngressHubSettingsDoc extends plugins.smartdata.SmartDataDbDo
@plugins.smartdata.svDb()
public settingsId: string = 'remote-ingress-hub-settings';
@plugins.smartdata.svDb()
public enabled?: boolean;
@plugins.smartdata.svDb()
public tunnelPort?: number;
@plugins.smartdata.svDb()
public hubDomain?: string;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
+1
View File
@@ -40,3 +40,4 @@ export * from './classes.acme-config.doc.js';
// Email domain management
export * from './classes.email-domain.doc.js';
export * from './classes.email-server-settings.doc.js';
-3
View File
@@ -1,9 +1,6 @@
// Unified database manager
export * from './classes.dcrouter-db.js';
// TTL base class and constants
export * from './classes.cached.document.js';
// Cache cleaner
export * from './classes.cache.cleaner.js';
+6 -2
View File
@@ -17,11 +17,15 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
*/
export class EmailDomainManager {
private dcRouter: any; // DcRouter — avoids circular import
private readonly baseEmailDomains: IEmailDomainConfig[];
private baseEmailDomains: IEmailDomainConfig[] = [];
constructor(dcRouterRef: any) {
this.dcRouter = dcRouterRef;
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
this.setBaseEmailDomains(this.dcRouter.options?.emailConfig?.domains as IEmailDomainConfig[] | undefined);
}
public setBaseEmailDomains(domains: IEmailDomainConfig[] | undefined): void {
this.baseEmailDomains = (domains || [])
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
}
+221
View File
@@ -0,0 +1,221 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
import { EmailServerSettingsDoc } from '../db/index.js';
import type { IDcRouterOptions } from '../classes.dcrouter.js';
import type {
IEmailPortConfig,
IEmailServerSettings,
TEmailServerSettingsUpdate,
} from '../../ts_interfaces/data/email-settings.js';
const defaultEmailPorts = [25, 587, 465];
function clonePlain<T>(value: T | undefined): T | undefined {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value)) as T;
}
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
export class EmailSettingsManager {
private cachedEmailConfig?: IUnifiedEmailServerOptions;
private cachedEmailPortConfig?: IEmailPortConfig;
private enabled = false;
private updatedAt = 0;
private updatedBy = 'default';
constructor(private options: IDcRouterOptions) {}
public async start(): Promise<void> {
let doc = await EmailServerSettingsDoc.load();
if (!doc) {
doc = new EmailServerSettingsDoc();
doc.settingsId = 'email-server-settings';
doc.enabled = false;
doc.updatedAt = Date.now();
doc.updatedBy = 'default';
await doc.save();
}
this.loadFromDoc(doc);
this.applyToRuntimeOptions();
}
public async stop(): Promise<void> {
this.cachedEmailConfig = undefined;
this.cachedEmailPortConfig = undefined;
this.enabled = false;
}
public isEnabled(): boolean {
return this.enabled && Boolean(this.cachedEmailConfig);
}
public getEmailConfig(): IUnifiedEmailServerOptions | undefined {
return this.isEnabled() ? clonePlain(this.cachedEmailConfig) : undefined;
}
public getEmailPortConfig(): IEmailPortConfig | undefined {
return this.isEnabled() ? clonePlain(this.cachedEmailPortConfig) : undefined;
}
public getPublicSettings(): IEmailServerSettings {
const emailConfig = this.cachedEmailConfig;
const emailPortConfig = this.cachedEmailPortConfig;
return {
enabled: this.isEnabled(),
hostname: emailConfig?.hostname || null,
ports: [...(emailConfig?.ports || [])],
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
maxMessageSize: emailConfig?.maxMessageSize ?? null,
domainCount: emailConfig?.domains?.length || 0,
routeCount: emailConfig?.routes?.length || 0,
authUserCount: emailConfig?.auth?.users?.length || 0,
updatedAt: this.updatedAt,
updatedBy: this.updatedBy,
};
}
public async updateSettings(
updates: TEmailServerSettingsUpdate,
updatedBy: string,
): Promise<IEmailServerSettings> {
let doc = await EmailServerSettingsDoc.load();
if (!doc) {
doc = new EmailServerSettingsDoc();
doc.settingsId = 'email-server-settings';
}
const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled);
const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates);
doc.enabled = nextEnabled;
doc.emailConfig = nextEmailConfig;
doc.emailPortConfig = nextEmailPortConfig;
doc.updatedAt = Date.now();
doc.updatedBy = updatedBy;
await doc.save();
this.loadFromDoc(doc);
this.applyToRuntimeOptions();
return this.getPublicSettings();
}
private loadFromDoc(doc: EmailServerSettingsDoc): void {
this.enabled = doc.enabled;
this.cachedEmailConfig = clonePlain(doc.emailConfig);
this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig);
this.updatedAt = doc.updatedAt;
this.updatedBy = doc.updatedBy;
}
private applyToRuntimeOptions(): void {
this.options.emailConfig = this.getEmailConfig();
this.options.emailPortConfig = this.getEmailPortConfig();
}
private patchEmailConfig(
existingConfig: IUnifiedEmailServerOptions | undefined,
updates: TEmailServerSettingsUpdate,
nextEnabled: boolean,
): IUnifiedEmailServerOptions | undefined {
const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
hostname: 'localhost',
ports: [...defaultEmailPorts],
domains: [],
routes: [],
} : undefined);
if (!nextConfig) return undefined;
if (hasOwn(updates, 'hostname')) {
const hostname = updates.hostname?.trim() || '';
if (nextEnabled && !hostname) {
throw new Error('Email hostname is required when email is enabled');
}
nextConfig.hostname = hostname || nextConfig.hostname;
}
if (hasOwn(updates, 'ports')) {
nextConfig.ports = this.normalizePorts(updates.ports || []);
}
if (hasOwn(updates, 'maxMessageSize')) {
if (updates.maxMessageSize === null || updates.maxMessageSize === undefined) {
delete nextConfig.maxMessageSize;
} else {
const maxMessageSize = Number(updates.maxMessageSize);
if (!Number.isInteger(maxMessageSize) || maxMessageSize <= 0) {
throw new Error('maxMessageSize must be a positive integer');
}
nextConfig.maxMessageSize = maxMessageSize;
}
}
if (nextEnabled) {
if (!nextConfig.hostname?.trim()) {
throw new Error('Email hostname is required when email is enabled');
}
nextConfig.ports = this.normalizePorts(nextConfig.ports || []);
}
nextConfig.domains = nextConfig.domains || [];
nextConfig.routes = nextConfig.routes || [];
return nextConfig;
}
private patchEmailPortConfig(
existingPortConfig: IEmailPortConfig | undefined,
updates: TEmailServerSettingsUpdate,
): IEmailPortConfig | undefined {
const nextPortConfig: IEmailPortConfig = clonePlain(existingPortConfig) || {};
if (hasOwn(updates, 'portMapping')) {
if (updates.portMapping === null) {
delete nextPortConfig.portMapping;
} else {
nextPortConfig.portMapping = this.normalizePortMapping(updates.portMapping || {});
}
}
if (hasOwn(updates, 'receivedEmailsPath')) {
const receivedEmailsPath = updates.receivedEmailsPath?.trim() || '';
if (receivedEmailsPath) {
nextPortConfig.receivedEmailsPath = receivedEmailsPath;
} else {
delete nextPortConfig.receivedEmailsPath;
}
}
return Object.keys(nextPortConfig).length > 0 ? nextPortConfig : undefined;
}
private normalizePorts(ports: number[]): number[] {
const normalized = [...new Set(ports.map((port) => Number(port)))];
if (normalized.length === 0) {
throw new Error('At least one email port is required when email is enabled');
}
for (const port of normalized) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid email port: ${port}`);
}
}
return normalized.sort((a, b) => a - b);
}
private normalizePortMapping(portMapping: Record<number, number>): Record<number, number> {
const normalized: Record<number, number> = {};
for (const [externalPortString, internalPortValue] of Object.entries(portMapping)) {
const externalPort = Number(externalPortString);
const internalPort = Number(internalPortValue);
for (const port of [externalPort, internalPort]) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`Invalid email port mapping value: ${port}`);
}
}
normalized[externalPort] = internalPort;
}
return normalized;
}
}
+1
View File
@@ -1,4 +1,5 @@
export * from './classes.email-domain.manager.js';
export * from './classes.email-settings.manager.js';
export * from './classes.smartmta-storage-manager.js';
export * from './classes.workapp-mail-manager.js';
export * from './email-dns-records.js';
+2
View File
@@ -23,6 +23,7 @@ export class OpsServer {
private statsHandler!: handlers.StatsHandler;
private radiusHandler!: handlers.RadiusHandler;
private emailOpsHandler!: handlers.EmailOpsHandler;
private emailSettingsHandler!: handlers.EmailSettingsHandler;
private certificateHandler!: handlers.CertificateHandler;
private remoteIngressHandler!: handlers.RemoteIngressHandler;
private routeManagementHandler!: handlers.RouteManagementHandler;
@@ -82,6 +83,7 @@ export class OpsServer {
this.statsHandler = new handlers.StatsHandler(this);
this.radiusHandler = new handlers.RadiusHandler(this);
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
this.emailSettingsHandler = new handlers.EmailSettingsHandler(this);
this.certificateHandler = new handlers.CertificateHandler(this);
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
+16 -13
View File
@@ -100,21 +100,23 @@ export class ConfigHandler {
}
let portMapping: Record<string, number> | null = null;
if (opts.emailPortConfig?.portMapping) {
const emailSettings = dcRouter.emailSettingsManager?.getPublicSettings();
const rawPortMapping = emailSettings?.portMapping || opts.emailPortConfig?.portMapping;
if (rawPortMapping) {
portMapping = {};
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
for (const [ext, int] of Object.entries(rawPortMapping)) {
portMapping[String(ext)] = int as number;
}
}
const email: interfaces.requests.IConfigData['email'] = {
enabled: !!dcRouter.emailServer,
ports: opts.emailConfig?.ports || [],
enabled: emailSettings?.enabled ?? !!dcRouter.emailServer,
ports: emailSettings?.ports || opts.emailConfig?.ports || [],
portMapping,
hostname: opts.emailConfig?.hostname || null,
hostname: emailSettings?.hostname || opts.emailConfig?.hostname || null,
domains: emailDomains,
emailRouteCount: opts.emailConfig?.routes?.length || 0,
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
emailRouteCount: emailSettings?.routeCount ?? opts.emailConfig?.routes?.length ?? 0,
receivedEmailsPath: emailSettings?.receivedEmailsPath || opts.emailPortConfig?.receivedEmailsPath || null,
};
// --- DNS ---
@@ -186,16 +188,17 @@ export class ConfigHandler {
// --- Remote Ingress ---
const riCfg = opts.remoteIngressConfig;
const riSettings = dcRouter.remoteIngressManager?.getHubSettings();
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
tlsMode = 'custom';
} else if (riCfg?.hubDomain) {
} else if (riSettings?.hubDomain) {
try {
const { ProxyCertDoc } = await import('../../db/index.js');
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
const stored = await ProxyCertDoc.findByDomain(riSettings.hubDomain);
if (stored?.publicKey && stored?.privateKey) {
tlsMode = 'acme';
}
@@ -203,12 +206,12 @@ export class ConfigHandler {
}
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
enabled: !!dcRouter.remoteIngressManager,
tunnelPort: riCfg?.tunnelPort || null,
hubDomain: riCfg?.hubDomain || null,
enabled: !!riSettings?.enabled,
tunnelPort: riSettings?.tunnelPort || null,
hubDomain: riSettings?.hubDomain || null,
tlsMode,
connectedEdgeIps,
performance: dcRouter.remoteIngressManager?.getHubPerformanceConfig() || riCfg?.performance,
performance: riSettings?.performance,
};
return {
@@ -0,0 +1,72 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class EmailSettingsHandler {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private opsServerRef: OpsServer) {
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
private registerHandlers(): void {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailServerSettings>(
'getEmailServerSettings',
async (dataArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'email-domains:read' as any });
return { settings: this.getSettings() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailServerSettings>(
'updateEmailServerSettings',
async (dataArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'email-domains:write' as any,
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
if (!manager) {
return { success: false, message: 'EmailSettingsManager not initialized' };
}
try {
const settings = await this.opsServerRef.dcRouterRef.updateEmailServerSettings(
dataArg.settings,
auth.userId,
);
return { success: true, settings };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
}
private getSettings(): interfaces.data.IEmailServerSettings {
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
if (manager) {
return manager.getPublicSettings();
}
const emailConfig = this.opsServerRef.dcRouterRef.options.emailConfig;
const emailPortConfig = this.opsServerRef.dcRouterRef.options.emailPortConfig;
return {
enabled: Boolean(emailConfig),
hostname: emailConfig?.hostname || null,
ports: [...(emailConfig?.ports || [])],
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
maxMessageSize: emailConfig?.maxMessageSize ?? null,
domainCount: emailConfig?.domains?.length || 0,
routeCount: emailConfig?.routes?.length || 0,
authUserCount: emailConfig?.auth?.users?.length || 0,
updatedAt: 0,
updatedBy: 'legacy-options',
};
}
}
+1
View File
@@ -5,6 +5,7 @@ export * from './security.handler.js';
export * from './stats.handler.js';
export * from './radius.handler.js';
export * from './email-ops.handler.js';
export * from './email-settings.handler.js';
export * from './certificate.handler.js';
export * from './remoteingress.handler.js';
export * from './route-management.handler.js';
+25 -5
View File
@@ -3,6 +3,10 @@ import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
export class RemoteIngressHandler {
constructor(private opsServerRef: OpsServer) {
this.registerHandlers();
@@ -197,6 +201,8 @@ export class RemoteIngressHandler {
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
return {
settings: manager?.getHubSettings() || {
enabled: false,
tunnelPort: 8443,
updatedAt: 0,
updatedBy: 'default',
},
@@ -216,8 +222,22 @@ export class RemoteIngressHandler {
});
try {
const updates: interfaces.data.TRemoteIngressHubSettingsUpdate = {};
if (hasOwn(dataArg, 'enabled') && dataArg.enabled !== undefined) {
updates.enabled = dataArg.enabled;
}
if (hasOwn(dataArg, 'tunnelPort') && dataArg.tunnelPort !== undefined) {
updates.tunnelPort = dataArg.tunnelPort;
}
if (hasOwn(dataArg, 'hubDomain')) {
updates.hubDomain = dataArg.hubDomain ?? null;
}
if (hasOwn(dataArg, 'performance')) {
updates.performance = dataArg.performance ?? null;
}
const settings = await this.opsServerRef.dcRouterRef.updateRemoteIngressHubSettings(
{ performance: dataArg.performance },
updates,
auth.userId,
);
return { success: true, settings };
@@ -250,16 +270,16 @@ export class RemoteIngressHandler {
return { success: false, message: 'Edge is disabled' };
}
const hubHost = dataArg.hubHost
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
const hubSettings = manager.getHubSettings();
const hubHost = dataArg.hubHost || hubSettings.hubDomain;
if (!hubHost) {
return {
success: false,
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
message: 'No hub hostname configured. Set the RemoteIngress hub domain or provide hubHost.',
};
}
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
const hubPort = hubSettings.tunnelPort;
const token = plugins.remoteingress.encodeConnectionToken({
hubHost,
+1 -1
View File
@@ -282,7 +282,7 @@ export class WorkHosterHandler {
outbound: Boolean(dcRouter.emailServer),
},
remoteIngress: {
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
enabled: Boolean(dcRouter.remoteIngressManager?.getHubSettings().enabled),
},
dns: {
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js';
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
interface IRemoteIngressFirewallConfig {
@@ -30,6 +30,11 @@ const performanceIntegerMaxByField: Record<TPerformanceIntegerField, number> = {
};
const maxServerFirstPorts = 128;
const defaultTunnelPort = 8443;
function hasOwn(objectArg: object, keyArg: string): boolean {
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
}
function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
@@ -46,12 +51,13 @@ export class RemoteIngressManager {
private routes: IDcRouterRouteConfig[] = [];
private firewallConfig?: IRemoteIngressFirewallConfig;
private hubSettings: IRemoteIngressHubSettings = {
enabled: false,
tunnelPort: defaultTunnelPort,
updatedAt: 0,
updatedBy: 'default',
};
constructor(private seedHubPerformance?: IRemoteIngressPerformanceConfig) {
}
constructor() {}
/**
* Load all edge registrations from the database into memory.
@@ -86,21 +92,17 @@ export class RemoteIngressManager {
private async initializeHubSettings(): Promise<void> {
let doc = await RemoteIngressHubSettingsDoc.load();
if (!doc) {
const seedPerformance = this.normalizePerformanceConfig(this.seedHubPerformance);
if (seedPerformance) {
doc = new RemoteIngressHubSettingsDoc();
doc.settingsId = 'remote-ingress-hub-settings';
doc.performance = seedPerformance;
doc.updatedAt = Date.now();
doc.updatedBy = 'seed';
await doc.save();
}
doc = new RemoteIngressHubSettingsDoc();
doc.settingsId = 'remote-ingress-hub-settings';
doc.enabled = false;
doc.tunnelPort = defaultTunnelPort;
doc.hubDomain = '';
doc.updatedAt = Date.now();
doc.updatedBy = 'default';
await doc.save();
}
this.hubSettings = doc ? this.toHubSettings(doc) : {
updatedAt: 0,
updatedBy: 'default',
};
this.hubSettings = this.toHubSettings(doc);
}
/**
@@ -131,16 +133,30 @@ export class RemoteIngressManager {
}
public async updateHubSettings(
updates: { performance?: IRemoteIngressPerformanceConfig },
updates: TRemoteIngressHubSettingsUpdate,
updatedBy: string,
): Promise<IRemoteIngressHubSettings> {
let doc = await RemoteIngressHubSettingsDoc.load();
if (!doc) {
doc = new RemoteIngressHubSettingsDoc();
doc.settingsId = 'remote-ingress-hub-settings';
doc.enabled = false;
doc.tunnelPort = defaultTunnelPort;
}
doc.performance = this.normalizePerformanceConfig(updates.performance);
const normalized = this.normalizeHubSettingsUpdate(updates);
if (hasOwn(normalized, 'enabled')) {
doc.enabled = normalized.enabled;
}
if (hasOwn(normalized, 'tunnelPort')) {
doc.tunnelPort = normalized.tunnelPort;
}
if (hasOwn(updates, 'hubDomain')) {
doc.hubDomain = normalized.hubDomain || '';
}
if (hasOwn(updates, 'performance')) {
doc.performance = normalized.performance || undefined;
}
doc.updatedAt = Date.now();
doc.updatedBy = updatedBy;
await doc.save();
@@ -408,6 +424,34 @@ export class RemoteIngressManager {
return result;
}
private normalizeHubSettingsUpdate(
updates: TRemoteIngressHubSettingsUpdate,
): TRemoteIngressHubSettingsUpdate {
const next: TRemoteIngressHubSettingsUpdate = {};
if (hasOwn(updates, 'enabled') && updates.enabled !== undefined) {
next.enabled = Boolean(updates.enabled);
}
if (hasOwn(updates, 'tunnelPort') && updates.tunnelPort !== undefined) {
const tunnelPort = Number(updates.tunnelPort);
if (!Number.isInteger(tunnelPort) || tunnelPort < 1 || tunnelPort > 65535) {
throw new Error('tunnelPort must be a valid TCP port');
}
next.tunnelPort = tunnelPort;
}
if (hasOwn(updates, 'hubDomain')) {
const hubDomain = `${updates.hubDomain || ''}`.trim();
next.hubDomain = hubDomain || undefined;
}
if (hasOwn(updates, 'performance')) {
next.performance = updates.performance === null
? undefined
: this.normalizePerformanceConfig(updates.performance || undefined);
}
return next;
}
private normalizePerformanceConfig(
performance?: IRemoteIngressPerformanceConfig,
): IRemoteIngressPerformanceConfig | undefined {
@@ -488,6 +532,9 @@ export class RemoteIngressManager {
private toHubSettings(doc: RemoteIngressHubSettingsDoc): IRemoteIngressHubSettings {
return {
enabled: doc.enabled ?? false,
tunnelPort: doc.tunnelPort ?? defaultTunnelPort,
hubDomain: doc.hubDomain || undefined,
performance: doc.performance,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
+43
View File
@@ -0,0 +1,43 @@
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
export interface IEmailPortConfig {
/** External to internal SMTP port mapping. */
portMapping?: Record<number, number>;
/** Custom route settings for specific external ports. */
portSettings?: Record<number, {
terminateTls?: boolean;
routeName?: string;
[key: string]: unknown;
}>;
/** Path to store received emails, when configured by the runtime. */
receivedEmailsPath?: string;
}
export interface IEmailServerSettings {
enabled: boolean;
hostname: string | null;
ports: number[];
portMapping: Record<number, number> | null;
receivedEmailsPath: string | null;
maxMessageSize: number | null;
domainCount: number;
routeCount: number;
authUserCount: number;
updatedAt: number;
updatedBy: string;
}
export interface IEmailServerSettingsSeed {
enabled?: boolean;
emailConfig?: IUnifiedEmailServerOptions;
emailPortConfig?: IEmailPortConfig;
}
export type TEmailServerSettingsUpdate = {
enabled?: boolean;
hostname?: string | null;
ports?: number[];
portMapping?: Record<number, number> | null;
receivedEmailsPath?: string | null;
maxMessageSize?: number | null;
};
+1
View File
@@ -10,4 +10,5 @@ export * from './workhoster.js';
export * from './dns-record.js';
export * from './acme-config.js';
export * from './email-domain.js';
export * from './email-settings.js';
export * from './security-policy.js';
+8
View File
@@ -64,11 +64,19 @@ export interface IRemoteIngressPerformanceConfig {
}
export interface IRemoteIngressHubSettings {
enabled: boolean;
tunnelPort: number;
hubDomain?: string;
performance?: IRemoteIngressPerformanceConfig;
updatedAt: number;
updatedBy: string;
}
export type TRemoteIngressHubSettingsUpdate = Partial<Pick<IRemoteIngressHubSettings, 'enabled' | 'tunnelPort'>> & {
hubDomain?: string | null;
performance?: IRemoteIngressPerformanceConfig | null;
};
export interface IRemoteIngressPerformanceEffective {
profile: TRemoteIngressPerformanceProfile;
maxStreamsPerEdge: number;
+34
View File
@@ -0,0 +1,34 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IEmailServerSettings, TEmailServerSettingsUpdate } from '../data/email-settings.js';
export interface IReq_GetEmailServerSettings extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetEmailServerSettings
> {
method: 'getEmailServerSettings';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
settings: IEmailServerSettings;
};
}
export interface IReq_UpdateEmailServerSettings extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateEmailServerSettings
> {
method: 'updateEmailServerSettings';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
settings: TEmailServerSettingsUpdate;
};
response: {
success: boolean;
settings?: IEmailServerSettings;
message?: string;
};
}
+1
View File
@@ -19,5 +19,6 @@ export * from './domains.js';
export * from './dns-records.js';
export * from './acme-config.js';
export * from './email-domains.js';
export * from './email-settings.js';
export * from './workhoster.js';
export * from './security-policy.js';
+4 -1
View File
@@ -176,7 +176,10 @@ export interface IReq_UpdateRemoteIngressHubSettings extends plugins.typedreques
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
performance?: IRemoteIngressPerformanceConfig;
enabled?: boolean;
tunnelPort?: number;
hubDomain?: string | null;
performance?: IRemoteIngressPerformanceConfig | null;
};
response: {
success: boolean;
+146
View File
@@ -21,6 +21,20 @@ export interface IMigrationRunner {
type TMigrationSecurity = Record<string, any>;
export interface IDcRouterMigrationOptions {
remoteIngressHubSettings?: {
enabled?: boolean;
tunnelPort?: number;
hubDomain?: string | null;
performance?: Record<string, any> | null;
};
emailServerSettings?: {
enabled?: boolean;
emailConfig?: Record<string, any>;
emailPortConfig?: Record<string, any>;
};
}
const DEFAULT_SOURCE_PROFILES: Array<{
name: string;
description: string;
@@ -62,6 +76,19 @@ const DEFAULT_SOURCE_PROFILES: Array<{
},
];
const remoteIngressHubSettingsMigrationBaseVersion = '13.43.5';
function compareSemver(a: string, b: string): number {
const aParts = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
const bParts = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
const maxLength = Math.max(aParts.length, bParts.length);
for (let i = 0; i < maxLength; i++) {
const diff = (aParts[i] || 0) - (bParts[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
function mergeMigrationSecurityFields(
base: TMigrationSecurity | undefined,
override: TMigrationSecurity | undefined,
@@ -379,6 +406,106 @@ async function convertRouteAccessMetadataToSourceBindings(ctx: {
);
}
async function backfillRemoteIngressHubSettings(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}, options: IDcRouterMigrationOptions): Promise<void> {
const collection = ctx.mongo!.collection('RemoteIngressHubSettingsDoc');
const seed = options.remoteIngressHubSettings || {};
const now = Date.now();
const doc = await collection.findOne({ settingsId: 'remote-ingress-hub-settings' });
if (!doc) {
await collection.insertOne({
settingsId: 'remote-ingress-hub-settings',
enabled: seed.enabled ?? false,
tunnelPort: seed.tunnelPort ?? 8443,
hubDomain: seed.hubDomain || '',
performance: seed.performance || undefined,
updatedAt: now,
updatedBy: 'migration',
});
ctx.log.log('info', 'backfill-remote-ingress-hub-settings: inserted singleton settings document');
return;
}
const $set: Record<string, any> = {};
if ((doc as any).enabled === undefined) {
$set.enabled = seed.enabled ?? false;
}
if ((doc as any).tunnelPort === undefined) {
$set.tunnelPort = seed.tunnelPort ?? 8443;
}
if ((doc as any).hubDomain === undefined && seed.hubDomain) {
$set.hubDomain = seed.hubDomain;
}
if ((doc as any).performance === undefined && seed.performance) {
$set.performance = seed.performance;
}
if (Object.keys($set).length === 0) {
ctx.log.log('info', 'backfill-remote-ingress-hub-settings: no changes needed');
return;
}
$set.updatedAt = now;
$set.updatedBy = (doc as any).updatedBy || 'migration';
await collection.updateOne(
(doc as any)._id ? { _id: (doc as any)._id } : { settingsId: 'remote-ingress-hub-settings' },
{ $set },
);
ctx.log.log('info', `backfill-remote-ingress-hub-settings: set ${Object.keys($set).length - 2} missing field(s)`);
}
async function backfillEmailServerSettings(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}, options: IDcRouterMigrationOptions): Promise<void> {
const collection = ctx.mongo!.collection('EmailServerSettingsDoc');
const seed = options.emailServerSettings || {};
const now = Date.now();
const doc = await collection.findOne({ settingsId: 'email-server-settings' });
if (!doc) {
await collection.insertOne({
settingsId: 'email-server-settings',
enabled: seed.enabled ?? Boolean(seed.emailConfig),
emailConfig: seed.emailConfig || undefined,
emailPortConfig: seed.emailPortConfig || undefined,
updatedAt: now,
updatedBy: 'migration',
});
ctx.log.log('info', 'backfill-email-server-settings: inserted singleton settings document');
return;
}
const $set: Record<string, any> = {};
if ((doc as any).enabled === undefined) {
$set.enabled = seed.enabled ?? Boolean(seed.emailConfig);
}
if ((doc as any).emailConfig === undefined && seed.emailConfig) {
$set.emailConfig = seed.emailConfig;
}
if ((doc as any).emailPortConfig === undefined && seed.emailPortConfig) {
$set.emailPortConfig = seed.emailPortConfig;
}
if (Object.keys($set).length === 0) {
ctx.log.log('info', 'backfill-email-server-settings: no changes needed');
return;
}
$set.updatedAt = now;
$set.updatedBy = (doc as any).updatedBy || 'migration';
await collection.updateOne(
(doc as any)._id ? { _id: (doc as any)._id } : { settingsId: 'email-server-settings' },
{ $set },
);
ctx.log.log('info', `backfill-email-server-settings: set ${Object.keys($set).length - 2} missing field(s)`);
}
/**
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
*
@@ -391,6 +518,7 @@ async function convertRouteAccessMetadataToSourceBindings(ctx: {
export async function createMigrationRunner(
db: unknown,
targetVersion: string,
options: IDcRouterMigrationOptions = {},
): Promise<IMigrationRunner> {
const sm = await import('@push.rocks/smartmigration');
const migration = new sm.SmartMigration({
@@ -494,7 +622,25 @@ export async function createMigrationRunner(
.description('Convert route sourceProfileRef/sourcePolicy metadata to canonical sourceBindings')
.up(async (ctx) => {
await convertRouteAccessMetadataToSourceBindings(ctx);
})
.step('backfill-remote-ingress-hub-settings-current')
.from('13.43.2').to(remoteIngressHubSettingsMigrationBaseVersion)
.description('Backfill RemoteIngress hub singleton settings for current dcrouter 13.43.5 installs')
.up(async (ctx) => {
await backfillRemoteIngressHubSettings(ctx, options);
await backfillEmailServerSettings(ctx, options);
});
if (compareSemver(targetVersion, remoteIngressHubSettingsMigrationBaseVersion) > 0) {
migration
.step('backfill-remote-ingress-hub-settings')
.from(remoteIngressHubSettingsMigrationBaseVersion).to(targetVersion)
.description('Backfill DB-backed singleton runtime settings from legacy bootstrap config')
.up(async (ctx) => {
await backfillRemoteIngressHubSettings(ctx, options);
await backfillEmailServerSettings(ctx, options);
});
}
return migration;
}
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.43.4',
version: '13.44.1',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+33 -1
View File
@@ -1230,7 +1230,10 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
});
export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.createAction<{
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
enabled?: boolean;
tunnelPort?: number;
hubDomain?: string | null;
performance?: interfaces.data.IRemoteIngressPerformanceConfig | null;
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
@@ -1242,6 +1245,9 @@ export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.creat
const response = await request.fire({
identity: context.identity!,
enabled: dataArg.enabled,
tunnelPort: dataArg.tunnelPort,
hubDomain: dataArg.hubDomain,
performance: dataArg.performance,
});
@@ -2956,6 +2962,7 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
export interface IEmailDomainsState {
domains: interfaces.data.IEmailDomain[];
settings: interfaces.data.IEmailServerSettings | null;
isLoading: boolean;
lastUpdated: number;
}
@@ -2964,6 +2971,7 @@ export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsSt
'emailDomains',
{
domains: [],
settings: null,
isLoading: false,
lastUpdated: 0,
},
@@ -2980,10 +2988,15 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailDomains
>('/typedrequest', 'getEmailDomains');
const settingsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetEmailServerSettings
>('/typedrequest', 'getEmailServerSettings');
const response = await request.fire({ identity: context.identity });
const settingsResponse = await settingsRequest.fire({ identity: context.identity });
return {
...currentState,
domains: response.domains,
settings: settingsResponse.settings,
isLoading: false,
lastUpdated: Date.now(),
};
@@ -3014,6 +3027,25 @@ export const createEmailDomainAction = emailDomainsStatePart.createAction<{
}
});
export const updateEmailServerSettingsAction = emailDomainsStatePart.createAction<
interfaces.data.TEmailServerSettingsUpdate
>(async (statePartArg, settings, actionContext) => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateEmailServerSettings
>('/typedrequest', 'updateEmailServerSettings');
const response = await request.fire({ identity: context.identity!, settings });
if (!response.success) {
return currentState;
}
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
} catch {
return currentState;
}
});
export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string>(
async (statePartArg, id, actionContext) => {
const context = getActionContext();
@@ -101,6 +101,7 @@ export class OpsViewEmailDomains extends DeesElement {
public render(): TemplateResult {
const domains = this.emailDomainsState.domains;
const settings = this.emailDomainsState.settings;
const validCount = domains.filter(
(d) =>
d.dnsStatus.mx === 'valid' &&
@@ -127,6 +128,22 @@ export class OpsViewEmailDomains extends DeesElement {
icon: 'lucide:Check',
color: '#22c55e',
},
{
id: 'server',
title: 'Server',
value: settings?.enabled ? 'enabled' : 'disabled',
type: 'text',
icon: 'lucide:mail-check',
color: settings?.enabled ? '#22c55e' : '#6b7280',
},
{
id: 'ports',
title: 'SMTP Ports',
value: settings?.ports?.join(', ') || 'none',
type: 'text',
icon: 'lucide:plug',
color: '#0ea5e9',
},
{
id: 'issues',
title: 'Issues',
@@ -163,6 +180,13 @@ export class OpsViewEmailDomains extends DeesElement {
);
},
},
{
name: 'Settings',
iconName: 'lucide:settings',
action: async () => {
await this.showSettingsDialog();
},
},
]}
></dees-statsgrid>
@@ -258,6 +282,108 @@ export class OpsViewEmailDomains extends DeesElement {
return html`<span class="sourceBadge">${label}</span>`;
}
private parsePortList(value: string): number[] {
return value
.split(',')
.map((part) => Number.parseInt(part.trim(), 10))
.filter((port) => Number.isInteger(port));
}
private parsePortMapping(value: string): Record<number, number> | null {
const trimmed = value.trim();
if (!trimmed) return null;
const mapping: Record<number, number> = {};
for (const pair of trimmed.split(',')) {
const [externalPort, internalPort] = pair
.split(':')
.map((part) => Number.parseInt(part.trim(), 10));
if (Number.isInteger(externalPort) && Number.isInteger(internalPort)) {
mapping[externalPort] = internalPort;
}
}
return Object.keys(mapping).length > 0 ? mapping : null;
}
private formatPortMapping(mapping: Record<number, number> | null | undefined): string {
if (!mapping) return '';
return Object.entries(mapping)
.map(([externalPort, internalPort]) => `${externalPort}:${internalPort}`)
.join(', ');
}
private async showSettingsDialog() {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const settings = this.emailDomainsState.settings;
DeesModal.createAndShow({
heading: 'Email Server Settings',
content: html`
<dees-form>
<dees-input-checkbox
.key=${'enabled'}
.label=${'Enable email server'}
.value=${settings?.enabled ?? false}
></dees-input-checkbox>
<dees-input-text
.key=${'hostname'}
.label=${'SMTP hostname'}
.description=${'Public hostname used in SMTP banners and DNS records'}
.value=${settings?.hostname || ''}
></dees-input-text>
<dees-input-text
.key=${'ports'}
.label=${'Public ports'}
.description=${'Comma-separated SMTP ingress ports, e.g. 25, 587, 465'}
.value=${settings?.ports?.join(', ') || '25, 587, 465'}
></dees-input-text>
<dees-input-text
.key=${'portMapping'}
.label=${'Port mapping'}
.description=${'Optional external:internal pairs, e.g. 25:10025, 587:10587'}
.value=${this.formatPortMapping(settings?.portMapping)}
></dees-input-text>
<dees-input-text
.key=${'maxMessageSize'}
.label=${'Max message size'}
.description=${'Bytes; leave empty for smartmta default'}
.value=${settings?.maxMessageSize ? String(settings.maxMessageSize) : ''}
></dees-input-text>
<dees-input-text
.key=${'receivedEmailsPath'}
.label=${'Received emails path'}
.description=${'Optional storage path for received email artifacts'}
.value=${settings?.receivedEmailsPath || ''}
></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', action: async (m: any) => m.destroy() },
{
name: 'Save',
action: async (m: any) => {
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const maxMessageSizeRaw = String(data.maxMessageSize || '').trim();
await appstate.emailDomainsStatePart.dispatchAction(
appstate.updateEmailServerSettingsAction,
{
enabled: Boolean(data.enabled),
hostname: String(data.hostname || '').trim() || null,
ports: this.parsePortList(String(data.ports || '')),
portMapping: this.parsePortMapping(String(data.portMapping || '')),
maxMessageSize: maxMessageSizeRaw ? Number.parseInt(maxMessageSizeRaw, 10) : null,
receivedEmailsPath: String(data.receivedEmailsPath || '').trim() || null,
},
);
DeesToast.show({ message: 'Email settings saved', type: 'success', duration: 2500 });
m.destroy();
},
},
],
});
}
private async showCreateDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
const domainOptions = this.domainsState.domains.map((d) => ({
@@ -620,16 +620,34 @@ export class OpsViewRemoteIngress extends DeesElement {
private async showHubSettingsDialog(): Promise<void> {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const performance = this.riState.hubSettings?.performance || {};
const hubSettings = this.riState.hubSettings;
const performance = hubSettings?.performance || {};
const selectedProfile = performanceProfileOptions.find((option) => option.key === (performance.profile || '')) || performanceProfileOptions[0];
const updatedAt = this.riState.hubSettings?.updatedAt
? new Date(this.riState.hubSettings.updatedAt).toLocaleString()
const updatedAt = hubSettings?.updatedAt
? new Date(hubSettings.updatedAt).toLocaleString()
: 'not persisted yet';
await DeesModal.createAndShow({
heading: 'RemoteIngress Hub Settings',
content: html`
<dees-form>
<dees-input-checkbox
.key=${'enabled'}
.label=${'Enable RemoteIngress Hub'}
.value=${hubSettings?.enabled ?? false}
></dees-input-checkbox>
<dees-input-text
.key=${'tunnelPort'}
.label=${'Tunnel Port'}
.description=${'TCP/UDP port edges connect to on the hub.'}
.value=${(hubSettings?.tunnelPort || 8443).toString()}
></dees-input-text>
<dees-input-text
.key=${'hubDomain'}
.label=${'Hub Domain / Address'}
.description=${'Public host or IP embedded in edge connection tokens.'}
.value=${hubSettings?.hubDomain || ''}
></dees-input-text>
<dees-input-dropdown
.key=${'profile'}
.label=${'Performance Profile'}
@@ -662,8 +680,8 @@ export class OpsViewRemoteIngress extends DeesElement {
></dees-input-text>
</dees-form>
<p class="settingsNote">
Saving restarts the RemoteIngress hub so connected edges reconnect and pick up the new defaults.
Last updated: ${updatedAt} by ${this.riState.hubSettings?.updatedBy || 'default'}.
Saving applies DB-backed hub settings. Enabling or disabling the hub restarts SmartProxy so tunneled traffic and route metadata stay consistent.
Last updated: ${updatedAt} by ${hubSettings?.updatedBy || 'default'}.
</p>
`,
menuOptions: [
@@ -679,9 +697,11 @@ export class OpsViewRemoteIngress extends DeesElement {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | null;
let tunnelPort: number;
try {
performanceSettings = this.collectHubPerformanceSettings(formData);
tunnelPort = this.parseRequiredPort(formData.tunnelPort, 'Tunnel Port');
performanceSettings = this.collectHubPerformanceSettings(formData, performance);
} catch (err: unknown) {
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
return;
@@ -689,7 +709,12 @@ export class OpsViewRemoteIngress extends DeesElement {
const nextState = await appstate.remoteIngressStatePart.dispatchAction(
appstate.updateRemoteIngressHubSettingsAction,
{ performance: performanceSettings },
{
enabled: formData.enabled !== false,
tunnelPort,
hubDomain: `${formData.hubDomain || ''}`.trim() || null,
performance: performanceSettings,
},
);
if (nextState.error) {
DeesToast.show({ message: nextState.error, type: 'error', duration: 4000 });
@@ -703,29 +728,37 @@ export class OpsViewRemoteIngress extends DeesElement {
});
}
private collectHubPerformanceSettings(formData: Record<string, any>): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
const next: interfaces.data.IRemoteIngressPerformanceConfig = {};
private collectHubPerformanceSettings(
formData: Record<string, any>,
currentPerformance: interfaces.data.IRemoteIngressPerformanceConfig,
): interfaces.data.IRemoteIngressPerformanceConfig | null {
const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...currentPerformance };
const profile = getDropdownKey(formData.profile) as interfaces.data.TRemoteIngressPerformanceProfile | '';
if (profile) {
next.profile = profile;
} else {
delete next.profile;
}
this.assignPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
this.assignPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
this.assignPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
this.assignOptionalPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
this.assignOptionalPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
this.assignOptionalPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
const serverFirstPorts = this.parsePortList(formData.serverFirstPorts, 'Server-first Ports');
if (serverFirstPorts.length > 0) {
const serverFirstPortsText = `${formData.serverFirstPorts || ''}`.trim();
if (serverFirstPortsText) {
const serverFirstPorts = this.parsePortList(serverFirstPortsText, 'Server-first Ports');
if (serverFirstPorts.includes(443)) {
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
}
next.serverFirstPorts = serverFirstPorts;
} else {
delete next.serverFirstPorts;
}
return Object.keys(next).length > 0 ? next : undefined;
return Object.keys(next).length > 0 ? next : null;
}
private assignPositiveIntegerSetting(
private assignOptionalPositiveIntegerSetting(
target: interfaces.data.IRemoteIngressPerformanceConfig,
key: 'maxStreamsPerEdge' | 'clientWriteTimeoutMs' | 'firstDataConnectTimeoutMs',
value: any,
@@ -733,6 +766,7 @@ export class OpsViewRemoteIngress extends DeesElement {
): void {
const text = `${value || ''}`.trim();
if (!text) {
delete target[key];
return;
}
const parsed = Number.parseInt(text, 10);
@@ -755,4 +789,12 @@ export class OpsViewRemoteIngress extends DeesElement {
}
return [...new Set(ports)].sort((a, b) => a - b);
}
private parseRequiredPort(value: any, label: string): number {
const port = Number.parseInt(`${value || ''}`.trim(), 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label} must be a valid port number`);
}
return port;
}
}