Compare commits

...

25 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
jkunz 7f7a26fb38 v13.40.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Failing after 8m31s
2026-05-30 19:57:32 +00:00
jkunz a089b681c4 feat(monitoring-opsserver-radius): use active connection snapshots for proxy metrics and RADIUS network secrets 2026-05-30 19:57:09 +00:00
jkunz 3e71301bf5 v13.39.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m54s
2026-05-30 18:09:42 +00:00
jkunz 58cc8c0753 feat(remoteingress,radius): add remote ingress performance overrides and update RADIUS integration 2026-05-30 18:09:18 +00:00
jkunz e279814803 v13.38.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m11s
2026-05-30 15:05:32 +00:00
jkunz 6bee2eb172 fix(deps): bump @serve.zone/remoteingress to ^4.22.1 2026-05-30 15:05:16 +00:00
jkunz db8ea99e88 v13.38.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m19s
2026-05-30 13:19:15 +00:00
jkunz 98ccf82af0 fix(deps): update @serve.zone/remoteingress to ^4.22.0 2026-05-30 13:18:48 +00:00
jkunz 0f99525612 v13.38.2
Docker (tags) / release (push) Failing after 16m7s
Release / build-and-release (push) Failing after 14m45s
2026-05-30 11:40:28 +00:00
jkunz 8e707d9c4d fix(deps): bump @serve.zone/remoteingress to ^4.21.1 2026-05-30 11:40:00 +00:00
jkunz 418c825b01 v13.38.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 8m58s
2026-05-30 10:35:31 +00:00
jkunz 75f29af27f fix(deps): update @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:35:02 +00:00
jkunz 4467fe629a fix(deps): bump @serve.zone/remoteingress to ^4.21.0 2026-05-30 10:31:37 +00:00
29 changed files with 1662 additions and 476 deletions
+101
View File
@@ -3,9 +3,110 @@
## 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
### Features
- use active connection snapshots for proxy metrics and RADIUS network secrets (monitoring-opsserver-radius)
- Add cached SmartProxy active connection snapshots for connection info and network statistics.
- Report ops security active connections from per-connection snapshots with protocol, state, and byte counters.
- Configure RADIUS clients through smartradius network secrets, including CIDR ranges, and forward additional RADIUS attributes.
- Bump smartproxy to ^27.12.1 and smartradius to ^1.3.0.
## 2026-05-30 - 13.39.0
### Features
- add remote ingress performance overrides and update RADIUS integration (remoteingress,radius)
- Persist and propagate optional remote ingress performance overrides through remote ingress create/update APIs, database documents, and hub allowed-edge sync.
- Add web UI controls and status display for per-edge maximum connection overrides.
- Extend remote ingress performance interfaces with stream payload, timeout, and server-first port settings.
- Update RADIUS server integration for smartradius 1.2 request/response handling and client secret resolution, including CIDR matching.
## 2026-05-30 - 13.38.4
### Fixes
- bump @serve.zone/remoteingress to ^4.22.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json.
## 2026-05-30 - 13.38.3
### Fixes
- update @serve.zone/remoteingress to ^4.22.0 (deps)
- Updated @serve.zone/remoteingress from ^4.21.1 to ^4.22.0 in package.json and deno.json.
## 2026-05-30 - 13.38.2
### Fixes
- bump @serve.zone/remoteingress to ^4.21.1 (deps)
- Updated @serve.zone/remoteingress in package.json and deno.json from ^4.21.0 to ^4.21.1.
## 2026-05-30 - 13.38.1
### Fixes
- bump @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
- update @serve.zone/remoteingress to ^4.21.0 (deps)
- Updates the Deno import mapping for @serve.zone/remoteingress from ^4.18.0 to ^4.21.0.
## 2026-05-29 - 13.38.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.38.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.18.0",
"@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",
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.38.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,8 +61,8 @@
"@push.rocks/smartnetwork": "^4.7.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartproxy": "^27.11.1",
"@push.rocks/smartradius": "^1.1.2",
"@push.rocks/smartproxy": "^27.12.3",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1",
@@ -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.18.0",
"@serve.zone/remoteingress": "^4.22.4",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.4.0",
+28 -27
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,11 +84,11 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.11.1
version: 27.11.1
specifier: ^27.12.3
version: 27.12.3
'@push.rocks/smartradius':
specifier: ^1.1.2
version: 1.1.2
specifier: ^1.3.0
version: 1.3.0
'@push.rocks/smartrequest':
specifier: ^5.0.3
version: 5.0.3
@@ -114,8 +114,8 @@ importers:
specifier: ^5.8.0
version: 5.8.0
'@serve.zone/remoteingress':
specifier: ^4.18.0
version: 4.18.0
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,14 +1429,14 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.11.1':
resolution: {integrity: sha512-29THhFUTr9NtU1/UBqqOgcbsHcUMHj7Dhh2XfXp6NP/rfDGUFiFFmCNcAdC3OJ0n6BgwTBOtOzo+4rJbrGJRpw==}
'@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==}
'@push.rocks/smartradius@1.1.2':
resolution: {integrity: sha512-p4fHhMgXZRuyRuMQjFQLVnXBG1Fz2latJ7BGAsfInOuVUaitBr/Wni9mZULAuIIddeWwUx9QvIGlv3tgmFn/ow==}
'@push.rocks/smartradius@1.3.0':
resolution: {integrity: sha512-97BQhVT5gdDTNfb8LZiqaPddTMlx5Eqpsj7jTBQ2kj4tYpK0YWRiKkpBxxEXTjsIsq7iTxHeNTwc8kMZj+yU3g==}
'@push.rocks/smartrequest@2.1.0':
resolution: {integrity: sha512-3eHLTRInHA+u+W98TqJwgTES7rRimBAsJC4JxVNQC3UUezmblAhM5/TIQsEBQTsbjAY8SeQKy6NHzW6iTiaD8w==}
@@ -1719,8 +1719,9 @@ packages:
'@serve.zone/interfaces@5.8.0':
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
'@serve.zone/remoteingress@4.18.0':
resolution: {integrity: sha512-/cW9wb/e57u9+715RzV5d8HCezWtR88LcpistTNSl7GACi5ai+C2tPy7ZQprnnrNhqjfgzWiAH4bKZafwONntg==}
'@serve.zone/remoteingress@4.22.4':
resolution: {integrity: sha512-3SPTlFQQlB7ptdUr0TzZJQ1UOppPWcjcffv25qpO64gzw5f5VhmkywN7YQGAeXqCe4UeuRZrxOwZY0m9SpfJzw==}
hasBin: true
'@smithy/chunked-blob-reader-native@4.2.3':
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
@@ -5250,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
@@ -5326,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
@@ -6135,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
@@ -6611,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
@@ -6674,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)
@@ -6695,7 +6696,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.11.1':
'@push.rocks/smartproxy@27.12.3':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -6719,7 +6720,7 @@ snapshots:
- typescript
- utf-8-validate
'@push.rocks/smartradius@1.1.2':
'@push.rocks/smartradius@1.3.0':
dependencies:
'@push.rocks/smartdelay': 3.1.0
'@push.rocks/smartpromise': 4.2.4
@@ -7084,7 +7085,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/remoteingress@4.18.0':
'@serve.zone/remoteingress@4.22.4':
dependencies:
'@push.rocks/qenv': 6.1.4
'@push.rocks/smartnftables': 1.2.0
@@ -14,6 +14,38 @@ const emptyProtocolDistribution = {
otherTotal: 0,
};
function createActiveConnectionSnapshots(entries: Array<{
count: number;
sourceIp?: string;
routeId?: string;
domain?: string;
localPort?: number;
}>) {
const snapshots: any[] = [];
let index = 0;
for (const entry of entries) {
for (let i = 0; i < entry.count; i++) {
snapshots.push({
id: `test-connection-${index++}`,
sourceIp: entry.sourceIp || '192.0.2.10',
sourcePort: 40000 + index,
localPort: entry.localPort || 443,
domain: entry.domain,
routeId: entry.routeId,
targetHost: '127.0.0.1',
targetPort: 8443,
protocol: 'https',
state: 'active',
startedAtMs: Date.now(),
ageMs: 0,
bytesIn: 0,
bytesOut: 0,
});
}
}
return snapshots;
}
function createProxyMetrics(args: {
connectionsByRoute: Map<string, number>;
throughputByRoute: Map<string, { in: number; out: number }>;
@@ -90,6 +122,10 @@ tap.test('MetricsManager joins domain activity to id-keyed route metrics', async
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 3, routeId: 'route-id-only', domain: 'alpha.example.com' },
{ count: 1, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
routeManager: {
getRoutes: () => [
{
@@ -150,6 +186,9 @@ tap.test('MetricsManager prefers live domain request rates for current activity'
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 10, routeId: 'route-id-only', domain: 'beta.example.com' },
]),
routeManager: {
getRoutes: () => [
{
@@ -231,6 +270,7 @@ tap.test('MetricsManager does not duplicate backend active counts onto protocol
const smartProxy = {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => [],
routeManager: {
getRoutes: () => [],
},
@@ -265,6 +305,10 @@ tap.test('MetricsManager queues IP intelligence without awaiting enrichment', as
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 2, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
@@ -300,6 +344,11 @@ tap.test('MetricsManager aggregates top ASNs from IP intelligence', async () =>
const manager = new MetricsManager({
smartProxy: {
getMetrics: () => proxyMetrics,
getActiveConnectionSnapshots: () => createActiveConnectionSnapshots([
{ count: 4, sourceIp: '8.8.8.8' },
{ count: 3, sourceIp: '8.8.4.4' },
{ count: 5, sourceIp: '1.1.1.1' },
]),
routeManager: { getRoutes: () => [] },
},
securityPolicyManager: {
+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.38.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
@@ -1,5 +1,6 @@
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();
@@ -27,6 +28,9 @@ export class RemoteIngressEdgeDoc extends plugins.smartdata.SmartDataDbDoc<Remot
@plugins.smartdata.svDb()
public autoDerivePorts!: boolean;
@plugins.smartdata.svDb()
public performance?: IRemoteIngressPerformanceConfig;
@plugins.smartdata.svDb()
public tags!: string[];
@@ -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';
+2 -18
View File
@@ -1,4 +1,4 @@
import type * as plugins from '../plugins.js';
import * as plugins from '../plugins.js';
/**
* Configuration for HTTP/3 (QUIC) route augmentation.
@@ -36,22 +36,6 @@ export interface IHttp3Config {
};
}
type TPortRange = plugins.smartproxy.IRouteConfig['match']['ports'];
/**
* Check whether a TPortRange includes port 443.
*/
function portRangeIncludes443(ports: TPortRange): boolean {
if (typeof ports === 'number') return ports === 443;
if (Array.isArray(ports)) {
return ports.some((p) => {
if (typeof p === 'number') return p === 443;
return p.from <= 443 && p.to >= 443;
});
}
return false;
}
/**
* Check if a route name indicates an email route that should not get HTTP/3.
*/
@@ -85,7 +69,7 @@ export function routeQualifiesForHttp3(
if (route.action.type !== 'forward') return false;
// Must include port 443
if (!portRangeIncludes443(route.match.ports)) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
// Must have TLS
if (!route.action.tls) return false;
+59 -21
View File
@@ -143,8 +143,9 @@ export class MetricsManager {
public async getServerStats() {
return this.metricsCache.get('serverStats', async () => {
const smartMetricsData = await this.smartMetrics.getMetrics();
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const proxyStats = this.dcRouter.smartProxy ? await this.dcRouter.smartProxy.getStatistics() : null;
const smartProxy = this.dcRouter.smartProxy;
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
const proxyStats = smartProxy ? await smartProxy.getStatistics() : null;
const { heapUsed, heapTotal, external, rss } = process.memoryUsage();
return {
@@ -291,27 +292,44 @@ export class MetricsManager {
});
}
public async getActiveConnectionSnapshots(
options: plugins.smartproxy.IActiveConnectionSnapshotOptions = {},
): Promise<plugins.smartproxy.IActiveConnectionSnapshot[]> {
const cacheKey = `activeConnectionSnapshots:${options.limit ?? 1000}:${options.routeId ?? ''}`;
return await this.metricsCache.get<plugins.smartproxy.IActiveConnectionSnapshot[]>(cacheKey, async () => {
if (!this.dcRouter.smartProxy) {
return [];
}
return this.dcRouter.smartProxy.getActiveConnectionSnapshots(options);
}, 500);
}
// Get connection info from SmartProxy
public async getConnectionInfo() {
return this.metricsCache.get('connectionInfo', () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return [] as Array<{ type: string; count: number; source: string; lastActivity: Date }>;
return this.metricsCache.get('connectionInfo', async () => {
const snapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
const connectionsByRoute = new Map<string, { count: number; lastActivity: Date }>();
for (const snapshot of snapshots) {
const source = snapshot.routeId || snapshot.domain || `${snapshot.protocol || 'connection'}:${snapshot.localPort}`;
const existing = connectionsByRoute.get(source) || { count: 0, lastActivity: new Date(snapshot.startedAtMs) };
existing.count++;
if (snapshot.startedAtMs > existing.lastActivity.getTime()) {
existing.lastActivity = new Date(snapshot.startedAtMs);
}
connectionsByRoute.set(source, existing);
}
const connectionsByRoute = proxyMetrics.connections.byRoute();
const connectionInfo: Array<{ type: string; count: number; source: string; lastActivity: Date }> = [];
for (const [routeName, count] of connectionsByRoute) {
for (const [source, info] of connectionsByRoute) {
connectionInfo.push({
type: 'https',
count,
source: routeName,
lastActivity: new Date(),
count: info.count,
source,
lastActivity: info.lastActivity,
});
}
return connectionInfo;
});
}
@@ -547,7 +565,8 @@ export class MetricsManager {
public async getNetworkStats() {
// Use shorter cache TTL for network stats to ensure real-time updates
return this.metricsCache.get('networkStats', async () => {
const proxyMetrics = this.dcRouter.smartProxy ? this.dcRouter.smartProxy.getMetrics() : null;
const smartProxy = this.dcRouter.smartProxy;
const proxyMetrics = smartProxy ? smartProxy.getMetrics() : null;
if (!proxyMetrics) {
return {
@@ -568,8 +587,22 @@ export class MetricsManager {
};
}
// Get metrics using the new API
const connectionsByIP = proxyMetrics.connections.byIP();
const activeConnectionSnapshots = await this.getActiveConnectionSnapshots({ limit: 10000 });
const connectionsByIP = new Map<string, number>();
const connectionsByRoute = new Map<string, number>();
const activeConnectionsByDomain = new Map<string, number>();
for (const snapshot of activeConnectionSnapshots) {
connectionsByIP.set(snapshot.sourceIp, (connectionsByIP.get(snapshot.sourceIp) || 0) + 1);
if (snapshot.routeId) {
connectionsByRoute.set(snapshot.routeId, (connectionsByRoute.get(snapshot.routeId) || 0) + 1);
}
if (snapshot.domain) {
activeConnectionsByDomain.set(snapshot.domain, (activeConnectionsByDomain.get(snapshot.domain) || 0) + 1);
}
}
const instantThroughput = proxyMetrics.throughput.instant();
// Get throughput rate
@@ -578,8 +611,11 @@ export class MetricsManager {
bytesOutPerSecond: instantThroughput.out
};
// Get top IPs by connection count
const topIPs = proxyMetrics.connections.topIPs(10);
// Get top IPs by active connection count
const topIPs = Array.from(connectionsByIP.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([ip, count]) => ({ ip, count }));
// Get total data transferred
const totalDataTransferred = {
@@ -738,7 +774,6 @@ export class MetricsManager {
const topASNs = await this.buildTopASNs(observedIps, allIPData);
// Build domain activity using per-IP domain request counts from Rust engine
const connectionsByRoute = proxyMetrics.connections.byRoute();
const throughputByRoute = proxyMetrics.throughput.byRoute();
// Aggregate per-IP domain request counts into per-domain totals
@@ -773,6 +808,9 @@ export class MetricsManager {
for (const entry of protocolCache) {
if (entry.domain) allKnownDomains.add(entry.domain);
}
for (const snapshot of activeConnectionSnapshots) {
if (snapshot.domain) allKnownDomains.add(snapshot.domain);
}
// Build reverse map: concrete domain → canonical route key(s)
const domainToRoutes = new Map<string, string[]>();
@@ -844,7 +882,7 @@ export class MetricsManager {
}
domainAgg.set(domain, {
activeConnections: Math.round(totalConns),
activeConnections: activeConnectionsByDomain.get(domain) ?? Math.round(totalConns),
bytesInPerSec: totalIn,
bytesOutPerSec: totalOut,
routeCount: routeKeys.length,
+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 -70
View File
@@ -52,29 +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,
);
// Sync allowed edges with the hub
if (tunnelManager) {
await tunnelManager.syncAllowedEdges();
}
return { success: true, edge };
},
),
);
@@ -88,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',
};
},
),
@@ -117,41 +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,
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,
};
},
),
@@ -166,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 };
},
),
@@ -203,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>(
+57 -109
View File
@@ -1,7 +1,6 @@
import * as plugins from '../../plugins.js';
import type { OpsServer } from '../classes.opsserver.js';
import * as interfaces from '../../../ts_interfaces/index.js';
import { MetricsManager } from '../../monitoring/index.js';
import { requireOpsAuth } from '../helpers/auth.js';
export class SecurityHandler {
@@ -46,18 +45,7 @@ export class SecurityHandler {
'getActiveConnections',
async (dataArg, toolsArg) => {
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'stats:read' });
const connections = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const connectionInfos: interfaces.data.IConnectionInfo[] = connections.map(conn => ({
id: conn.id,
remoteAddress: conn.source.ip,
localAddress: conn.destination.ip,
startTime: conn.startTime,
protocol: conn.type === 'http' ? 'https' : conn.type as any,
state: conn.status === 'active' ? 'connected' : conn.status as any,
bytesReceived: (conn as any)._throughputIn || 0,
bytesSent: (conn as any)._throughputOut || 0,
connectionCount: conn.bytesTransferred || 1,
}));
const connectionInfos = await this.getActiveConnections(dataArg.protocol, dataArg.state);
const totalConnections = connectionInfos.reduce((sum, conn) => sum + (conn.connectionCount || 1), 0);
const summary = {
@@ -362,106 +350,66 @@ export class SecurityHandler {
private async getActiveConnections(
protocol?: 'http' | 'https' | 'smtp' | 'smtps',
state?: string
): Promise<Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}>> {
const connections: Array<{
id: string;
type: 'http' | 'smtp' | 'dns';
source: {
ip: string;
port: number;
country?: string;
};
destination: {
ip: string;
port: number;
service?: string;
};
startTime: number;
bytesTransferred: number;
status: 'active' | 'idle' | 'closing';
}> = [];
// Get connection info and network stats from MetricsManager if available
if (this.opsServerRef.dcRouterRef.metricsManager) {
const connectionInfo = await this.opsServerRef.dcRouterRef.metricsManager.getConnectionInfo();
const networkStats = await this.opsServerRef.dcRouterRef.metricsManager.getNetworkStats();
// One aggregate row per IP with real throughput data
if (networkStats.connectionsByIP && networkStats.connectionsByIP.size > 0) {
let connIndex = 0;
const publicIp = this.opsServerRef.dcRouterRef.options.publicIp || 'server';
): Promise<interfaces.data.IConnectionInfo[]> {
const metricsManager = this.opsServerRef.dcRouterRef.metricsManager;
if (!metricsManager) {
return [];
}
for (const [ip, count] of networkStats.connectionsByIP) {
const tp = networkStats.throughputByIP?.get(ip);
connections.push({
id: `ip-${connIndex++}`,
type: 'http',
source: {
ip: ip,
port: 0,
},
destination: {
ip: publicIp,
port: 443,
service: 'proxy',
},
startTime: 0,
bytesTransferred: count, // Store connection count here
status: 'active',
// Attach real throughput for the handler mapping
...(tp ? { _throughputIn: tp.in, _throughputOut: tp.out } : {}),
} as any);
}
} else if (connectionInfo.length > 0) {
// Fallback to route-based connection info if no IP data available
connectionInfo.forEach((info, index) => {
connections.push({
id: `conn-${index}`,
type: 'http',
source: {
ip: 'unknown',
port: 0,
},
destination: {
ip: this.opsServerRef.dcRouterRef.options.publicIp || 'server',
port: 443,
service: info.source,
},
startTime: info.lastActivity.getTime(),
bytesTransferred: 0,
status: 'active',
});
});
const snapshots = await metricsManager.getActiveConnectionSnapshots({ limit: 10000 });
const connections = snapshots.map((snapshot): interfaces.data.IConnectionInfo => ({
id: String(snapshot.id),
remoteAddress: snapshot.sourcePort === null
? snapshot.sourceIp
: `${snapshot.sourceIp}:${snapshot.sourcePort}`,
localAddress: snapshot.targetHost
? `${snapshot.targetHost}:${snapshot.targetPort ?? snapshot.localPort}`
: `${this.opsServerRef.dcRouterRef.options.publicIp || 'server'}:${snapshot.localPort}`,
startTime: snapshot.startedAtMs,
protocol: this.mapSnapshotProtocol(snapshot),
state: this.mapSnapshotState(snapshot.state),
bytesReceived: snapshot.bytesIn,
bytesSent: snapshot.bytesOut,
}));
return connections.filter((connection) => {
if (protocol && connection.protocol !== protocol) {
return false;
}
if (state && connection.state !== state) {
return false;
}
return true;
});
}
private mapSnapshotProtocol(
snapshot: plugins.smartproxy.IActiveConnectionSnapshot,
): interfaces.data.IConnectionInfo['protocol'] {
if (snapshot.localPort === 465) {
return 'smtps';
}
// Filter by protocol if specified
if (protocol) {
return connections.filter(conn => {
if (protocol === 'https' || protocol === 'http') {
return conn.type === 'http';
}
return conn.type === protocol.replace('s', ''); // smtp/smtps -> smtp
});
if ([25, 587, 2525].includes(snapshot.localPort)) {
return 'smtp';
}
return connections;
switch (snapshot.protocol) {
case 'http':
return 'http';
case 'https':
case 'tls':
case 'tls-passthrough':
case 'tls-reencrypt':
case 'tls-socket-handler':
case 'quic':
return 'https';
default:
return snapshot.localPort === 80 ? 'http' : 'https';
}
}
private mapSnapshotState(state: string): interfaces.data.IConnectionInfo['state'] {
return state === 'closing' ? 'closing' : 'connected';
}
private async getRateLimitStatus(
+54 -80
View File
@@ -91,7 +91,6 @@ export class RadiusServer {
private vlanManager: VlanManager;
private accountingManager: AccountingManager;
private config: IRadiusServerConfig;
private clientSecrets: Map<string, string> = new Map();
private running: boolean = false;
// Statistics
@@ -138,24 +137,18 @@ export class RadiusServer {
await this.vlanManager.importMappings(this.config.vlanAssignment.mappings);
}
// Build client secrets map
this.buildClientSecretsMap();
const cidrSecrets = this.buildClientSecretsMap();
// Create the RADIUS server
this.radiusServer = new plugins.smartradius.RadiusServer({
authPort: this.config.authPort,
acctPort: this.config.acctPort,
bindAddress: this.config.bindAddress,
defaultSecret: this.getDefaultSecret(),
cidrSecrets,
authenticationHandler: this.handleAuthentication.bind(this),
accountingHandler: this.handleAccounting.bind(this),
});
// Configure per-client secrets
for (const [ip, secret] of this.clientSecrets) {
this.radiusServer.setClientSecret(ip, secret);
}
// Start the server
await this.radiusServer.start();
@@ -189,19 +182,22 @@ export class RadiusServer {
/**
* Handle authentication request
*/
private async handleAuthentication(request: any): Promise<any> {
private async handleAuthentication(
request: plugins.smartradius.IAuthenticationRequest,
): Promise<plugins.smartradius.IAuthenticationResponse> {
this.stats.authRequests++;
const authData: IAuthRequestData = {
username: request.attributes?.UserName || '',
password: request.attributes?.UserPassword,
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
nasPort: request.attributes?.NasPort,
nasPortType: request.attributes?.NasPortType,
nasIdentifier: request.attributes?.NasIdentifier,
calledStationId: request.attributes?.CalledStationId,
callingStationId: request.attributes?.CallingStationId,
serviceType: request.attributes?.ServiceType,
username: request.username || '',
password: request.password,
nasIpAddress: request.nasIpAddress || request.clientAddress || '',
nasPort: request.nasPort,
nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
nasIdentifier: request.nasIdentifier,
calledStationId: request.calledStationId,
callingStationId: request.callingStationId,
serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
framedMtu: request.framedMtu,
};
logger.log('debug', `RADIUS Auth Request: user=${authData.username}, NAS=${authData.nasIpAddress}`);
@@ -215,15 +211,15 @@ export class RadiusServer {
logger.log('info', `RADIUS Auth Accept: user=${authData.username}, VLAN=${result.vlanId}`);
// Build response with VLAN attributes
const response: any = {
const response: plugins.smartradius.IAuthenticationResponse = {
code: plugins.smartradius.ERadiusCode.AccessAccept,
replyMessage: result.replyMessage,
};
// Add VLAN attributes if assigned
if (result.vlanId !== undefined) {
response.tunnelType = 13; // VLAN
response.tunnelMediumType = 6; // IEEE 802
response.tunnelType = plugins.smartradius.ETunnelType.Vlan;
response.tunnelMediumType = plugins.smartradius.ETunnelMediumType.Ieee802;
response.tunnelPrivateGroupId = String(result.vlanId);
}
@@ -257,34 +253,37 @@ export class RadiusServer {
/**
* Handle accounting request
*/
private async handleAccounting(request: any): Promise<any> {
private async handleAccounting(
request: plugins.smartradius.IAccountingRequest,
): Promise<plugins.smartradius.IAccountingResponse> {
this.stats.accountingRequests++;
if (!this.config.accounting?.enabled) {
// Still respond even if not tracking
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
return { success: true };
}
const statusType = request.attributes?.AcctStatusType;
const sessionId = request.attributes?.AcctSessionId || '';
const statusType = request.statusType;
const sessionId = request.sessionId || '';
const accountingData = {
sessionId,
username: request.attributes?.UserName || '',
macAddress: request.attributes?.CallingStationId,
nasIpAddress: request.attributes?.NasIpAddress || request.source?.address || '',
nasPort: request.attributes?.NasPort,
nasPortType: request.attributes?.NasPortType,
nasIdentifier: request.attributes?.NasIdentifier,
calledStationId: request.attributes?.CalledStationId,
callingStationId: request.attributes?.CallingStationId,
inputOctets: request.attributes?.AcctInputOctets,
outputOctets: request.attributes?.AcctOutputOctets,
inputPackets: request.attributes?.AcctInputPackets,
outputPackets: request.attributes?.AcctOutputPackets,
sessionTime: request.attributes?.AcctSessionTime,
terminateCause: request.attributes?.AcctTerminateCause,
serviceType: request.attributes?.ServiceType,
username: request.username || '',
macAddress: request.callingStationId,
nasIpAddress: request.nasIpAddress || request.clientAddress || '',
nasPort: request.nasPort,
nasPortType: request.nasPortType !== undefined ? String(request.nasPortType) : undefined,
nasIdentifier: request.nasIdentifier,
calledStationId: request.calledStationId,
callingStationId: request.callingStationId,
inputOctets: request.inputOctets,
outputOctets: request.outputOctets,
inputPackets: request.inputPackets,
outputPackets: request.outputPackets,
sessionTime: request.sessionTime,
terminateCause: request.terminateCause !== undefined ? String(request.terminateCause) : undefined,
framedIpAddress: request.framedIpAddress,
serviceType: request.serviceType !== undefined ? String(request.serviceType) : undefined,
};
try {
@@ -311,7 +310,7 @@ export class RadiusServer {
logger.log('error', `RADIUS accounting error: ${(error as Error).message}`);
}
return { code: plugins.smartradius.ERadiusCode.AccountingResponse };
return { success: true };
}
/**
@@ -391,37 +390,18 @@ export class RadiusServer {
/**
* Build client secrets map from configuration
*/
private buildClientSecretsMap(): void {
this.clientSecrets.clear();
private buildClientSecretsMap(): Record<string, string> {
const cidrSecrets: Record<string, string> = {};
for (const client of this.config.clients) {
if (!client.enabled) {
continue;
}
// Handle CIDR ranges
if (client.ipRange.includes('/')) {
// For CIDR ranges, we'll use the network address as key
// In practice, smartradius may handle this differently
const [network] = client.ipRange.split('/');
this.clientSecrets.set(network, client.secret);
} else {
this.clientSecrets.set(client.ipRange, client.secret);
}
cidrSecrets[client.ipRange] = client.secret;
}
}
/**
* Get default secret for unknown clients
*/
private getDefaultSecret(): string {
// Use first enabled client's secret as default, or a random one
for (const client of this.config.clients) {
if (client.enabled) {
return client.secret;
}
}
return plugins.crypto.randomBytes(16).toString('hex');
return cidrSecrets;
}
/**
@@ -430,21 +410,19 @@ export class RadiusServer {
async addClient(client: IRadiusClient): Promise<void> {
// Check if client already exists
const existingIndex = this.config.clients.findIndex(c => c.name === client.name);
const previousClient = existingIndex >= 0 ? this.config.clients[existingIndex] : undefined;
if (existingIndex >= 0) {
this.config.clients[existingIndex] = client;
} else {
this.config.clients.push(client);
}
// Update client secrets if running
if (this.running && this.radiusServer && client.enabled) {
if (client.ipRange.includes('/')) {
const [network] = client.ipRange.split('/');
this.radiusServer.setClientSecret(network, client.secret);
this.clientSecrets.set(network, client.secret);
} else {
this.radiusServer.setClientSecret(client.ipRange, client.secret);
this.clientSecrets.set(client.ipRange, client.secret);
if (this.running && this.radiusServer) {
if (previousClient) {
this.radiusServer.removeNetworkSecret(previousClient.ipRange);
}
if (client.enabled) {
this.radiusServer.setNetworkSecret(client.ipRange, client.secret);
}
}
@@ -460,12 +438,8 @@ export class RadiusServer {
const client = this.config.clients[index];
this.config.clients.splice(index, 1);
// Remove from secrets map
if (client.ipRange.includes('/')) {
const [network] = client.ipRange.split('/');
this.clientSecrets.delete(network);
} else {
this.clientSecrets.delete(client.ipRange);
if (this.radiusServer) {
this.radiusServer.removeNetworkSecret(client.ipRange);
}
logger.log('info', `RADIUS client removed: ${name}`);
+183 -23
View File
@@ -1,29 +1,38 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngress, 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[];
}
/**
* Flatten a port range (number | number[] | Array<{from, to}>) to a sorted unique number array.
*/
function extractPorts(portRange: number | Array<number | { from: number; to: number }>): number[] {
const ports = new Set<number>();
if (typeof portRange === 'number') {
ports.add(portRange);
} else if (Array.isArray(portRange)) {
for (const entry of portRange) {
if (typeof entry === 'number') {
ports.add(entry);
} else if (typeof entry === 'object' && 'from' in entry && 'to' in entry) {
for (let p = entry.from; p <= entry.to; p++) {
ports.add(p);
}
}
}
}
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);
}
@@ -36,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) {
}
/**
@@ -59,12 +72,35 @@ export class RemoteIngressManager {
listenPortsUdp: doc.listenPortsUdp,
enabled: doc.enabled,
autoDerivePorts: doc.autoDerivePorts,
performance: doc.performance,
tags: doc.tags,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
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',
};
}
/**
@@ -81,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.
@@ -189,6 +257,7 @@ export class RemoteIngressManager {
listenPorts: number[] = [],
tags?: string[],
autoDerivePorts: boolean = true,
performance?: IRemoteIngressPerformanceConfig,
): Promise<IRemoteIngress> {
const id = plugins.uuid.v4();
const secret = plugins.crypto.randomBytes(32).toString('hex');
@@ -201,6 +270,7 @@ export class RemoteIngressManager {
listenPorts,
enabled: true,
autoDerivePorts,
performance,
tags: tags || [],
createdAt: now,
updatedAt: now,
@@ -237,6 +307,7 @@ export class RemoteIngressManager {
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
},
): Promise<IRemoteIngress | null> {
@@ -249,6 +320,7 @@ export class RemoteIngressManager {
if (updates.listenPorts !== undefined) edge.listenPorts = updates.listenPorts;
if (updates.autoDerivePorts !== undefined) edge.autoDerivePorts = updates.autoDerivePorts;
if (updates.enabled !== undefined) edge.enabled = updates.enabled;
if (updates.performance !== undefined) edge.performance = updates.performance;
if (updates.tags !== undefined) edge.tags = updates.tags;
edge.updatedAt = Date.now();
@@ -317,20 +389,108 @@ export class RemoteIngressManager {
* Get the list of allowed edges (enabled only) for the Rust hub.
* Includes listenPortsUdp when routes with transport 'udp' or 'all' are present.
*/
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig }> = [];
public getAllowedEdges(): Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> {
const result: Array<{ id: string; secret: string; listenPorts: number[]; listenPortsUdp?: number[]; firewallConfig?: IRemoteIngressFirewallConfig; performance?: IRemoteIngressPerformanceConfig }> = [];
for (const edge of this.edges.values()) {
if (edge.enabled) {
const listenPortsUdp = this.getEffectiveListenPortsUdp(edge);
const performance = edge.performance && Object.keys(edge.performance).length > 0 ? edge.performance : undefined;
result.push({
id: edge.id,
secret: edge.secret,
listenPorts: this.getEffectiveListenPorts(edge),
...(listenPortsUdp.length > 0 ? { listenPortsUdp } : {}),
...(this.firewallConfig ? { firewallConfig: this.firewallConfig } : {}),
...(performance ? { performance } : {}),
});
}
}
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;
+16
View File
@@ -13,6 +13,8 @@ export interface IRemoteIngress {
enabled: boolean;
/** Whether to auto-derive ports from remoteIngress-tagged routes. Defaults to true. */
autoDerivePorts: boolean;
/** Optional per-edge performance overrides. */
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
createdAt: number;
updatedAt: number;
@@ -55,6 +57,16 @@ export interface IRemoteIngressPerformanceConfig {
maxStreamWindowBytes?: number;
sustainedStreamWindowBytes?: number;
quicDatagramReceiveBufferBytes?: number;
streamFramePayloadBytes?: number;
firstDataConnectTimeoutMs?: number;
clientWriteTimeoutMs?: number;
serverFirstPorts?: number[];
}
export interface IRemoteIngressHubSettings {
performance?: IRemoteIngressPerformanceConfig;
updatedAt: number;
updatedBy: string;
}
export interface IRemoteIngressPerformanceEffective {
@@ -65,6 +77,10 @@ export interface IRemoteIngressPerformanceEffective {
maxStreamWindowBytes: number;
sustainedStreamWindowBytes: number;
quicDatagramReceiveBufferBytes: number;
streamFramePayloadBytes: number;
firstDataConnectTimeoutMs: number;
clientWriteTimeoutMs: number;
serverFirstPorts: number[];
}
export interface IRemoteIngressFlowControlStatus {
+40 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import * as authInterfaces from '../data/auth.js';
import type { IRemoteIngress, IRemoteIngressStatus } from '../data/remoteingress.js';
import type { IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../data/remoteingress.js';
// ============================================================================
// Remote Ingress Edge Management
@@ -20,6 +20,7 @@ export interface IReq_CreateRemoteIngress extends plugins.typedrequestInterfaces
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
};
response: {
@@ -63,6 +64,7 @@ export interface IReq_UpdateRemoteIngress extends plugins.typedrequestInterfaces
listenPorts?: number[];
autoDerivePorts?: boolean;
enabled?: boolean;
performance?: IRemoteIngressPerformanceConfig;
tags?: string[];
};
response: {
@@ -145,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.38.0',
version: '13.41.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+45 -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(),
@@ -1120,6 +1128,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
tags?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext();
@@ -1135,6 +1144,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
performance: dataArg.performance,
tags: dataArg.tags,
});
@@ -1187,6 +1197,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
name?: string;
listenPorts?: number[];
autoDerivePorts?: boolean;
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
tags?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
const context = getActionContext();
@@ -1203,6 +1214,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
performance: dataArg.performance,
tags: dataArg.tags,
});
@@ -1215,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')};
}
`,
];
@@ -242,6 +260,7 @@ export class OpsViewRemoteIngress extends DeesElement {
publicIp: this.getEdgePublicIp(edge.id),
ports: this.getPortsHtml(edge),
tunnels: this.getEdgeTunnelCount(edge.id),
maxConnections: this.getMaxConnectionsHtml(edge),
window: this.getWindowHtml(edge.id),
queues: this.getQueuesHtml(edge.id),
traffic: this.getTrafficHtml(edge.id),
@@ -261,6 +280,7 @@ export class OpsViewRemoteIngress extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers, optional'}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${true}></dees-input-checkbox>
<dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated, optional'}></dees-input-text>
</dees-form>
`,
@@ -284,12 +304,20 @@ export class OpsViewRemoteIngress extends DeesElement {
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: undefined;
const autoDerivePorts = formData.autoDerivePorts !== false;
let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
try {
performance = this.collectPerformanceOverride(formData);
} catch (err: unknown) {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
return;
}
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
await appstate.remoteIngressStatePart.dispatchAction(
appstate.createRemoteIngressAction,
{ name, listenPorts, autoDerivePorts, tags },
{ name, listenPorts, autoDerivePorts, performance, tags },
);
await modalArg.destroy();
},
@@ -298,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',
@@ -338,6 +374,7 @@ export class OpsViewRemoteIngress extends DeesElement {
<dees-input-text .key=${'name'} .label=${'Name'} .value=${edge.name}></dees-input-text>
<dees-input-text .key=${'listenPorts'} .label=${'Manual Ports'} .description=${'Comma-separated port numbers'} .value=${(edge.listenPorts || []).join(', ')}></dees-input-text>
<dees-input-checkbox .key=${'autoDerivePorts'} .label=${'Auto-derive ports from routes'} .value=${edge.autoDerivePorts !== false}></dees-input-checkbox>
<dees-input-text .key=${'maxStreamsPerEdge'} .label=${'Max Connections'} .description=${'Optional maximum concurrent client connections for this edge. Leave empty to use the hub default.'} .value=${edge.performance?.maxStreamsPerEdge?.toString() || ''}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Tags'} .description=${'Comma-separated'} .value=${(edge.tags || []).join(', ')}></dees-input-text>
</dees-form>
`,
@@ -359,6 +396,14 @@ export class OpsViewRemoteIngress extends DeesElement {
? portsStr.split(',').map((p: string) => parseInt(p.trim(), 10)).filter((p: number) => !isNaN(p))
: [];
const autoDerivePorts = formData.autoDerivePorts !== false;
let performance: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
try {
performance = this.collectPerformanceOverride(formData, edge.performance);
} catch (err: unknown) {
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
return;
}
const tags = formData.tags
? formData.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
@@ -369,6 +414,7 @@ export class OpsViewRemoteIngress extends DeesElement {
name: formData.name || edge.name,
listenPorts,
autoDerivePorts,
performance,
tags,
},
);
@@ -475,6 +521,19 @@ export class OpsViewRemoteIngress extends DeesElement {
return status?.activeTunnels || 0;
}
private getMaxConnectionsHtml(edge: interfaces.data.IRemoteIngress): TemplateResult | string {
const status = this.getEdgeStatus(edge.id);
const override = edge.performance?.maxStreamsPerEdge;
const effective = status?.performance?.maxStreamsPerEdge;
if (!override && !effective) return '-';
return html`
<div class="metricStack">
<span>${override || effective}</span>
<span class="metricMuted">${override ? 'edge override' : 'hub default'}</span>
</div>
`;
}
private getTransportHtml(edgeId: string): TemplateResult | string {
const status = this.getEdgeStatus(edgeId);
if (!status?.connected) return '-';
@@ -535,4 +594,165 @@ export class OpsViewRemoteIngress extends DeesElement {
}
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
private collectPerformanceOverride(
formData: Record<string, any>,
base?: interfaces.data.IRemoteIngressPerformanceConfig,
): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...(base || {}) };
const maxStreamsText = `${formData.maxStreamsPerEdge || ''}`.trim();
if (maxStreamsText) {
const maxStreamsPerEdge = Number.parseInt(maxStreamsText, 10);
if (!Number.isInteger(maxStreamsPerEdge) || maxStreamsPerEdge < 1) {
throw new Error('Max Connections must be a positive integer');
}
next.maxStreamsPerEdge = maxStreamsPerEdge;
} else {
delete next.maxStreamsPerEdge;
}
if (Object.keys(next).length > 0) {
return next;
}
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);
}
}
@@ -304,6 +304,16 @@ export class OpsViewConfig extends DeesElement {
{ key: 'Connected Edge IPs', value: ri.connectedEdgeIps?.length > 0 ? ri.connectedEdgeIps : null, type: 'pills' },
];
if (ri.performance) {
fields.push(
{ key: 'Performance Profile', value: ri.performance.profile || null, type: 'badge' },
{ key: 'Max Connections / Edge', value: ri.performance.maxStreamsPerEdge ?? null },
{ key: 'Client Write Timeout', value: ri.performance.clientWriteTimeoutMs ? `${ri.performance.clientWriteTimeoutMs} ms` : null },
{ key: 'First Data Timeout', value: ri.performance.firstDataConnectTimeoutMs ? `${ri.performance.firstDataConnectTimeoutMs} ms` : null },
{ key: 'Server-first Ports', value: ri.performance.serverFirstPorts?.length ? ri.performance.serverFirstPorts.map(String) : null, type: 'pills' },
);
}
const actions: IConfigSectionAction[] = [
{ label: 'View Remote Ingress', icon: 'lucide:arrow-right', event: 'navigate', detail: { view: 'network', subview: 'remoteingress' } },
];