Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a381df937 | |||
| 38e2f3cee1 | |||
| 4a47460bf1 | |||
| 3679cba3a4 | |||
| 3dc0371f7e | |||
| b212662764 | |||
| 776c65a18c | |||
| 5f6ec63770 |
@@ -3,17 +3,42 @@
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.40.2",
|
||||
"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.12.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.2",
|
||||
"@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",
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.40.2",
|
||||
"version": "13.41.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -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.2",
|
||||
"@serve.zone/remoteingress": "^4.22.4",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
|
||||
Generated
+10
-10
@@ -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.2
|
||||
version: 4.22.2
|
||||
specifier: ^4.22.4
|
||||
version: 4.22.4
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -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.2':
|
||||
resolution: {integrity: sha512-fE7dQkhHZqNHztu5dTy91CuM+e9NBlYvB0iJRV/4MiSP/5z+SBiwyAmR1i6RTHoLogj/XCPcwy8N+mSbb2oI9A==}
|
||||
'@serve.zone/remoteingress@4.22.4':
|
||||
resolution: {integrity: sha512-3SPTlFQQlB7ptdUr0TzZJQ1UOppPWcjcffv25qpO64gzw5f5VhmkywN7YQGAeXqCe4UeuRZrxOwZY0m9SpfJzw==}
|
||||
hasBin: true
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.2.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.2':
|
||||
'@serve.zone/remoteingress@4.22.4':
|
||||
dependencies:
|
||||
'@push.rocks/qenv': 6.1.4
|
||||
'@push.rocks/smartnftables': 1.2.0
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.40.2',
|
||||
version: '13.41.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+329
-84
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -208,7 +208,7 @@ export class ConfigHandler {
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: riCfg?.performance,
|
||||
performance: dcRouter.remoteIngressManager?.getHubPerformanceConfig() || riCfg?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.40.2',
|
||||
version: '13.41.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+41
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user