Compare commits

...

12 Commits

Author SHA1 Message Date
jkunz 1a381df937 v13.41.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m57s
2026-06-01 14:49:38 +00:00
jkunz 38e2f3cee1 fix(deps): update smartproxy and remoteingress 2026-06-01 14:38:34 +00:00
jkunz 4a47460bf1 v13.41.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m50s
2026-05-31 21:06:24 +00:00
jkunz 3679cba3a4 fix(smartacme): prevent SmartAcme startup from blocking router startup 2026-05-31 21:05:34 +00:00
jkunz 3dc0371f7e v13.41.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m5s
2026-05-31 19:42:51 +00:00
jkunz b212662764 feat(remoteingress): add RemoteIngress hub settings management 2026-05-31 19:42:17 +00:00
jkunz 776c65a18c v13.40.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m44s
2026-05-31 16:23:56 +00:00
jkunz 5f6ec63770 fix(deps): bump smartproxy and remoteingress dependencies 2026-05-31 16:23:48 +00:00
jkunz 1b4cc0567f v13.40.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m0s
2026-05-31 15:26:43 +00:00
jkunz 22de50b544 fix(routes): ensure source profiles fully own route security 2026-05-31 15:26:18 +00:00
jkunz 2e3bead40c v13.40.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 19m10s
2026-05-31 11:50:08 +00:00
jkunz 85065b05c8 fix(deps): update smartproxy, remoteingress, and tsdeno dependencies 2026-05-31 11:49:25 +00:00
22 changed files with 1280 additions and 223 deletions
+45
View File
@@ -3,15 +3,60 @@
## Pending
## 2026-06-01 - 13.41.2
### Fixes
- update SmartProxy and RemoteIngress dependencies (deps)
- Bump SmartProxy to 27.12.3 for the published half-close regression coverage.
- Bump RemoteIngress to 4.22.4 for the half-close/reset and UDP startup lifecycle fixes.
- Align npm and Deno import metadata for both runtime dependencies.
## 2026-05-31 - 13.41.1
### Fixes
- prevent SmartAcme startup from blocking router startup (smartacme)
- Start SmartAcme in the background with bounded exponential retry handling
- Re-trigger certificate provisioning after SmartAcme becomes ready
- Cancel stale retry timers and clean up SmartAcme instances during shutdown or config updates
## 2026-05-31 - 13.41.0
### Features
- add RemoteIngress hub settings management (remoteingress)
- Persist hub-level RemoteIngress performance settings with validation and seed defaults from config
- Add typed read/update handlers and web UI controls for hub performance settings
- Restart the tunnel hub after hub setting updates so new performance defaults take effect
- Serialize RemoteIngress lifecycle tasks, edge mutations, route syncs, and stop/start operations to avoid hub race conditions
## 2026-05-31 - 13.40.3
### Fixes
- bump smartproxy and remoteingress dependencies (deps)
- Bumped @push.rocks/smartproxy from ^27.12.1 to ^27.12.2
- Bumped @serve.zone/remoteingress from ^4.22.2 to ^4.22.3
- Updated dependency versions in both package.json and deno.json
## 2026-05-31 - 13.40.2
### Fixes
- ensure source profiles fully own route security (routes)
- Resolve profile-backed routes by cloning source profile security instead of merging inline route overrides
- Clear stale route security when a source profile reference is removed without explicit replacement security
- Add a migration to rematerialize persisted profile-backed route security
## 2026-05-31 - 13.40.1
### Fixes
- update smartproxy, remoteingress, and tsdeno dependencies (deps)
- Bump @push.rocks/smartproxy to ^27.12.1 in Deno imports
- Bump @serve.zone/remoteingress to ^4.22.2 in package and Deno configuration
- Bump @git.zone/tsdeno to ^1.5.0
## 2026-05-30 - 13.40.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.40.0",
"version": "13.41.2",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
@@ -31,7 +31,7 @@
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.11.1",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.3",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
@@ -40,7 +40,7 @@
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.1",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.4",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
"lru-cache": "npm:lru-cache@^11.4.0",
"qrcode": "npm:qrcode@^1.5.4",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.40.0",
"version": "13.41.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -29,7 +29,7 @@
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsdeno": "^1.4.0",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
@@ -61,7 +61,7 @@
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.12.1",
"@push.rocks/smartproxy": "^27.12.3",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
@@ -71,7 +71,7 @@
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.22.1",
"@serve.zone/remoteingress": "^4.22.4",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.4.0",
+22 -22
View File
@@ -76,7 +76,7 @@ importers:
version: 5.3.3
'@push.rocks/smartnetwork':
specifier: ^4.7.2
version: 4.7.2
version: 4.7.3
'@push.rocks/smartpath':
specifier: ^6.0.0
version: 6.0.0
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.12.1
version: 27.12.1
specifier: ^27.12.3
version: 27.12.3
'@push.rocks/smartradius':
specifier: ^1.3.0
version: 1.3.0
@@ -114,8 +114,8 @@ importers:
specifier: ^5.8.0
version: 5.8.0
'@serve.zone/remoteingress':
specifier: ^4.22.1
version: 4.22.1
specifier: ^4.22.4
version: 4.22.4
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
@@ -139,8 +139,8 @@ importers:
specifier: ^2.10.4
version: 2.10.4
'@git.zone/tsdeno':
specifier: ^1.4.0
version: 1.4.0
specifier: ^1.5.0
version: 1.5.0
'@git.zone/tsdocker':
specifier: ^2.4.0
version: 2.4.0
@@ -729,8 +729,8 @@ packages:
resolution: {integrity: sha512-/xWOGrnuMaJ/Xo/EasaF9N3N9w1J9LDywZaRTa0UTtzbEtfJP7F2NJ9l4tWCwS+vTKpnqApX7ZueRh1h5MrwPQ==}
hasBin: true
'@git.zone/tsdeno@1.4.0':
resolution: {integrity: sha512-84kFa/uKPTlzeLxtHoFxefk6O9khsWWQ2PCWNbCNYIUqWHUvN9COpGq0GXWtsoxLWPhTTIeHsOX4+O55uT2MPw==}
'@git.zone/tsdeno@1.5.0':
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
hasBin: true
'@git.zone/tsdocker@2.4.0':
@@ -1402,8 +1402,8 @@ packages:
'@push.rocks/smartmustache@3.0.2':
resolution: {integrity: sha512-G3LyRXoJhyM+iQhkvP/MR/2WYMvC9U7zc2J44JxUM5tPdkQ+o3++FbfRtnZj6rz5X/A7q03//vsxPitVQwoi2Q==}
'@push.rocks/smartnetwork@4.7.2':
resolution: {integrity: sha512-OwT8kwQeEO+E3RuCyCfgQEBz+FyydUVaTBivZzzVchdJCUDgoDkXSnRkbIuGoHd1BfRFkUg9DQlSzt0uDfsIbw==}
'@push.rocks/smartnetwork@4.7.3':
resolution: {integrity: sha512-ecv8aSGbcHUDkE0IJ+/0mRpgQv1fSjQAgcTe1qgBNY1Lk8lQTTaNjpG7g21EdK23seyShewejtGKOcK5o7Rh6A==}
'@push.rocks/smartnftables@1.2.0':
resolution: {integrity: sha512-VTRHnxHrJj9VOq2MaCOqxiA4JLGRnzEaZ7kXxA7v3ljX+Y2wWK9VYpwKKBEbjgjoTpQyOf+I0gEG9wkR/jtUvQ==}
@@ -1429,8 +1429,8 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.12.1':
resolution: {integrity: sha512-B1QNyGzwFea8fE2vvXO0iDzYrTfe3HcEnhPhNi6hVnmdSPe1yhNYUu5tm1CKLeCoXu/EVkAUkEFv/+d7gKa9EA==}
'@push.rocks/smartproxy@27.12.3':
resolution: {integrity: sha512-nw5+iYhngwrdmSOg87R1opHVZXdLK4GHm/PAtVSWHD7zlnOPhEvdrlJndAq4ehGktf7z6B0SvwwmdrAOCPhWWw==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -1719,8 +1719,8 @@ packages:
'@serve.zone/interfaces@5.8.0':
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
'@serve.zone/remoteingress@4.22.1':
resolution: {integrity: sha512-SkpP9VeC30A6HyonlLLE8rZVNWAPjw5NeY3pU+CWRJ0Si+hJX3FkyI4IFbOOBE+PE4JbxdIjwMNzvpxuXqZeUQ==}
'@serve.zone/remoteingress@4.22.4':
resolution: {integrity: sha512-3SPTlFQQlB7ptdUr0TzZJQ1UOppPWcjcffv25qpO64gzw5f5VhmkywN7YQGAeXqCe4UeuRZrxOwZY0m9SpfJzw==}
hasBin: true
'@smithy/chunked-blob-reader-native@4.2.3':
@@ -5251,7 +5251,7 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdeno@1.4.0':
'@git.zone/tsdeno@1.5.0':
dependencies:
'@push.rocks/early': 4.0.4
'@push.rocks/smartcli': 4.0.21
@@ -5327,7 +5327,7 @@ snapshots:
'@push.rocks/smartjson': 6.0.1
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartmongo': 7.0.0(socks@2.8.8)
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartnetwork': 4.7.3
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartrequest': 5.0.3
@@ -6136,7 +6136,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartlog': 3.2.2
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartnetwork': 4.7.3
'@push.rocks/smartstring': 4.1.1
'@push.rocks/smarttime': 4.2.3
'@push.rocks/smartunique': 3.0.9
@@ -6612,7 +6612,7 @@ snapshots:
dependencies:
handlebars: 4.7.9
'@push.rocks/smartnetwork@4.7.2':
'@push.rocks/smartnetwork@4.7.3':
dependencies:
'@push.rocks/smartdns': 7.9.3
'@push.rocks/smartrust': 1.4.0
@@ -6675,7 +6675,7 @@ snapshots:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartfs': 1.5.1
'@push.rocks/smartjimp': 1.2.1
'@push.rocks/smartnetwork': 4.7.2
'@push.rocks/smartnetwork': 4.7.3
'@push.rocks/smartpath': 6.0.0
'@push.rocks/smartpromise': 4.2.4
'@push.rocks/smartpuppeteer': 2.0.6(typescript@6.0.3)
@@ -6696,7 +6696,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.12.1':
'@push.rocks/smartproxy@27.12.3':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -7085,7 +7085,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/remoteingress@4.22.1':
'@serve.zone/remoteingress@4.22.4':
dependencies:
'@push.rocks/qenv': 6.1.4
'@push.rocks/smartnftables': 1.2.0
+124 -11
View File
@@ -12,13 +12,77 @@ function setPath(target: Record<string, any>, path: string, value: unknown): voi
cursor[parts[parts.length - 1]] = value;
}
function getPath(target: Record<string, any>, path: string): unknown {
let cursor: any = target;
for (const part of path.split('.')) {
if (cursor === null || cursor === undefined) return undefined;
cursor = cursor[part];
}
return cursor;
}
function applySet(document: Record<string, any>, set: Record<string, unknown>): void {
for (const [key, value] of Object.entries(set)) {
setPath(document, key, value);
}
}
function createFakeDb(currentVersion: string) {
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
for (const [key, expected] of Object.entries(query)) {
const actual = getPath(document, key);
if (expected && typeof expected === 'object' && !Array.isArray(expected)) {
if ('$exists' in expected) {
const exists = actual !== undefined;
if (exists !== Boolean(expected.$exists)) return false;
continue;
}
if ('$type' in expected) {
if (expected.$type === 'string' && typeof actual !== 'string') return false;
continue;
}
if ('$in' in expected) {
if (!Array.isArray(expected.$in) || !expected.$in.includes(actual)) return false;
continue;
}
}
if (actual !== expected) return false;
}
return true;
}
function createFakeCollection(documents: Array<Record<string, any>> = []) {
return {
find: (query: Record<string, any> = {}) => ({
async *[Symbol.asyncIterator]() {
for (const document of documents) {
if (matchesQuery(document, query)) {
yield structuredClone(document);
}
}
},
}),
updateMany: async (query: Record<string, any>, update: any) => {
let modifiedCount = 0;
for (const document of documents) {
if (!matchesQuery(document, query)) continue;
applySet(document, update.$set || {});
modifiedCount++;
}
return { modifiedCount };
},
updateOne: async (query: Record<string, any>, update: any) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
applySet(document, update.$set || {});
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
},
};
}
function createFakeDb(
currentVersion: string,
collections: Record<string, Array<Record<string, any>>> = {},
) {
const ledgerDocument = {
nameId: 'smartmigration:smartmigration',
data: {
@@ -29,12 +93,10 @@ function createFakeDb(currentVersion: string) {
},
};
const emptyCollection = {
find: () => ({
async *[Symbol.asyncIterator]() {},
}),
updateMany: async () => ({ modifiedCount: 0 }),
};
const fakeCollections = new Map(
Object.entries(collections).map(([name, documents]) => [name, createFakeCollection(documents)]),
);
const emptyCollection = createFakeCollection();
const ledgerCollection = {
createIndex: async () => undefined,
@@ -52,18 +114,69 @@ function createFakeDb(currentVersion: string) {
return {
mongoDb: {
collection: (name: string) =>
name === 'SmartdataEasyStore' ? ledgerCollection : emptyCollection,
name === 'SmartdataEasyStore'
? ledgerCollection
: fakeCollections.get(name) || emptyCollection,
},
};
}
tap.test('migration runner bridges old package-version targets without real schema steps', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.31.0');
tap.test('migration runner applies schema steps through the current target', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.40.2');
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.31.0');
expect(result.currentVersionAfter).toEqual('13.40.2');
expect(result.stepsApplied).toHaveLength(3);
});
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: {
ipAllowList: ['192.168.*', '127.0.0.1'],
maxConnections: 1000,
},
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'Public service domains',
match: { ports: 443, domains: ['code.foss.global'] },
action: { type: 'forward', targets: [{ host: '192.168.5.247', port: 443 }] },
security: {
ipAllowList: ['192.168.*', '*'],
maxConnections: 1000,
},
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Standard',
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.40.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].route.security.ipAllowList.includes('*')).toBeFalse();
expect(routes[0].route.security.ipAllowList).toContain('192.168.*');
expect(routes[0].route.security.maxConnections).toEqual(1000);
expect(routes[0].metadata.lastResolvedAt).toBeTruthy();
});
export default tap.start();
+18 -5
View File
@@ -91,7 +91,7 @@ tap.test('should resolve source profile onto a route', async () => {
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should merge inline route security with profile security', async () => {
tap.test('should replace inline route security when source profile is selected', async () => {
const route = makeRoute({
security: {
ipAllowList: ['127.0.0.1'],
@@ -102,13 +102,26 @@ tap.test('should merge inline route security with profile security', async () =>
const result = resolver.resolveRoute(route, metadata);
// IP lists are unioned
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('127.0.0.1');
expect(result.route.security!.ipAllowList!.includes('127.0.0.1')).toBeFalse();
expect(result.route.security!.maxConnections).toEqual(1000);
});
// Inline maxConnections overrides profile
expect(result.route.security!.maxConnections).toEqual(5000);
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
const route = makeRoute({
security: {
ipAllowList: ['*'],
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.maxConnections).toEqual(1000);
});
tap.test('should deduplicate IP lists during merge', async () => {
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.40.0',
version: '13.41.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+329 -84
View File
@@ -33,6 +33,7 @@ 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 type { IRoute } from '../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig } from '../ts_interfaces/data/remoteingress.js';
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
export interface IDcRouterOptions {
@@ -280,6 +281,9 @@ export class DcRouter {
// Remote Ingress
public remoteIngressManager?: RemoteIngressManager;
public tunnelManager?: TunnelManager;
private remoteIngressHubLifecycleChain: Promise<void> = Promise.resolve();
private remoteIngressHubStopping = false;
private remoteIngressHubGeneration = 0;
// VPN
public vpnManager?: VpnManager;
@@ -326,6 +330,11 @@ export class DcRouter {
public serviceManager: plugins.taskbuffer.ServiceManager;
private serviceSubjectSubscription?: plugins.smartrx.rxjs.Subscription;
public smartAcmeReady = false;
private smartAcmeServiceStarted = false;
private smartAcmeStartGeneration = 0;
private smartAcmeStartPromise?: Promise<void>;
private smartAcmeRetryTimer?: ReturnType<typeof setTimeout>;
private smartAcmeRetryAttempt = 0;
// TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -545,45 +554,14 @@ export class DcRouter {
.optional()
.dependsOn('SmartProxy')
.withStart(async () => {
if (this.smartAcme) {
await this.smartAcme.start();
this.smartAcmeReady = true;
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
// Re-trigger certificate provisioning for all auto-cert routes.
// During startup, certProvisionFunction returned 'http01' (SmartAcme not ready),
// but Rust ACME is disabled when certProvisionFunction is set — so all domains
// failed silently (SmartProxy doesn't emit certificate-failed for this path).
// Calling updateRoutes() re-triggers provisionCertificatesViaCallback internally,
// which calls certProvisionFunction again — now with smartAcmeReady === true.
if (this.routeConfigManager) {
// Go through RouteConfigManager to get the full merged route set
// and serialize via the route-update mutex (prevents stale overwrites)
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
this.routeConfigManager.applyRoutes().catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
} else if (this.smartProxy) {
// No RouteConfigManager (DB disabled) — re-send current routes to trigger cert provisioning
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
const currentRoutes = this.smartProxy.routeManager.getRoutes();
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
}
}
this.smartAcmeServiceStarted = true;
this.startSmartAcmeInBackground();
})
.withStop(async () => {
this.smartAcmeReady = false;
if (this.smartAcme) {
await this.smartAcme.stop();
this.smartAcme = undefined;
}
this.smartAcmeServiceStarted = false;
await this.stopSmartAcme();
})
.withRetry({ maxRetries: 20, baseDelayMs: 5000, maxDelayMs: 3_600_000, backoffFactor: 2 }),
.withRetry({ maxRetries: 0 }),
);
}
@@ -613,15 +591,10 @@ export class DcRouter {
// Sync routes to RemoteIngressManager whenever routes change,
// then push updated derived ports to the Rust hub binary
async (routes) => {
if (this.remoteIngressManager) {
this.remoteIngressManager.setRoutes(routes as any[]);
}
if (this.tunnelManager) {
try {
await this.tunnelManager.syncAllowedEdges();
} catch (err: unknown) {
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
}
try {
await this.updateRemoteIngressRoutes(routes as IDcRouterRouteConfig[]);
} catch (err: unknown) {
logger.log('error', `Failed to sync Remote Ingress allowed edges: ${(err as Error).message}`);
}
},
undefined,
@@ -739,11 +712,7 @@ export class DcRouter {
await this.setupRemoteIngress();
})
.withStop(async () => {
if (this.tunnelManager) {
await this.tunnelManager.stop();
this.tunnelManager = undefined;
}
this.remoteIngressManager = undefined;
await this.stopRemoteIngress();
})
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
);
@@ -783,6 +752,138 @@ export class DcRouter {
});
}
private startSmartAcmeInBackground(): void {
if (!this.smartAcme) {
this.smartAcmeReady = false;
return;
}
const generation = ++this.smartAcmeStartGeneration;
this.smartAcmeReady = false;
this.smartAcmeRetryAttempt = 0;
this.clearSmartAcmeRetryTimer();
this.scheduleSmartAcmeStart(generation, 0);
}
private scheduleSmartAcmeStart(generation: number, delayMs: number): void {
this.clearSmartAcmeRetryTimer();
const retryTimer = setTimeout(() => {
this.smartAcmeRetryTimer = undefined;
this.runSmartAcmeStartAttempt(generation).catch((err) => {
logger.log('error', `Unexpected SmartAcme startup error: ${(err as Error).message}`);
});
}, delayMs);
this.smartAcmeRetryTimer = retryTimer;
const unrefableTimer = retryTimer as any;
if (typeof unrefableTimer?.unref === 'function') {
unrefableTimer.unref();
}
}
private async runSmartAcmeStartAttempt(generation: number): Promise<void> {
const smartAcme = this.smartAcme;
if (!smartAcme || generation !== this.smartAcmeStartGeneration) {
return;
}
const startPromise = smartAcme.start();
this.smartAcmeStartPromise = startPromise;
try {
await startPromise;
if (generation !== this.smartAcmeStartGeneration || this.smartAcme !== smartAcme) {
await smartAcme.stop().catch((err) => {
logger.log('warn', `Failed to stop stale SmartAcme instance: ${(err as Error).message}`);
});
return;
}
this.smartAcmeReady = true;
this.smartAcmeRetryAttempt = 0;
logger.log('info', 'SmartAcme DNS-01 provider is now ready');
this.retriggerCertificateProvisioningAfterSmartAcmeReady();
} catch (err) {
if (generation !== this.smartAcmeStartGeneration || this.smartAcme !== smartAcme) {
return;
}
this.smartAcmeReady = false;
await smartAcme.stop().catch((stopErr) => {
logger.log('warn', `Failed to clean up SmartAcme after startup failure: ${(stopErr as Error).message}`);
});
this.smartAcmeRetryAttempt++;
if (this.smartAcmeRetryAttempt > 20) {
logger.log('error', `SmartAcme DNS-01 provider failed after 20 startup attempts: ${(err as Error).message}`);
return;
}
const baseDelayMs = 5000;
const maxDelayMs = 3_600_000;
const delayMs = Math.min(baseDelayMs * Math.pow(2, this.smartAcmeRetryAttempt - 1), maxDelayMs);
const jitter = 0.8 + Math.random() * 0.4;
const actualDelayMs = Math.floor(delayMs * jitter);
logger.log('warn', `SmartAcme DNS-01 provider startup failed: ${(err as Error).message}; retrying in ${actualDelayMs}ms (attempt ${this.smartAcmeRetryAttempt}/20)`);
this.scheduleSmartAcmeStart(generation, actualDelayMs);
} finally {
if (this.smartAcmeStartPromise === startPromise) {
this.smartAcmeStartPromise = undefined;
}
}
}
private retriggerCertificateProvisioningAfterSmartAcmeReady(): void {
// During startup, certProvisionFunction returns 'http01' while SmartAcme is not ready,
// but Rust ACME is disabled when certProvisionFunction is set. Re-applying routes
// retries provisioning now that DNS-01 is available.
if (this.routeConfigManager) {
logger.log('info', 'Re-triggering certificate provisioning via RouteConfigManager');
this.routeConfigManager.applyRoutes().catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
return;
}
if (this.smartProxy) {
if (this.certProvisionScheduler) {
this.certProvisionScheduler.clear();
}
const currentRoutes = this.smartProxy.routeManager.getRoutes();
logger.log('info', `Re-triggering certificate provisioning for ${currentRoutes.length} routes`);
this.smartProxy.updateRoutes(currentRoutes).catch((err: any) => {
logger.log('warn', `Failed to re-trigger cert provisioning: ${err?.message || err}`);
});
}
}
private clearSmartAcmeRetryTimer(): void {
if (this.smartAcmeRetryTimer) {
clearTimeout(this.smartAcmeRetryTimer);
this.smartAcmeRetryTimer = undefined;
}
}
private async stopSmartAcme(): Promise<void> {
this.smartAcmeStartGeneration++;
this.smartAcmeReady = false;
this.smartAcmeRetryAttempt = 0;
this.clearSmartAcmeRetryTimer();
const smartAcme = this.smartAcme;
if (!smartAcme) {
return;
}
try {
await smartAcme.stop();
} catch (err) {
logger.log('error', 'Error stopping SmartAcme', { error: String(err) });
} finally {
if (this.smartAcme === smartAcme) {
this.smartAcme = undefined;
}
}
}
public async start() {
await this.checkSystemLimits();
logger.log('info', 'Starting DcRouter Services');
@@ -1098,17 +1199,13 @@ export class DcRouter {
// Initialize cert provision scheduler
this.certProvisionScheduler = new CertProvisionScheduler();
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction
// Note: SmartAcme.start() is NOT called here — it runs as a separate optional service
// via the ServiceManager, with aggressive retry for rate-limit resilience.
// If we have DNS challenge handlers, create SmartAcme instance and wire certProvisionFunction.
// SmartAcme starts in the background because ACME account setup can be slow or rate-limited,
// and must not block dcrouter's global startup timeout.
if (this.smartAcme) {
await this.stopSmartAcme();
}
if (challengeHandlers.length > 0) {
// Stop old SmartAcme if it exists (e.g., during updateSmartProxyConfig)
if (this.smartAcme) {
this.smartAcmeReady = false;
await this.smartAcme.stop().catch(err =>
logger.log('error', 'Error stopping old SmartAcme', { error: String(err) })
);
}
// Safe non-null: challengeHandlers.length > 0 implies both dnsManager
// and acmeConfig exist (enforced above).
this.smartAcme = new plugins.smartacme.SmartAcme({
@@ -1118,6 +1215,9 @@ export class DcRouter {
challengeHandlers: challengeHandlers,
challengePriority: ['dns-01'],
});
if (this.smartAcmeServiceStarted) {
this.startSmartAcmeInBackground();
}
const scheduler = this.certProvisionScheduler;
smartProxyConfig.certProvisionFallbackToAcme = false;
@@ -1319,12 +1419,15 @@ export class DcRouter {
}
const firewallConfig = await this.securityPolicyManager.compileRemoteIngressFirewall();
if (this.remoteIngressManager) {
(this.remoteIngressManager as any).setFirewallConfig?.(firewallConfig);
}
if (this.tunnelManager) {
await this.tunnelManager.syncAllowedEdges();
}
await this.queueRemoteIngressHubTask(async () => {
if (this.remoteIngressHubStopping) return;
if (this.remoteIngressManager) {
this.remoteIngressManager.setFirewallConfig(firewallConfig);
}
if (this.tunnelManager) {
await this.tunnelManager.syncAllowedEdges();
}
});
}
private mergeSecurityPolicies(
@@ -2340,28 +2443,180 @@ export class DcRouter {
}
logger.log('info', 'Setting up Remote Ingress hub...');
this.remoteIngressHubStopping = false;
const generation = ++this.remoteIngressHubGeneration;
// Initialize the edge registration manager
this.remoteIngressManager = new RemoteIngressManager();
await this.remoteIngressManager.initialize();
this.remoteIngressManager.setFirewallConfig(
await this.securityPolicyManager?.compileRemoteIngressFirewall(),
);
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;
}
remoteIngressManager.setFirewallConfig(firewallConfig);
// Pass current bootstrap routes so the manager can derive edge ports initially.
// Once RouteConfigManager applies the full DB set, the onRoutesApplied callback
// will push the complete merged routes here.
const bootstrapRoutes = [...this.seedConfigRoutes, ...this.seedEmailRoutes, ...this.runtimeDnsRoutes];
this.remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
remoteIngressManager.setRoutes(bootstrapRoutes as any[]);
// If ConfigManagers finished before us, re-apply routes
// so the callback delivers the full DB set to our newly-created remoteIngressManager.
if (this.routeConfigManager) {
await this.routeConfigManager.applyRoutes();
}
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
return;
}
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
await this.queueRemoteIngressHubTask(async () => {
await this.startRemoteIngressTunnelHubLocked(generation);
});
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
return;
}
const edgeCount = remoteIngressManager.getAllEdges().length;
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
}
private isRemoteIngressHubGenerationCurrent(generation: number, manager: RemoteIngressManager): boolean {
return !this.remoteIngressHubStopping
&& generation === this.remoteIngressHubGeneration
&& this.remoteIngressManager === manager;
}
private queueRemoteIngressHubTask<T>(task: () => Promise<T>): Promise<T> {
const run = this.remoteIngressHubLifecycleChain.then(task);
this.remoteIngressHubLifecycleChain = 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();
}
});
this.remoteIngressManager = undefined;
}
public async mutateRemoteIngressEdges<T>(
mutation: (manager: RemoteIngressManager) => Promise<T>,
syncAllowedEdges = true,
): Promise<T> {
return await this.queueRemoteIngressHubTask(async () => {
if (this.remoteIngressHubStopping) {
throw new Error('RemoteIngress is stopping');
}
const manager = this.remoteIngressManager;
if (!manager) {
throw new Error('RemoteIngress not configured');
}
const result = await mutation(manager);
if (syncAllowedEdges && this.tunnelManager) {
await this.tunnelManager.syncAllowedEdges();
}
return result;
});
}
private async updateRemoteIngressRoutes(routes: IDcRouterRouteConfig[]): Promise<void> {
await this.queueRemoteIngressHubTask(async () => {
if (this.remoteIngressHubStopping) return;
if (this.remoteIngressManager) {
this.remoteIngressManager.setRoutes(routes);
}
if (this.tunnelManager) {
await this.tunnelManager.syncAllowedEdges();
}
});
}
public async updateRemoteIngressHubSettings(
updates: { performance?: IRemoteIngressPerformanceConfig },
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 settings = await this.remoteIngressManager.updateHubSettings(updates, updatedBy);
if (this.options.remoteIngressConfig?.enabled) {
await this.restartRemoteIngressTunnelHubLocked();
}
return settings;
});
}
private async restartRemoteIngressTunnelHubLocked(): Promise<void> {
const generation = ++this.remoteIngressHubGeneration;
if (!this.remoteIngressManager || !this.options.remoteIngressConfig?.enabled || this.remoteIngressHubStopping) {
return;
}
const currentTunnelManager = this.tunnelManager;
this.tunnelManager = undefined;
if (currentTunnelManager) {
await currentTunnelManager.stop();
}
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
return;
}
await this.startRemoteIngressTunnelHubLocked(generation);
}
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) {
return;
}
const tlsConfig = await this.resolveRemoteIngressTlsConfig(riCfg);
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
return;
}
const tunnelManager = new TunnelManager(manager, {
tunnelPort: riCfg.tunnelPort ?? 8443,
targetHost: '127.0.0.1',
tls: tlsConfig,
performance: manager.getHubPerformanceConfig(),
});
try {
await tunnelManager.start();
} catch (err) {
await tunnelManager.stop().catch(() => {});
throw err;
}
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
await tunnelManager.stop();
return;
}
this.tunnelManager = tunnelManager;
}
private async resolveRemoteIngressTlsConfig(
riCfg: NonNullable<IDcRouterOptions['remoteIngressConfig']>,
): 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
@@ -2391,17 +2646,7 @@ export class DcRouter {
logger.log('info', 'No TLS cert configured for RemoteIngress tunnel — using auto-generated self-signed');
}
// Create and start the tunnel manager
this.tunnelManager = new TunnelManager(this.remoteIngressManager, {
tunnelPort: riCfg.tunnelPort ?? 8443,
targetHost: '127.0.0.1',
tls: tlsConfig,
performance: riCfg.performance,
});
await this.tunnelManager.start();
const edgeCount = this.remoteIngressManager.getAllEdges().length;
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
return tlsConfig;
}
/**
+7 -2
View File
@@ -281,6 +281,7 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes source profile and/or network target into the route's fields.
* When a source profile is selected, it owns the route security fully.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -293,10 +294,9 @@ export class ReferenceResolver {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
// Merge: profile provides base, route's inline values override
route = {
...route,
security: this.mergeSecurityFields(resolvedSecurity, route.security),
security: this.cloneSecurityFields(resolvedSecurity),
};
resolvedMetadata.sourceProfileName = profile?.name;
resolvedMetadata.lastResolvedAt = Date.now();
@@ -445,10 +445,15 @@ export class ReferenceResolver {
if (override.authentication !== undefined) merged.authentication = override.authentication;
if (override.basicAuth !== undefined) merged.basicAuth = override.basicAuth;
if (override.jwtAuth !== undefined) merged.jwtAuth = override.jwtAuth;
if (override.vpn !== undefined) merged.vpn = override.vpn;
return merged;
}
private cloneSecurityFields(security: IRouteSecurity): IRouteSecurity {
return structuredClone(security);
}
// =========================================================================
// Private: persistence
// =========================================================================
@@ -175,6 +175,8 @@ export class RouteConfigManager {
return { success: false, message: 'Route not found' };
}
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
&& patch.metadata === undefined;
@@ -216,6 +218,13 @@ export class RouteConfigManager {
...stored.metadata,
...patch.metadata,
});
if (
previousSourceProfileRef
&& !stored.metadata?.sourceProfileRef
&& !patch.route?.security
) {
delete stored.route.security;
}
}
// Re-resolve if metadata refs exist and resolver is available
@@ -0,0 +1,29 @@
import * as plugins from '../../plugins.js';
import { DcRouterDb } from '../classes.dcrouter-db.js';
import type { IRemoteIngressPerformanceConfig } from '../../../ts_interfaces/data/remoteingress.js';
const getDb = () => DcRouterDb.getInstance().getDb();
@plugins.smartdata.Collection(() => getDb())
export class RemoteIngressHubSettingsDoc extends plugins.smartdata.SmartDataDbDoc<RemoteIngressHubSettingsDoc, RemoteIngressHubSettingsDoc> {
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public settingsId: string = 'remote-ingress-hub-settings';
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public updatedAt: number = 0;
@plugins.smartdata.svDb()
public updatedBy: string = '';
constructor() {
super();
}
public static async load(): Promise<RemoteIngressHubSettingsDoc | null> {
return await RemoteIngressHubSettingsDoc.getInstance({ settingsId: 'remote-ingress-hub-settings' });
}
}
+1
View File
@@ -24,6 +24,7 @@ export * from './classes.cert-backoff.doc.js';
// Remote ingress document classes
export * from './classes.remote-ingress-edge.doc.js';
export * from './classes.remote-ingress-hub-settings.doc.js';
// RADIUS document classes
export * from './classes.vlan-mappings.doc.js';
+1 -1
View File
@@ -208,7 +208,7 @@ export class ConfigHandler {
hubDomain: riCfg?.hubDomain || null,
tlsMode,
connectedEdgeIps,
performance: riCfg?.performance,
performance: dcRouter.remoteIngressManager?.getHubPerformanceConfig() || riCfg?.performance,
};
return {
+95 -72
View File
@@ -52,30 +52,21 @@ export class RemoteIngressHandler {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
try {
const edge = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges((manager) => manager.createEdge(
dataArg.name,
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
dataArg.performance,
));
return { success: true, edge };
} catch (err: unknown) {
return {
success: false,
edge: null as any,
};
}
const edge = await manager.createEdge(
dataArg.name,
dataArg.listenPorts || [],
dataArg.tags,
dataArg.autoDerivePorts ?? true,
dataArg.performance,
);
// Sync allowed edges with the hub
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge };
},
),
);
@@ -89,21 +80,18 @@ export class RemoteIngressHandler {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, message: 'RemoteIngress not configured' };
}
const deleted = await manager.deleteEdge(dataArg.id);
if (deleted && tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
const deleted = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(
(manager) => manager.deleteEdge(dataArg.id),
).catch((err: unknown) => {
if ((err as Error).message.includes('RemoteIngress')) {
return false;
}
throw err;
});
return {
success: deleted,
message: deleted ? undefined : 'Edge not found',
message: deleted ? undefined : 'Edge not found or RemoteIngress not configured',
};
},
),
@@ -118,42 +106,42 @@ export class RemoteIngressHandler {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
const result = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(async (manager) => {
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
performance: dataArg.performance,
tags: dataArg.tags,
});
if (!manager) {
return { success: false, edge: null as any };
}
if (!edge) {
return null;
}
const edge = await manager.updateEdge(dataArg.id, {
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
enabled: dataArg.enabled,
performance: dataArg.performance,
tags: dataArg.tags,
});
if (!edge) {
return { success: false, edge: null as any };
}
// Sync allowed edges — ports, tags, or enabled may have changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
const breakdown = manager.getPortBreakdown(edge);
return {
success: true,
edge: {
const breakdown = manager.getPortBreakdown(edge);
return {
...edge,
secret: '********',
effectiveListenPorts: manager.getEffectiveListenPorts(edge),
effectiveListenPortsUdp: manager.getEffectiveListenPortsUdp(edge),
manualPorts: breakdown.manual,
derivedPorts: breakdown.derived,
},
};
}).catch((err: unknown) => {
if ((err as Error).message.includes('RemoteIngress')) {
return null;
}
throw err;
});
if (!result) {
return { success: false, edge: null as any };
}
return {
success: true,
edge: result,
};
},
),
@@ -168,23 +156,18 @@ export class RemoteIngressHandler {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
const tunnelManager = this.opsServerRef.dcRouterRef.tunnelManager;
if (!manager) {
return { success: false, secret: '' };
}
const secret = await manager.regenerateSecret(dataArg.id);
const secret = await this.opsServerRef.dcRouterRef.mutateRemoteIngressEdges(
(manager) => manager.regenerateSecret(dataArg.id),
).catch((err: unknown) => {
if ((err as Error).message.includes('RemoteIngress')) {
return null;
}
throw err;
});
if (!secret) {
return { success: false, secret: '' };
}
// Sync allowed edges since secret changed
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, secret };
},
),
@@ -205,6 +188,46 @@ export class RemoteIngressHandler {
),
);
// Get hub-level settings (read)
viewRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressHubSettings>(
'getRemoteIngressHubSettings',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'remote-ingress:read' });
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
return {
settings: manager?.getHubSettings() || {
updatedAt: 0,
updatedBy: 'default',
},
};
},
),
);
// Update hub-level settings (write)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateRemoteIngressHubSettings>(
'updateRemoteIngressHubSettings',
async (dataArg, toolsArg) => {
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
scope: 'remote-ingress:write',
requireAdminIdentity: true,
});
try {
const settings = await this.opsServerRef.dcRouterRef.updateRemoteIngressHubSettings(
{ performance: dataArg.performance },
auth.userId,
);
return { success: true, settings };
} catch (err: unknown) {
return { success: false, message: (err as Error).message };
}
},
),
);
// Get a connection token for an edge (write — exposes secret)
adminRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetRemoteIngressConnectionToken>(
@@ -1,11 +1,36 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngress, IRemoteIngressPerformanceConfig, IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc } from '../db/index.js';
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
interface IRemoteIngressFirewallConfig {
blockedIps?: string[];
}
type TPerformanceIntegerField =
| 'maxStreamsPerEdge'
| 'totalWindowBudgetBytes'
| 'minStreamWindowBytes'
| 'maxStreamWindowBytes'
| 'sustainedStreamWindowBytes'
| 'quicDatagramReceiveBufferBytes'
| 'streamFramePayloadBytes'
| 'firstDataConnectTimeoutMs'
| 'clientWriteTimeoutMs';
const performanceIntegerMaxByField: Record<TPerformanceIntegerField, number> = {
maxStreamsPerEdge: 100_000,
totalWindowBudgetBytes: 1_073_741_824,
minStreamWindowBytes: 16_777_216,
maxStreamWindowBytes: 134_217_728,
sustainedStreamWindowBytes: 134_217_728,
quicDatagramReceiveBufferBytes: 67_108_864,
streamFramePayloadBytes: 16_777_216,
firstDataConnectTimeoutMs: 3_600_000,
clientWriteTimeoutMs: 3_600_000,
};
const maxServerFirstPorts = 128;
function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
return [...ports].sort((a, b) => a - b);
@@ -20,8 +45,12 @@ export class RemoteIngressManager {
private edges: Map<string, IRemoteIngress> = new Map();
private routes: IDcRouterRouteConfig[] = [];
private firewallConfig?: IRemoteIngressFirewallConfig;
private hubSettings: IRemoteIngressHubSettings = {
updatedAt: 0,
updatedBy: 'default',
};
constructor() {
constructor(private seedHubPerformance?: IRemoteIngressPerformanceConfig) {
}
/**
@@ -50,6 +79,28 @@ export class RemoteIngressManager {
};
this.edges.set(edge.id, edge);
}
await this.initializeHubSettings();
}
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();
}
}
this.hubSettings = doc ? this.toHubSettings(doc) : {
updatedAt: 0,
updatedBy: 'default',
};
}
/**
@@ -66,6 +117,38 @@ export class RemoteIngressManager {
this.firewallConfig = firewallConfig;
}
public getHubSettings(): IRemoteIngressHubSettings {
return {
...this.hubSettings,
performance: this.hubSettings.performance ? { ...this.hubSettings.performance } : undefined,
};
}
public getHubPerformanceConfig(): IRemoteIngressPerformanceConfig | undefined {
return this.hubSettings.performance && Object.keys(this.hubSettings.performance).length > 0
? { ...this.hubSettings.performance }
: undefined;
}
public async updateHubSettings(
updates: { performance?: IRemoteIngressPerformanceConfig },
updatedBy: string,
): Promise<IRemoteIngressHubSettings> {
let doc = await RemoteIngressHubSettingsDoc.load();
if (!doc) {
doc = new RemoteIngressHubSettingsDoc();
doc.settingsId = 'remote-ingress-hub-settings';
}
doc.performance = this.normalizePerformanceConfig(updates.performance);
doc.updatedAt = Date.now();
doc.updatedBy = updatedBy;
await doc.save();
this.hubSettings = this.toHubSettings(doc);
return this.getHubSettings();
}
/**
* Derive listen ports for an edge from routes tagged with remoteIngress.enabled.
* When a route specifies edgeFilter, only edges whose id or tags match get that route's ports.
@@ -324,4 +407,90 @@ export class RemoteIngressManager {
}
return result;
}
private normalizePerformanceConfig(
performance?: IRemoteIngressPerformanceConfig,
): IRemoteIngressPerformanceConfig | undefined {
if (!performance) {
return undefined;
}
const next: IRemoteIngressPerformanceConfig = {};
const validProfiles: TRemoteIngressPerformanceProfile[] = ['balanced', 'throughput', 'highConcurrency'];
if (performance.profile !== undefined) {
if (!validProfiles.includes(performance.profile)) {
throw new Error('Invalid RemoteIngress performance profile');
}
next.profile = performance.profile;
}
const assignPositiveInteger = (field: TPerformanceIntegerField) => {
const value = performance[field];
if (value === undefined) {
return;
}
const maxValue = performanceIntegerMaxByField[field];
if (!Number.isSafeInteger(value) || value < 1 || value > maxValue) {
throw new Error(`${field} must be a positive safe integer no greater than ${maxValue}`);
}
(next as Record<string, number>)[field] = value;
};
assignPositiveInteger('maxStreamsPerEdge');
assignPositiveInteger('totalWindowBudgetBytes');
assignPositiveInteger('minStreamWindowBytes');
assignPositiveInteger('maxStreamWindowBytes');
assignPositiveInteger('sustainedStreamWindowBytes');
assignPositiveInteger('quicDatagramReceiveBufferBytes');
assignPositiveInteger('streamFramePayloadBytes');
assignPositiveInteger('firstDataConnectTimeoutMs');
assignPositiveInteger('clientWriteTimeoutMs');
if (
next.minStreamWindowBytes !== undefined
&& next.maxStreamWindowBytes !== undefined
&& next.minStreamWindowBytes > next.maxStreamWindowBytes
) {
throw new Error('minStreamWindowBytes must not exceed maxStreamWindowBytes');
}
if (
next.sustainedStreamWindowBytes !== undefined
&& next.maxStreamWindowBytes !== undefined
&& next.sustainedStreamWindowBytes > next.maxStreamWindowBytes
) {
throw new Error('sustainedStreamWindowBytes must not exceed maxStreamWindowBytes');
}
const configuredServerFirstPorts = performance.serverFirstPorts;
if (configuredServerFirstPorts !== undefined) {
if (!Array.isArray(configuredServerFirstPorts)) {
throw new Error('serverFirstPorts must contain valid port numbers');
}
if (configuredServerFirstPorts.length > maxServerFirstPorts) {
throw new Error(`serverFirstPorts must contain at most ${maxServerFirstPorts} ports`);
}
const serverFirstPorts = [...new Set(configuredServerFirstPorts.map((port) => Number(port)))].sort((a, b) => a - b);
for (const port of serverFirstPorts) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('serverFirstPorts must contain valid port numbers');
}
if (port === 443) {
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
}
}
if (serverFirstPorts.length > 0) {
next.serverFirstPorts = serverFirstPorts;
}
}
return Object.keys(next).length > 0 ? next : undefined;
}
private toHubSettings(doc: RemoteIngressHubSettingsDoc): IRemoteIngressHubSettings {
return {
performance: doc.performance,
updatedAt: doc.updatedAt,
updatedBy: doc.updatedBy,
};
}
}
+39 -12
View File
@@ -22,6 +22,8 @@ export class TunnelManager {
private edgeStatuses: Map<string, IRemoteIngressStatus> = new Map();
private reconcileInterval: ReturnType<typeof setInterval> | null = null;
private syncChain: Promise<void> = Promise.resolve();
private reconcileChain: Promise<void> = Promise.resolve();
private stopped = true;
constructor(manager: RemoteIngressManager, config: ITunnelManagerConfig = {}) {
this.manager = manager;
@@ -64,30 +66,51 @@ export class TunnelManager {
* Start the tunnel hub and load allowed edges.
*/
public async start(): Promise<void> {
await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
...(this.config.performance ? { performance: this.config.performance } : {}),
} as any);
this.stopped = false;
try {
await this.hub.start({
tunnelPort: this.config.tunnelPort ?? 8443,
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
...(this.config.performance ? { performance: this.config.performance } : {}),
} as any);
// Send allowed edges to the hub
await this.syncAllowedEdges();
if (this.stopped) return;
// Periodically reconcile with authoritative Rust hub status
this.reconcileInterval = setInterval(() => {
this.reconcile().catch(() => {});
}, 15_000);
// Send allowed edges to the hub
await this.syncAllowedEdges();
if (this.stopped) return;
// Periodically reconcile with authoritative Rust hub status
this.reconcileInterval = setInterval(() => {
this.reconcileChain = this.reconcileChain
.catch(() => {})
.then(() => this.reconcile());
this.reconcileChain.catch(() => {});
}, 15_000);
} catch (err) {
await this.stop();
throw err;
}
}
/**
* Stop the tunnel hub.
*/
public async stop(): Promise<void> {
if (this.stopped) {
return;
}
this.stopped = true;
if (this.reconcileInterval) {
clearInterval(this.reconcileInterval);
this.reconcileInterval = null;
}
await Promise.all([
this.syncChain.catch(() => {}),
this.reconcileChain.catch(() => {}),
]);
// Remove event listeners before stopping to prevent leaks
this.hub.removeAllListeners();
await this.hub.stop();
@@ -99,7 +122,9 @@ export class TunnelManager {
* Overwrites event-derived activeTunnels with the real activeStreams count.
*/
private async reconcile(): Promise<void> {
if (this.stopped) return;
const hubStatus = await this.hub.getStatus();
if (this.stopped) return;
if (!hubStatus || !hubStatus.connectedEdges) return;
const rustEdgeIds = new Set<string>();
@@ -144,7 +169,9 @@ export class TunnelManager {
*/
public async syncAllowedEdges(): Promise<void> {
const run = this.syncChain.catch(() => {}).then(async () => {
if (this.stopped) return;
const edges = this.manager.getAllowedEdges();
if (this.stopped) return;
await this.hub.updateAllowedEdges(edges as any);
});
this.syncChain = run;
+6
View File
@@ -63,6 +63,12 @@ export interface IRemoteIngressPerformanceConfig {
serverFirstPorts?: number[];
}
export interface IRemoteIngressHubSettings {
performance?: IRemoteIngressPerformanceConfig;
updatedAt: number;
updatedBy: string;
}
export interface IRemoteIngressPerformanceEffective {
profile: TRemoteIngressPerformanceProfile;
maxStreamsPerEdge: number;
+38 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IRemoteIngress, IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../data/remoteingress.js';
import type { IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../data/remoteingress.js';
// ============================================================================
// Remote Ingress Edge Management
@@ -147,3 +147,40 @@ export interface IReq_GetRemoteIngressConnectionToken extends plugins.typedreque
message?: string;
};
}
/**
* Get hub-level RemoteIngress settings.
*/
export interface IReq_GetRemoteIngressHubSettings extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetRemoteIngressHubSettings
> {
method: 'getRemoteIngressHubSettings';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
settings: IRemoteIngressHubSettings;
};
}
/**
* Update hub-level RemoteIngress settings.
*/
export interface IReq_UpdateRemoteIngressHubSettings extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_UpdateRemoteIngressHubSettings
> {
method: 'updateRemoteIngressHubSettings';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
performance?: IRemoteIngressPerformanceConfig;
};
response: {
success: boolean;
settings?: IRemoteIngressHubSettings;
message?: string;
};
}
+131
View File
@@ -19,6 +19,131 @@ export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>;
}
type TMigrationSecurity = Record<string, any>;
function mergeMigrationSecurityFields(
base: TMigrationSecurity | undefined,
override: TMigrationSecurity | undefined,
): TMigrationSecurity {
if (!base && !override) return {};
if (!base) return structuredClone(override || {});
if (!override) return structuredClone(base || {});
const merged: TMigrationSecurity = structuredClone(base);
if (override.ipAllowList || base.ipAllowList) {
merged.ipAllowList = [
...new Set([
...(base.ipAllowList || []),
...(override.ipAllowList || []),
]),
];
}
if (override.ipBlockList || base.ipBlockList) {
merged.ipBlockList = [
...new Set([
...(base.ipBlockList || []),
...(override.ipBlockList || []),
]),
];
}
for (const key of ['maxConnections', 'rateLimit', 'authentication', 'basicAuth', 'jwtAuth', 'vpn']) {
if (override[key] !== undefined) {
merged[key] = structuredClone(override[key]);
}
}
return merged;
}
function resolveMigrationSourceProfileSecurity(
profileId: string,
profiles: Map<string, any>,
visited = new Set<string>(),
depth = 0,
): TMigrationSecurity | null {
if (depth > 5 || visited.has(profileId)) return null;
const profile = profiles.get(profileId);
if (!profile) return null;
visited.add(profileId);
let baseSecurity: TMigrationSecurity = {};
const extendsProfiles = Array.isArray(profile.extendsProfiles) ? profile.extendsProfiles : [];
for (const parentId of extendsProfiles) {
if (typeof parentId !== 'string') continue;
const parentSecurity = resolveMigrationSourceProfileSecurity(
parentId,
profiles,
new Set(visited),
depth + 1,
);
if (parentSecurity) {
baseSecurity = mergeMigrationSecurityFields(baseSecurity, parentSecurity);
}
}
return mergeMigrationSecurityFields(baseSecurity, profile.security || {});
}
async function rematerializeSourceProfileRouteSecurity(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
const routeCollection = ctx.mongo!.collection('RouteDoc');
const profiles = new Map<string, any>();
for await (const profile of profileCollection.find({})) {
if (typeof (profile as any).id === 'string') {
profiles.set((profile as any).id, profile);
}
}
let inspected = 0;
let migrated = 0;
let skippedMissingProfile = 0;
const now = Date.now();
for await (const routeDoc of routeCollection.find({})) {
const sourceProfileRef = (routeDoc as any).metadata?.sourceProfileRef;
if (typeof sourceProfileRef !== 'string' || sourceProfileRef.trim() === '') continue;
inspected++;
const resolvedSecurity = resolveMigrationSourceProfileSecurity(sourceProfileRef, profiles);
const profile = profiles.get(sourceProfileRef);
if (!resolvedSecurity || !profile) {
skippedMissingProfile++;
continue;
}
const currentSecurity = (routeDoc as any).route?.security || {};
const securityChanged = JSON.stringify(currentSecurity) !== JSON.stringify(resolvedSecurity);
const profileNameChanged = (routeDoc as any).metadata?.sourceProfileName !== profile.name;
if (!securityChanged && !profileNameChanged) continue;
const query = (routeDoc as any)._id
? { _id: (routeDoc as any)._id }
: { id: (routeDoc as any).id };
await routeCollection.updateOne(query, {
$set: {
'route.security': structuredClone(resolvedSecurity),
'metadata.sourceProfileName': profile.name,
'metadata.lastResolvedAt': now,
updatedAt: now,
},
});
migrated++;
}
ctx.log.log(
'info',
`rematerialize-source-profile-route-security: migrated ${migrated}/${inspected} route(s), skipped ${skippedMissingProfile} missing profile ref(s)`,
);
}
async function migrateTargetProfileTargetHosts(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
@@ -167,6 +292,12 @@ export async function createMigrationRunner(
.description('Backfill RouteDoc.systemKey for persisted config/email/dns routes')
.up(async (ctx) => {
await backfillSystemRouteKeys(ctx);
})
.step('rematerialize-source-profile-route-security')
.from('13.18.0').to('13.40.2')
.description('Replace stale route security with resolved source profile security')
.up(async (ctx) => {
await rematerializeSourceProfileRouteSecurity(ctx);
});
return migration;
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.40.0',
version: '13.41.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+41 -1
View File
@@ -260,6 +260,7 @@ export const acmeConfigStatePart = await appState.getStatePart<IAcmeConfigState>
export interface IRemoteIngressState {
edges: interfaces.data.IRemoteIngress[];
statuses: interfaces.data.IRemoteIngressStatus[];
hubSettings: interfaces.data.IRemoteIngressHubSettings | null;
selectedEdgeId: string | null;
newEdgeId: string | null;
isLoading: boolean;
@@ -272,6 +273,7 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
{
edges: [],
statuses: [],
hubSettings: null,
selectedEdgeId: null,
newEdgeId: null,
isLoading: false,
@@ -1094,15 +1096,21 @@ export const fetchRemoteIngressAction = remoteIngressStatePart.createAction(asyn
interfaces.requests.IReq_GetRemoteIngressStatus
>('/typedrequest', 'getRemoteIngressStatus');
const [edgesResponse, statusResponse] = await Promise.all([
const hubSettingsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetRemoteIngressHubSettings
>('/typedrequest', 'getRemoteIngressHubSettings');
const [edgesResponse, statusResponse, hubSettingsResponse] = await Promise.all([
edgesRequest.fire({ identity: context.identity }),
statusRequest.fire({ identity: context.identity }),
hubSettingsRequest.fire({ identity: context.identity }),
]);
return {
...currentState,
edges: edgesResponse.edges,
statuses: statusResponse.statuses,
hubSettings: hubSettingsResponse.settings,
isLoading: false,
error: null,
lastUpdated: Date.now(),
@@ -1219,6 +1227,38 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
}
});
export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.createAction<{
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateRemoteIngressHubSettings
>('/typedrequest', 'updateRemoteIngressHubSettings');
const response = await request.fire({
identity: context.identity!,
performance: dataArg.performance,
});
if (!response.success) {
return {
...currentState,
error: response.message || 'Failed to update RemoteIngress hub settings',
};
}
return await actionContext!.dispatch(fetchRemoteIngressAction, null);
} catch (error: unknown) {
return {
...currentState,
error: error instanceof Error ? error.message : 'Failed to update RemoteIngress hub settings',
};
}
});
export const regenerateRemoteIngressSecretAction = remoteIngressStatePart.createAction<string>(
async (statePartArg, edgeId): Promise<IRemoteIngressState> => {
const context = getActionContext();
@@ -12,6 +12,17 @@ import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
const performanceProfileOptions = [
{ key: '', option: 'Default' },
{ key: 'balanced', option: 'Balanced' },
{ key: 'throughput', option: 'Throughput' },
{ key: 'highConcurrency', option: 'High concurrency' },
];
function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
declare global {
interface HTMLElementTagNameMap {
'ops-view-remoteingress': OpsViewRemoteIngress;
@@ -137,6 +148,13 @@ export class OpsViewRemoteIngress extends DeesElement {
.metricMuted {
color: var(--text-muted, #6b7280);
}
.settingsNote {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.5;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
`,
];
@@ -308,6 +326,14 @@ export class OpsViewRemoteIngress extends DeesElement {
});
},
},
{
name: 'Hub Settings',
iconName: 'lucide:slidersHorizontal',
type: ['header' as const],
actionFunc: async () => {
await this.showHubSettingsDialog();
},
},
{
name: 'Enable',
iconName: 'lucide:play',
@@ -591,4 +617,142 @@ export class OpsViewRemoteIngress extends DeesElement {
return base ? {} : undefined;
}
private async showHubSettingsDialog(): Promise<void> {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const performance = this.riState.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()
: 'not persisted yet';
await DeesModal.createAndShow({
heading: 'RemoteIngress Hub Settings',
content: html`
<dees-form>
<dees-input-dropdown
.key=${'profile'}
.label=${'Performance Profile'}
.options=${performanceProfileOptions}
.selectedOption=${selectedProfile}
></dees-input-dropdown>
<dees-input-text
.key=${'maxStreamsPerEdge'}
.label=${'Max Connections / Edge'}
.description=${'Maximum concurrent client streams per edge. Leave empty for RemoteIngress defaults.'}
.value=${performance.maxStreamsPerEdge?.toString() || ''}
></dees-input-text>
<dees-input-text
.key=${'clientWriteTimeoutMs'}
.label=${'Client Write Timeout'}
.description=${'Milliseconds before idle client writes are timed out. Leave empty for default.'}
.value=${performance.clientWriteTimeoutMs?.toString() || ''}
></dees-input-text>
<dees-input-text
.key=${'firstDataConnectTimeoutMs'}
.label=${'First Data Timeout'}
.description=${'Milliseconds to wait for initial client data before connecting upstream. Leave empty for default.'}
.value=${performance.firstDataConnectTimeoutMs?.toString() || ''}
></dees-input-text>
<dees-input-text
.key=${'serverFirstPorts'}
.label=${'Server-first Ports'}
.description=${'Comma-separated ports such as 21, 22, 25, 110, 143, 587. Do not include 443.'}
.value=${(performance.serverFirstPorts || []).join(', ')}
></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'}.
</p>
`,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Save',
iconName: 'lucide:check',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const formData = await form.collectFormData();
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
try {
performanceSettings = this.collectHubPerformanceSettings(formData);
} catch (err: unknown) {
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
return;
}
const nextState = await appstate.remoteIngressStatePart.dispatchAction(
appstate.updateRemoteIngressHubSettingsAction,
{ performance: performanceSettings },
);
if (nextState.error) {
DeesToast.show({ message: nextState.error, type: 'error', duration: 4000 });
return;
}
await modalArg.destroy();
DeesToast.show({ message: 'RemoteIngress hub settings saved', type: 'success', duration: 3000 });
},
},
],
});
}
private collectHubPerformanceSettings(formData: Record<string, any>): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
const next: interfaces.data.IRemoteIngressPerformanceConfig = {};
const profile = getDropdownKey(formData.profile) as interfaces.data.TRemoteIngressPerformanceProfile | '';
if (profile) {
next.profile = 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');
const serverFirstPorts = this.parsePortList(formData.serverFirstPorts, 'Server-first Ports');
if (serverFirstPorts.length > 0) {
if (serverFirstPorts.includes(443)) {
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
}
next.serverFirstPorts = serverFirstPorts;
}
return Object.keys(next).length > 0 ? next : undefined;
}
private assignPositiveIntegerSetting(
target: interfaces.data.IRemoteIngressPerformanceConfig,
key: 'maxStreamsPerEdge' | 'clientWriteTimeoutMs' | 'firstDataConnectTimeoutMs',
value: any,
label: string,
): void {
const text = `${value || ''}`.trim();
if (!text) {
return;
}
const parsed = Number.parseInt(text, 10);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`${label} must be a positive integer`);
}
target[key] = parsed;
}
private parsePortList(value: any, label: string): number[] {
const text = `${value || ''}`.trim();
if (!text) {
return [];
}
const ports = text.split(',').map((part) => Number.parseInt(part.trim(), 10));
for (const port of ports) {
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label} must contain valid port numbers`);
}
}
return [...new Set(ports)].sort((a, b) => a - b);
}
}