Compare commits

...

35 Commits

Author SHA1 Message Date
jkunz 9c08384df0 v13.43.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m22s
2026-06-03 09:32:11 +00:00
jkunz 9286f56316 fix(route-management): use canonical source bindings 2026-06-03 06:46:38 +00:00
jkunz 1c4caf2b85 v13.43.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m57s
2026-06-03 04:20:56 +00:00
jkunz 4a09b273df fix(dockerignore): ignore generated artifacts and caches in Docker build context 2026-06-03 04:17:02 +00:00
jkunz 4ceb46b509 v13.43.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m6s
2026-06-03 03:29:58 +00:00
jkunz 0aa1cde5eb feat(http-redirects): add derived HTTP-to-HTTPS redirects 2026-06-03 03:24:55 +00:00
jkunz 584782dcb7 v13.42.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m58s
2026-06-02 18:59:20 +00:00
jkunz 810ecf46f8 fix(deps): update Deno import dependencies 2026-06-02 17:38:51 +00:00
jkunz 6d5d23a691 fix(source-policy-compiler): normalize source policy route priorities to stable integers 2026-06-02 17:25:18 +00:00
jkunz c6617c79f5 v13.42.3
Release / build-and-release (push) Successful in 6m49s
Docker (tags) / release (push) Failing after 1s
2026-06-02 15:40:09 +00:00
jkunz 135432260d fix(deps): update dependency versions 2026-06-02 15:40:07 +00:00
jkunz b55d2ac61d v13.42.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m1s
2026-06-02 14:11:18 +00:00
jkunz c88e8e1758 fix(dev-deps): bump @git.zone/tsdocker to ^2.4.1 2026-06-02 14:10:49 +00:00
jkunz 6ee716e4ef v13.42.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m6s
2026-06-02 12:48:16 +00:00
jkunz 1d4ed9af2c fix(deps): bump @serve.zone/remoteingress to ^4.22.5 2026-06-02 12:47:53 +00:00
jkunz d2331fdcbe v13.42.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m27s
2026-06-02 00:29:38 +00:00
jkunz 0e7765c740 feat(source-policy): add ordered route source policies with Gitea preset support 2026-06-02 00:29:13 +00:00
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
59 changed files with 6122 additions and 1148 deletions
+8
View File
@@ -1,7 +1,15 @@
node_modules/
.nogit/
.git/
.cache/
.rpt2_cache
.yarn/
.playwright-mcp/
.vscode/
coverage/
dist/
dist_*/
pages/
public/
test/
test_watch/
+144
View File
@@ -3,12 +3,156 @@
## Pending
## 2026-06-03 - 13.43.2
### Fixes
- enforce canonical source bindings for route access (route-management)
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
## 2026-06-03 - 13.43.1
### Fixes
- ignore generated artifacts and caches in Docker build context (dockerignore)
- Exclude cache directories, coverage reports, distribution outputs, and generated static assets from Docker contexts.
## 2026-06-03 - 13.43.0
### Features
- add derived HTTP-to-HTTPS redirects (http-redirects)
- Generate 301 runtime redirect routes from eligible HTTPS routes while detecting existing HTTP route coverage or conflicts
- Expose derived redirect metadata through the getHttpRedirects typed request API
- Add an Ops Redirects network view with redirect status metrics and table details
- Add tests for redirect derivation, conflict handling, and preserving request host/path
## 2026-06-02 - 13.42.4
### Fixes
- normalize source policy route priorities to stable integers (source-policy-compiler)
- Assign integer priorities to compiled source policy route variants while preserving relative priority order.
- Keep path-specific source policy variants ranked above fallback variants.
- update Deno import dependencies (deps)
- Bumped Deno import map versions for API, identity, push.rocks, serve.zone, and lru-cache dependencies.
## 2026-06-02 - 13.42.3
### Fixes
- update dependency versions (deps)
- Bumped runtime dependencies including @serve.zone/interfaces to ^6.2.1, @serve.zone/catalog to ^2.12.7, and lru-cache to ^11.5.1.
- Updated @git.zone/tsdocker dev dependency to ^2.4.2.
## 2026-06-02 - 13.42.2
### Fixes
- bump @git.zone/tsdocker to ^2.4.1 (dev-deps)
- Updated @git.zone/tsdocker from ^2.4.0 to ^2.4.1.
## 2026-06-02 - 13.42.1
### Fixes
- bump @serve.zone/remoteingress to ^4.22.5 (deps)
- Updates @serve.zone/remoteingress from ^4.22.4 to ^4.22.5.
## 2026-06-02 - 13.42.0
### Features
- add ordered route source policies with Gitea preset support (source-policy)
- Compile metadata.sourcePolicy bindings into SmartProxy route variants with ordered source matching, path-class overrides, and terminal 429 rate/connection limit handling
- Add shared source-policy interfaces, Gitea path-class patterns, validation limits, and resolver support for policy-backed profile usage and display names
- Add Ops UI controls for manual and Gitea source-policy presets plus rate-limit editing for source profiles
- Seed TRUSTED NETWORKS, AI CRAWLERS, and PUBLIC default profiles through defaults and the 13.42.0 migration
- Bump smartproxy to ^27.12.4 and add coverage for source-policy compilation, rate-limit behavior, migrations, and port-safe server tests
## 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
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.38.3",
"version": "13.43.2",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
@@ -8,18 +8,18 @@
]
},
"imports": {
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.2",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.7",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.4",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.4.0/server",
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.2",
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
@@ -31,18 +31,18 @@
"@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/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.4",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.3.0",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.0",
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.2.1",
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.5",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
"lru-cache": "npm:lru-cache@^11.4.0",
"lru-cache": "npm:lru-cache@^11.5.1",
"qrcode": "npm:qrcode@^1.5.4",
"uuid": "npm:uuid@^14.0.0"
}
+14 -14
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.38.3",
"version": "13.43.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -28,28 +28,28 @@
"devDependencies": {
"@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/tsdocker": "^2.4.2",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "^3.3.1",
"@api.global/typedrequest": "^3.3.2",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.3",
"@api.global/typedserver": "^8.4.7",
"@api.global/typedsocket": "^4.1.4",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@design.estate/dees-catalog": "^3.83.0",
"@design.estate/dees-element": "^2.2.4",
"@idp.global/sdk": "^1.3.1",
"@idp.global/sdk": "^1.4.0",
"@push.rocks/lik": "^6.4.1",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdb": "^2.10.1",
"@push.rocks/smartdb": "^2.10.2",
"@push.rocks/smartdns": "^7.9.3",
"@push.rocks/smartfs": "^1.5.1",
"@push.rocks/smartguard": "^3.1.0",
@@ -61,20 +61,20 @@
"@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.4",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.22.0",
"@serve.zone/catalog": "^2.12.7",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/remoteingress": "^4.22.5",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.4.0",
"lru-cache": "^11.5.1",
"qrcode": "^1.5.4",
"uuid": "^14.0.0"
},
+160 -390
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -146,6 +146,79 @@ dcrouter keeps generated and operator-created routes separate so automation can
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
## Route Source Bindings
API-created route records pass ordered `metadata.sourceBindings[]` alongside the SmartProxy route config to express source and path policy variants without duplicating whole routes by hand. Each binding points at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
Runtime behavior:
- Source matching uses the referenced `SourceProfile.security.ipAllowList`.
- Bindings are evaluated in order and the first matching source profile wins.
- A matched binding that exceeds its configured rate or connection limit is terminal and returns `429`; dcrouter does not fall through to later bindings.
- Source-binding rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-binding and path-policy overrides.
- Private-only binding lists are valid. dcrouter adds a same-match terminal deny fallback so unmatched sources fail closed.
- A public or wildcard binding is optional. When present, it must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
- Create/update paths reject source bindings with missing source profiles, source profiles without source matches, or any all-source binding that shadows later bindings; persisted invalid bindings fail closed at compile time.
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, 512 compiled SmartProxy route-port variants per stored route, and enough priority headroom above the stored route priority for generated source-binding variants.
Path policies let a source binding override rate limits or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps `git-smart-http` high-limit and separate from HTML crawling paths so normal `git clone`, `git fetch`, `git push`, and Git LFS traffic are not subject to the lower HTML crawler limits.
```typescript
const trustedProfileId = 'source-profile-id-trusted';
const publicProfileId = 'source-profile-id-public';
const createRoutePayload = {
route: {
name: 'public-gitea',
match: { domains: ['code.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '10.10.0.20', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
metadata: {
sourceBindings: [
{
sourceProfileRef: trustedProfileId,
maxConnections: 5000,
onExceeded: { type: '429' },
},
{
sourceProfileRef: publicProfileId,
onExceeded: { type: '429' },
pathPolicies: [
{
pathClass: 'git-smart-http',
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
},
{
pathClass: 'static',
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
{
pathClass: 'raw',
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
{
pathClass: 'archive',
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
{
pathClass: 'expensive-html',
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
],
},
],
},
};
```
## Production-Flavored Example
```typescript
+14 -1
View File
@@ -2,9 +2,21 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as path from 'path';
import * as fs from 'fs';
import * as net from 'node:net';
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
tap.test('DcRouter class - Custom email port configuration', async () => {
// Define custom port mapping
@@ -115,6 +127,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
const opsServerPort = await getFreePort();
// Create a basic email configuration
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
@@ -129,7 +142,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
tls: {
contactEmail: 'test@example.com'
},
opsServerPort: 3104,
opsServerPort,
dbConfig: {
enabled: false,
}
+16 -2
View File
@@ -1,15 +1,29 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import * as plugins from '../ts/plugins.js';
import * as net from 'node:net';
let dcRouter: DcRouter;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
const opsServerPort = await getFreePort();
dcRouter = new DcRouter({
smartProxyConfig: {
routes: []
},
opsServerPort: 3100,
opsServerPort,
dbConfig: { enabled: false }
});
@@ -146,4 +160,4 @@ tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();
export default tap.start();
+232
View File
@@ -0,0 +1,232 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import * as http from 'node:http';
import * as net from 'node:net';
import {
deriveHttpRedirectConfiguration,
deriveHttpRedirects,
} from '../ts/config/helpers.http-redirects.js';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
tap.test('deriveHttpRedirectConfiguration creates active runtime redirects from HTTPS routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
id: 'route-1',
name: 'app-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a'],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('active');
expect(result.redirects[0].domainPattern).toEqual('app.example.com');
expect(result.redirects[0].remoteIngress).toEqual(true);
expect(result.runtimeRoutes.length).toEqual(1);
expect(result.runtimeRoutes[0].match.ports).toEqual(80);
expect(result.runtimeRoutes[0].match.domains).toEqual('app.example.com');
expect(result.runtimeRoutes[0].priority).toEqual(0);
expect(result.runtimeRoutes[0].remoteIngress).toEqual({ enabled: true, edgeFilter: ['edge-a'] });
expect(typeof result.runtimeRoutes[0].action.socketHandler).toEqual('function');
});
tap.test('deriveHttpRedirectConfiguration deduplicates identical redirect scopes', async () => {
const redirects = deriveHttpRedirects([
{
id: 'route-1',
name: 'first-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
id: 'route-2',
name: 'second-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8081 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]);
expect(redirects.length).toEqual(1);
expect(redirects[0].sourceRouteNames).toEqual(['first-route', 'second-route']);
});
tap.test('deriveHttpRedirectConfiguration treats broad explicit HTTP routes as covered', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('covered');
expect(result.redirects[0].coveredByRouteNames).toEqual(['existing-http-route']);
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips broad redirects that overlap path-specific HTTP routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-health-route',
match: { ports: 80, domains: 'app.example.com', path: '/health' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips wildcard redirects that overlap explicit HTTP domains', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'wildcard-https-route',
match: { ports: 443, domains: '*.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'explicit-http-app-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration ignores non-web or narrowed HTTPS routes', async () => {
const redirects = deriveHttpRedirects([
{
name: 'udp-route',
match: { ports: 443, domains: 'udp.example.com', transport: 'udp' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 443 }],
tls: { mode: 'passthrough' },
},
} as any,
{
name: 'header-route',
match: { ports: 443, domains: 'header.example.com', headers: { 'x-test': 'yes' } },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'socket-handler-route',
match: { ports: 443, domains: 'handler.example.com' },
action: {
type: 'socket-handler',
socketHandler: () => {},
},
} as any,
]);
expect(redirects.length).toEqual(0);
});
tap.test('generated runtime redirect preserves host and path', async () => {
const proxyPort = await getFreePort();
const redirectRoute = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]).runtimeRoutes[0] as any;
redirectRoute.match = { ...redirectRoute.match, ports: proxyPort };
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [redirectRoute],
});
try {
await proxy.start();
const response = await requestHeaders(proxyPort, '/some/path?x=1', { host: 'app.example.com' });
expect(response.statusCode).toEqual(301);
expect(response.headers.location).toEqual('https://app.example.com/some/path?x=1');
response.destroy();
} finally {
await proxy.stop();
}
});
export default tap.start();
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3102,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login with admin credentials and receive JWT', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -48,7 +67,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -68,7 +87,7 @@ tap.test('should verify valid JWT identity', async () => {
tap.test('should reject invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -85,7 +104,7 @@ tap.test('should reject invalid JWT', async () => {
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -106,7 +125,7 @@ tap.test('should verify JWT matches identity data', async () => {
tap.test('should handle logout', async () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLogout'
);
@@ -120,7 +139,7 @@ tap.test('should handle logout', async () => {
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -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: {
+265 -12
View File
@@ -12,13 +12,105 @@ 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 unsetPath(target: Record<string, any>, path: string): void {
const parts = path.split('.');
let cursor: any = target;
for (const part of parts.slice(0, -1)) {
if (cursor?.[part] === undefined) return;
cursor = cursor[part];
}
if (cursor && typeof cursor === 'object') {
delete cursor[parts[parts.length - 1]];
}
}
function applyUnset(document: Record<string, any>, unset: Record<string, unknown>): void {
for (const key of Object.keys(unset)) {
unsetPath(document, key);
}
}
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 {
findOne: async (query: Record<string, any> = {}) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
return document ? structuredClone(document) : null;
},
find: (query: Record<string, any> = {}) => ({
async *[Symbol.asyncIterator]() {
for (const document of documents) {
if (matchesQuery(document, query)) {
yield structuredClone(document);
}
}
},
}),
insertOne: async (document: Record<string, any>) => {
documents.push(structuredClone(document));
return { insertedId: document._id || document.id };
},
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 || {});
applyUnset(document, update.$unset || {});
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 || {});
applyUnset(document, update.$unset || {});
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 +121,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 +142,181 @@ 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 sourceProfiles: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.16.0', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.31.0');
expect(result.stepsApplied).toHaveLength(3);
expect(result.currentVersionAfter).toEqual('13.42.0');
expect(result.stepsApplied).toHaveLength(4);
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
});
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();
});
tap.test('migration runner seeds only missing default source profiles', async () => {
const sourceProfiles: Array<Record<string, any>> = [
{
id: 'public-profile',
name: 'PUBLIC',
description: 'Existing public profile',
security: { ipAllowList: ['*'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.2', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
const publicProfiles = sourceProfiles.filter((profile) => profile.name === 'PUBLIC');
expect(result.stepsApplied).toHaveLength(1);
expect(sourceProfiles).toHaveLength(3);
expect(publicProfiles).toHaveLength(1);
expect(publicProfiles[0].security.rateLimit).toBeUndefined();
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
});
tap.test('migration runner converts legacy route access metadata to source bindings', async () => {
const profiles: Array<Record<string, any>> = [
{
_id: 'profile-doc-1',
id: 'standard-profile',
name: 'Standard',
security: { ipAllowList: ['10.0.0.0/8'] },
},
{
_id: 'profile-doc-2',
id: 'public-profile',
name: 'PUBLIC',
security: { ipAllowList: ['*'] },
},
];
const routes: Array<Record<string, any>> = [
{
_id: 'route-doc-1',
id: 'route-1',
route: {
name: 'standard service',
match: { ports: 443, domains: ['onebox.example.com'] },
action: { type: 'forward', targets: [{ host: '10.0.0.2', port: 443 }] },
security: { ipAllowList: ['10.0.0.0/8'], maxConnections: 1000 },
},
metadata: {
sourceProfileRef: 'standard-profile',
sourceProfileName: 'Old Standard Name',
},
updatedAt: 1,
},
{
_id: 'route-doc-2',
id: 'route-2',
route: {
name: 'gitea',
match: { ports: 443, domains: ['code.example.com'] },
action: { type: 'forward', targets: [{ host: '10.0.0.3', port: 3000 }] },
security: { basicAuth: { username: 'user', password: 'pass' } },
},
metadata: {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'standard-profile' },
{ sourceProfileRef: 'public-profile' },
],
},
},
updatedAt: 1,
},
];
const runner = await createMigrationRunner(
createFakeDb('13.43.1', {
SourceProfileDoc: profiles,
RouteDoc: routes,
}),
'13.43.2',
);
const result = await runner.run();
expect(result.stepsApplied).toHaveLength(1);
expect(routes[0].metadata.sourceBindings).toEqual([
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Old Standard Name' },
]);
expect(routes[0].metadata.sourceProfileRef).toBeUndefined();
expect(routes[0].metadata.sourceProfileName).toBeUndefined();
expect(routes[0].metadata.sourcePolicy).toBeUndefined();
expect(routes[0].route.security).toBeUndefined();
expect(routes[1].metadata.sourceBindings).toEqual([
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Standard' },
{ sourceProfileRef: 'public-profile', sourceProfileName: 'PUBLIC' },
]);
expect(routes[1].metadata.sourcePolicy).toBeUndefined();
expect(routes[1].route.security.basicAuth.username).toEqual('user');
});
export default tap.start();
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
let opsServerPort: number;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function typedRequestUrl(): string {
return `http://127.0.0.1:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3101,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -40,7 +59,7 @@ tap.test('should login as admin', async () => {
tap.test('should respond to health status request', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
@@ -56,7 +75,7 @@ tap.test('should respond to health status request', async () => {
tap.test('should respond to server statistics request', async () => {
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getServerStatistics'
);
@@ -73,7 +92,7 @@ tap.test('should respond to server statistics request', async () => {
tap.test('should respond to configuration request', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getConfiguration'
);
@@ -94,7 +113,7 @@ tap.test('should respond to configuration request', async () => {
tap.test('should handle log retrieval request', async () => {
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getRecentLogs'
);
@@ -111,7 +130,7 @@ tap.test('should handle log retrieval request', async () => {
tap.test('should reject unauthenticated requests', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3103,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -41,7 +60,7 @@ tap.test('should login as admin', async () => {
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -56,7 +75,7 @@ tap.test('should allow admin to verify identity', async () => {
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -71,7 +90,7 @@ tap.test('should reject verify identity without identity', async () => {
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -91,7 +110,7 @@ tap.test('should reject verify identity with invalid JWT', async () => {
tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getHealthStatus'
);
@@ -107,7 +126,7 @@ tap.test('should reject protected endpoints without auth', async () => {
tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getConfiguration'
);
+48 -198
View File
@@ -3,10 +3,6 @@ import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Helpers: access private maps for direct unit testing without DB
// ============================================================================
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
@@ -54,10 +50,6 @@ function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
} as IRouteConfig;
}
// ============================================================================
// Resolution tests
// ============================================================================
let resolver: ReferenceResolver;
tap.test('should create ReferenceResolver instance', async () => {
@@ -67,79 +59,43 @@ tap.test('should create ReferenceResolver instance', async () => {
tap.test('should list empty profiles and targets initially', async () => {
expect(resolver.listProfiles()).toBeArray();
expect(resolver.listProfiles().length).toEqual(0);
expect(resolver.listProfiles()).toHaveLength(0);
expect(resolver.listTargets()).toBeArray();
expect(resolver.listTargets().length).toEqual(0);
expect(resolver.listTargets()).toHaveLength(0);
});
// ---- Source profile resolution ----
tap.test('should resolve source profile onto a route', async () => {
tap.test('should resolve source binding display names without materializing route security', async () => {
const profile = makeProfile();
injectProfile(resolver, profile);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const route = makeRoute({
security: { ipAllowList: ['127.0.0.1'], maxConnections: 42 },
});
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
};
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
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!.maxConnections).toEqual(1000);
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.route.security!.ipAllowList).toEqual(['127.0.0.1']);
expect(result.route.security!.maxConnections).toEqual(42);
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should merge inline route security with profile security', async () => {
const route = makeRoute({
security: {
ipAllowList: ['127.0.0.1'],
maxConnections: 5000,
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
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');
// Inline maxConnections overrides profile
expect(result.route.security!.maxConnections).toEqual(5000);
});
tap.test('should deduplicate IP lists during merge', async () => {
const route = makeRoute({
security: {
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
},
});
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
const result = resolver.resolveRoute(route, metadata);
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
expect(count).toEqual(1);
});
tap.test('should handle missing profile gracefully', async () => {
tap.test('should keep missing source binding refs fail-closed for compiler validation', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'nonexistent-profile' }],
};
const result = resolver.resolveRoute(route, metadata);
// Route should be unchanged
expect(result.route.security).toBeUndefined();
expect(result.metadata.sourceProfileName).toBeUndefined();
expect(result.metadata.sourceBindings![0].sourceProfileName).toBeUndefined();
});
// ---- Profile inheritance ----
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
tap.test('should resolve source profile inheritance for apply-time compiler use', async () => {
const baseProfile = makeProfile({
id: 'base-profile',
name: 'BASE',
@@ -160,46 +116,12 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
});
injectProfile(resolver, extendedProfile);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
const result = resolver.resolveRoute(route, metadata);
// Should have IPs from both base and extended profiles
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
// maxConnections from base (extended doesn't override)
expect(result.route.security!.maxConnections).toEqual(500);
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
const security = resolver.resolveSourceProfileSecurity('extended-profile')!;
expect(security.ipAllowList).toContain('10.0.0.0/8');
expect(security.ipAllowList).toContain('160.79.104.0/21');
expect(security.maxConnections).toEqual(500);
});
tap.test('should detect circular profile inheritance', async () => {
const profileA = makeProfile({
id: 'circular-a',
name: 'A',
security: { ipAllowList: ['1.1.1.1'] },
extendsProfiles: ['circular-b'],
});
const profileB = makeProfile({
id: 'circular-b',
name: 'B',
security: { ipAllowList: ['2.2.2.2'] },
extendsProfiles: ['circular-a'],
});
injectProfile(resolver, profileA);
injectProfile(resolver, profileB);
const route = makeRoute();
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
// Should not infinite loop — resolves what it can
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeTruthy();
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
});
// ---- Network target resolution ----
tap.test('should resolve network target onto a route', async () => {
const target = makeTarget();
injectTarget(resolver, target);
@@ -209,86 +131,34 @@ tap.test('should resolve network target onto a route', async () => {
const result = resolver.resolveRoute(route, metadata);
expect(result.route.action.targets).toBeTruthy();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
expect(result.metadata.networkTargetName).toEqual('INFRA');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
tap.test('should handle missing target gracefully', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
const result = resolver.resolveRoute(route, metadata);
// Route targets should be unchanged (still the placeholder)
expect(result.route.action.targets![0].host).toEqual('placeholder');
expect(result.metadata.networkTargetName).toBeUndefined();
});
// ---- Combined resolution ----
tap.test('should resolve both profile and target simultaneously', async () => {
tap.test('should resolve source bindings and target references together', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourceProfileRef: 'profile-1',
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
};
const result = resolver.resolveRoute(route, metadata);
// Security from profile
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
expect(result.route.security!.maxConnections).toEqual(1000);
// Target from network target
expect(result.route.security).toBeUndefined();
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
expect(result.route.action.targets![0].port).toEqual(443);
// Both names recorded
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.networkTargetName).toEqual('INFRA');
});
tap.test('should skip resolution when no metadata refs', async () => {
const route = makeRoute({
security: { ipAllowList: ['1.2.3.4'] },
});
const metadata: IRouteMetadata = {};
const result = resolver.resolveRoute(route, metadata);
// Route should be completely unchanged
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
expect(result.route.security!.ipAllowList!.length).toEqual(1);
expect(result.route.action.targets![0].host).toEqual('placeholder');
});
tap.test('should be idempotent — resolving twice gives same result', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourceProfileRef: 'profile-1',
networkTargetRef: 'target-1',
};
const first = resolver.resolveRoute(route, metadata);
const second = resolver.resolveRoute(first.route, first.metadata);
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
});
// ---- Lookup helpers ----
tap.test('should find routes by profile ref (sync)', async () => {
tap.test('should find routes by source binding profile ref only', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-a', {
id: 'route-a',
route: makeRoute({ name: 'route-a' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1' },
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
});
storedRoutes.set('route-b', {
id: 'route-b',
@@ -300,37 +170,31 @@ tap.test('should find routes by profile ref (sync)', async () => {
id: 'route-c',
route: makeRoute({ name: 'route-c' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
metadata: {
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
},
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
expect(profileRefs.length).toEqual(2);
expect(profileRefs).toHaveLength(2);
expect(profileRefs).toContain('route-a');
expect(profileRefs).toContain('route-c');
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
expect(targetRefs.length).toEqual(2);
expect(targetRefs).toHaveLength(2);
expect(targetRefs).toContain('route-b');
expect(targetRefs).toContain('route-c');
});
tap.test('should get profile usage for a specific profile ID', async () => {
tap.test('should get profile and target usage for specific IDs', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-x', {
id: 'route-x',
route: makeRoute({ name: 'my-route' }),
enabled: true,
metadata: { sourceProfileRef: 'profile-1' },
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
});
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-x');
expect(usage[0].routeName).toEqual('my-route');
});
tap.test('should get target usage for a specific target ID', async () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-y', {
id: 'route-y',
route: makeRoute({ name: 'other-route' }),
@@ -338,34 +202,20 @@ tap.test('should get target usage for a specific target ID', async () => {
metadata: { networkTargetRef: 'target-1' },
});
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(usage.length).toEqual(1);
expect(usage[0].id).toEqual('route-y');
expect(usage[0].routeName).toEqual('other-route');
const profileUsage = resolver.getProfileUsageForId('profile-1', storedRoutes);
expect(profileUsage).toHaveLength(1);
expect(profileUsage[0].routeName).toEqual('my-route');
const targetUsage = resolver.getTargetUsageForId('target-1', storedRoutes);
expect(targetUsage).toHaveLength(1);
expect(targetUsage[0].routeName).toEqual('other-route');
});
// ---- Profile/target getters ----
tap.test('should get profile by name', async () => {
const profile = resolver.getProfileByName('STANDARD');
expect(profile).toBeTruthy();
expect(profile!.id).toEqual('profile-1');
});
tap.test('should get target by name', async () => {
const target = resolver.getTargetByName('INFRA');
expect(target).toBeTruthy();
expect(target!.id).toEqual('target-1');
});
tap.test('should return undefined for nonexistent profile name', async () => {
const profile = resolver.getProfileByName('NONEXISTENT');
expect(profile).toBeUndefined();
});
tap.test('should return undefined for nonexistent target name', async () => {
const target = resolver.getTargetByName('NONEXISTENT');
expect(target).toBeUndefined();
tap.test('should get profiles and targets by name', async () => {
expect(resolver.getProfileByName('STANDARD')!.id).toEqual('profile-1');
expect(resolver.getTargetByName('INFRA')!.id).toEqual('target-1');
expect(resolver.getProfileByName('NONEXISTENT')).toBeUndefined();
expect(resolver.getTargetByName('NONEXISTENT')).toBeUndefined();
});
export default tap.start();
+296
View File
@@ -0,0 +1,296 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import { Buffer } from 'node:buffer';
import * as http from 'node:http';
import * as net from 'node:net';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startBackend(
handler: http.RequestListener = (_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
},
): Promise<{ server: http.Server; port: number }> {
const server = http.createServer(handler);
const port = await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : 0);
});
});
return { server, port };
}
async function closeServer(server: http.Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
async function readResponseBody(response: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
tap.test('SmartProxy route rateLimit returns 429 after threshold', async () => {
const backend = await startBackend();
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
name: 'rate-limit-smoke',
match: {
ports: proxyPort,
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'too many requests',
},
},
},
],
});
try {
await proxy.start();
const firstResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const secondResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const firstBody = await firstResponse.text();
const secondBody = await secondResponse.text();
expect(firstResponse.status).toEqual(200);
expect(firstBody).toEqual('ok');
expect(secondResponse.status).toEqual(429);
expect(secondBody).toContain('too many requests');
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy rateLimit is terminal and does not fall through to a lower priority route', async () => {
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('limited');
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-rate-limit',
name: 'terminal-rate-limit',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'limited route exceeded',
},
},
},
{
id: 'lower-priority-fallback',
name: 'lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
const firstResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const secondResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const firstBody = await readResponseBody(firstResponse);
const secondBody = await readResponseBody(secondResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody).toContain('limited route exceeded');
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
tap.test('SmartProxy route maxConnections returns 429 when concurrent limit is exceeded', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const backend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('released'));
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'max-connections-smoke',
name: 'max-connections-smoke',
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
maxConnections: 1,
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold');
secondResponse = await requestHeaders(proxyPort, '/blocked');
expect(firstResponse.statusCode).toEqual(200);
expect(secondResponse.statusCode).toEqual(429);
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
expect(await readResponseBody(firstResponse)).toEqual('released');
expect(secondBody.length > 0).toBeTrue();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy maxConnections is terminal and does not fall through to a lower priority route', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('limited released'));
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-max-connections',
name: 'terminal-max-connections',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
maxConnections: 1,
},
},
{
id: 'max-connections-lower-priority-fallback',
name: 'max-connections-lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold', { host: 'limited.local' });
secondResponse = await requestHeaders(proxyPort, '/blocked', { host: 'limited.local' });
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
const firstBody = await readResponseBody(firstResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited released');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
export default tap.start();
+937
View File
@@ -0,0 +1,937 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { SourcePolicyCompiler, sourcePolicyLimits } from '../ts/config/classes.source-policy-compiler.js';
import type { ISourceProfile, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
function makeRoute(): IRouteConfig {
return {
id: 'route-1',
name: 'gitea',
priority: 10,
match: { ports: 443, domains: 'code.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
};
}
function makeProfile(profile: Partial<ISourceProfile> & Pick<ISourceProfile, 'id' | 'name'>): ISourceProfile {
return {
description: '',
security: {},
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
...profile,
};
}
tap.test('source policy compiler expands one route into ordered source variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'ai' },
{ sourceProfileRef: 'public' },
],
};
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:Trusted');
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[0].security?.ipAllowList).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(30);
expect(variants[2].match.clientIp).toBeUndefined();
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority! + 1);
});
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
onExceeded: { type: '429', errorMessage: 'Slow down' },
},
],
};
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variant.security?.rateLimit?.maxRequests).toEqual(10);
expect(variant.security?.rateLimit?.errorMessage).toEqual('Slow down');
});
tap.test('source policy compiler forces source-policy rate limits to source IP keys', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'header',
headerName: 'x-forwarded-for',
},
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'header',
headerName: 'x-client-id',
},
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: ['/git'],
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
},
],
},
],
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[0].security?.rateLimit?.headerName).toBeUndefined();
expect(variants[1].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[1].security?.rateLimit?.headerName).toBeUndefined();
});
tap.test('source policy binding can split Gitea path classes before its fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'ai',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: ['/*/*.git/info/refs'],
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
},
],
},
{ sourceProfileRef: 'public' },
],
},
resolver,
'route-1',
);
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:AI Crawlers:path:Git Smart HTTP');
expect(variants[0].match.clientIp).toEqual(['203.0.113.0/24']);
expect(variants[0].match.path).toEqual('/*/*.git/info/refs');
expect(variants[0].security?.rateLimit?.maxRequests).toEqual(600);
expect(variants[1].name).toEqual('gitea:source:AI Crawlers:path:Normal HTML');
expect(variants[1].match.path).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(20);
expect(variants[2].name).toEqual('gitea:source:Public');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
});
tap.test('source policy compiler uses built-in Gitea path class patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }],
},
],
},
resolver,
'route-1',
);
expect(variants.map((variant) => variant.match.path)).toEqual([
'/*/*.git/info/refs',
'/*/*.git/git-upload-pack',
'/*/*.git/git-receive-pack',
'/*/*.git/info/lfs',
'/*/*.git/info/lfs/*',
undefined,
]);
expect(variants[0].id).toEqual('route-1:source:public:path:git-smart-http:1');
expect(variants[5].id).toEqual('route-1:source:public');
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
});
tap.test('source policy compiler keeps path-specific variants above fallback variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'normal-html',
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
},
{
pathClass: 'git-smart-http',
pathPatterns: ['/*/*.git/info/refs'],
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
},
],
},
],
},
resolver,
'route-1',
);
const fallbackVariant = variants.find((variant) => variant.match.path === undefined)!;
const gitVariant = variants.find((variant) => variant.match.path === '/*/*.git/info/refs')!;
expect(gitVariant.priority! > fallbackVariant.priority!).toBeTrue();
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
});
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{ sourceProfileRef: 'public' },
{ sourceProfileRef: 'trusted' },
],
},
resolver,
'route-1',
);
expect(variants).toEqual([]);
});
tap.test('source policy compiler adds terminal deny fallback for private-only bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[1].id).toEqual('route-1:source:deny-fallback');
expect(variants[1].match.clientIp).toBeUndefined();
expect(variants[1].action.type).toEqual('socket-handler');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > makeRoute().priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const pathPolicies = Array.from({ length: sourcePolicyLimits.maxPathPoliciesPerBinding }, (_policy, policyIndex) => ({
pathClass: 'git-smart-http' as const,
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy },
(_pattern, patternIndex) => `/heavy-${policyIndex}-${patternIndex}`,
),
}));
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
};
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('source policy compiler fails closed when configured bindings cannot compile', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [
{ sourceProfileRef: 'empty-ai' },
],
},
resolver,
'route-1',
);
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
undefined,
'route-1',
);
expect(emptyProfileVariants.length).toEqual(0);
expect(missingResolverVariants.length).toEqual(0);
});
tap.test('source policy compiler keeps generated priorities inside SmartProxy bounds', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const route = makeRoute();
route.priority = 9000;
const variants = SourcePolicyCompiler.compileRoute(
route,
{
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
},
],
},
resolver,
'route-1',
);
expect(variants.length > 0).toBeTrue();
expect(variants.every((variant) => variant.priority! <= 10000 && variant.priority! >= 0)).toBeTrue();
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when route priority lacks variant headroom', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const route = makeRoute();
route.priority = 10000;
const metadata: IRouteMetadata = {
sourceBindings: [{ sourceProfileRef: 'trusted' }],
};
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings, route)).toContain('priority headroom');
expect(SourcePolicyCompiler.compileRoute(route, metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'public' },
],
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0][0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(appliedRoutes[0][1].match.clientIp).toBeUndefined();
expect(appliedRoutes[0][1].security?.rateLimit?.maxRequests).toEqual(120);
});
tap.test('RouteConfigManager does not apply an uncompiled source-policy route', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(0);
});
tap.test('RouteConfigManager fail-closes managed routes without source bindings', async () => {
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
gatewayClientAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
},
});
await manager.applyRoutes();
expect(appliedRoutes).toHaveLength(1);
expect(appliedRoutes[0]).toHaveLength(0);
});
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('Wildcard source profile bindings must be last');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'missing' not found");
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: { ipAllowList: [] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).persistRoute = async () => undefined;
(manager as any).applyRoutes = async () => undefined;
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeTrue();
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
route: {
match: { ports: [{ from: 1, to: 1_000_000_000 }], domains: 'code.example.com' },
} as any,
});
expect(result.success).toBeFalse();
expect(result.message).toContain('compiled route-port variants');
expect(manager.getRoute('route-1')?.route.match.ports).toEqual(443);
});
tap.test('RouteConfigManager rejects negative source-policy maxConnections overrides', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('maxConnections');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'ip',
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
},
},
],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('rate limit error message');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
(_item, index) => `/too-many-${index}`,
),
},
],
},
],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('path patterns');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
});
export default tap.start();
+23
View File
@@ -108,6 +108,11 @@ const makeRouteConfigManager = () => {
if (!storedRoute) return { success: false, message: 'Route not found' };
if (patch.route) {
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
for (const [key, value] of Object.entries(patch.route)) {
if (value === null) {
delete (storedRoute.route as any)[key];
}
}
}
if (patch.enabled !== undefined) {
storedRoute.enabled = patch.enabled;
@@ -126,6 +131,20 @@ const makeRouteConfigManager = () => {
};
};
const standardSourceProfile: interfaces.data.ISourceProfile = {
id: 'standard',
name: 'STANDARD',
description: 'Standard test profile',
security: { ipAllowList: ['10.0.0.0/8'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
};
const makeReferenceResolver = () => ({
listProfiles: () => [standardSourceProfile],
});
const setupHandler = (options: {
scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
@@ -146,6 +165,7 @@ const setupHandler = (options: {
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
referenceResolver: makeReferenceResolver(),
...options.dcRouterRef,
},
};
@@ -244,6 +264,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
expect(createdRoute.createdBy).toEqual('token-user');
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
expect(createdRoute.metadata).toEqual({
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
@@ -253,6 +274,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
workAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
});
createdRoute.route.security = { ipAllowList: ['*'] };
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
@@ -275,6 +297,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.38.3',
version: '13.43.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+331 -86
View File
@@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -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,18 +591,13 @@ 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,
(preparedRoutes) => buildHttpRedirectRuntimeRoutes(preparedRoutes || []),
(storedRoute: IRoute) => this.hydrateStoredRouteForRuntime(storedRoute),
);
this.apiTokenManager = new ApiTokenManager();
@@ -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;
}
/**
+28 -1
View File
@@ -68,11 +68,38 @@ export class DbSeeder {
}
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
{
name: 'TRUSTED NETWORKS',
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
security: {
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
maxConnections: 5000,
},
},
{
name: 'AI CRAWLERS',
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
security: {
ipAllowList: [],
rateLimit: {
enabled: true,
maxRequests: 30,
window: 60,
keyBy: 'ip',
},
},
},
{
name: 'PUBLIC',
description: 'Allow all traffic — no IP restrictions',
description: 'Public fallback source profile with per-IP request limiting',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
},
},
},
{
+87 -26
View File
@@ -7,6 +7,7 @@ import type {
IRouteMetadata,
IRoute,
IRouteSecurity,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
@@ -107,7 +108,7 @@ export class ReferenceResolver {
// If force-deleting with referencing routes, clear refs but keep resolved values
if (affectedIds.length > 0) {
await this.clearProfileRefsOnRoutes(affectedIds);
await this.clearProfileRefsOnRoutes(id, affectedIds, storedRoutes);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
@@ -131,15 +132,22 @@ export class ReferenceResolver {
return [...this.profiles.values()];
}
public resolveSourceProfileSecurity(profileId: string): IRouteSecurity | null {
const resolvedSecurity = this.resolveSourceProfile(profileId);
return resolvedSecurity ? this.cloneSecurityFields(resolvedSecurity) : null;
}
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.sourceProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
const refs = this.getSourceProfileRefsFromMetadata(stored.metadata);
for (const ref of refs) {
if (usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
}
return usage;
@@ -151,7 +159,7 @@ export class ReferenceResolver {
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
@@ -280,7 +288,8 @@ export class ReferenceResolver {
/**
* Resolve references for a single route.
* Materializes source profile and/or network target into the route's fields.
* Resolves source binding display names and/or network target references.
* Source profile security is resolved at apply time by SourcePolicyCompiler.
* Returns the resolved route and updated metadata.
*/
public resolveRoute(
@@ -289,19 +298,11 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.sourceProfileRef) {
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),
};
resolvedMetadata.sourceProfileName = profile?.name;
if (resolvedMetadata.sourceBindings?.length) {
const resolvedSourceBindings = this.resolveRouteSourceBindings(resolvedMetadata.sourceBindings);
if (resolvedSourceBindings) {
resolvedMetadata.sourceBindings = resolvedSourceBindings;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
}
}
@@ -336,7 +337,7 @@ export class ReferenceResolver {
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await RouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.filter((doc) => this.metadataUsesSourceProfile(doc.metadata, profileId))
.map((doc) => doc.id);
}
@@ -350,7 +351,7 @@ export class ReferenceResolver {
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
ids.push(routeId);
}
}
@@ -371,6 +372,38 @@ export class ReferenceResolver {
// Private: source profile resolution with inheritance
// =========================================================================
private resolveRouteSourceBindings(sourceBindings: IRouteSourceBinding[]): IRouteSourceBinding[] | undefined {
const bindings = sourceBindings
.map((binding) => {
const profile = this.profiles.get(binding.sourceProfileRef);
if (!profile) {
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source binding resolution`);
return binding;
}
return {
...binding,
sourceProfileName: profile.name,
};
})
.filter((binding) => binding.sourceProfileRef);
return bindings.length > 0 ? bindings : undefined;
}
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
return this.getSourceProfileRefsFromMetadata(metadata).includes(profileId);
}
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
const refs = new Set<string>();
for (const binding of metadata?.sourceBindings || []) {
if (binding.sourceProfileRef) {
refs.add(binding.sourceProfileRef);
}
}
return [...refs];
}
private resolveSourceProfile(
profileId: string,
visited: Set<string> = new Set(),
@@ -445,10 +478,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
// =========================================================================
@@ -545,21 +583,44 @@ export class ReferenceResolver {
// Private: ref cleanup on force-delete
// =========================================================================
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
private async clearProfileRefsOnRoutes(
profileId: string,
routeIds: string[],
storedRoutes?: Map<string, IRoute>,
): Promise<void> {
for (const routeId of routeIds) {
const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
sourceProfileRef: undefined,
sourceProfileName: undefined,
};
doc.metadata = this.clearSourceProfileFromMetadata(doc.metadata, profileId);
doc.updatedAt = Date.now();
await doc.save();
}
const storedRoute = storedRoutes?.get(routeId);
if (storedRoute?.metadata) {
storedRoute.metadata = this.clearSourceProfileFromMetadata(storedRoute.metadata, profileId);
storedRoute.updatedAt = Date.now();
}
}
}
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
const sourceBindings = metadata.sourceBindings?.length
? metadata.sourceBindings.filter((binding) => binding.sourceProfileRef !== profileId)
: undefined;
const nextMetadata: IRouteMetadata = {
...metadata,
sourceBindings: sourceBindings?.length ? sourceBindings : undefined,
};
if (!nextMetadata.sourceBindings && !nextMetadata.networkTargetRef) {
nextMetadata.lastResolvedAt = undefined;
}
return nextMetadata;
}
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await RouteDoc.findById(routeId);
+197 -18
View File
@@ -1,15 +1,22 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { RouteDoc } from '../db/index.js';
import { routePathClasses } from '../../ts_interfaces/data/route-management.js';
import type {
IHttpRedirectInfo,
IRoute,
IMergedRoute,
IRouteWarning,
IRouteMetadata,
IRoutePathPolicyBinding,
IRouteSourceBinding,
IRouteSecurity,
} from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
import { type IHttp3Config, augmentRouteWithHttp3 } from '../http3/index.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
import { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
import { deriveHttpRedirects } from './helpers.http-redirects.js';
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
@@ -59,7 +66,7 @@ export class RouteConfigManager {
private getVpnClientAccessForRoute?: (route: IDcRouterRouteConfig, routeId?: string) => TVpnClientAllowEntry[],
private referenceResolver?: ReferenceResolver,
private onRoutesApplied?: (routes: plugins.smartproxy.IRouteConfig[]) => void | Promise<void>,
private getRuntimeRoutes?: () => plugins.smartproxy.IRouteConfig[],
private getRuntimeRoutes?: (preparedRoutes?: plugins.smartproxy.IRouteConfig[]) => plugins.smartproxy.IRouteConfig[],
private hydrateStoredRoute?: (storedRoute: IRoute) => plugins.smartproxy.IRouteConfig | undefined,
) {}
@@ -119,6 +126,10 @@ export class RouteConfigManager {
return { routes: merged, warnings: [...this.warnings] };
}
public getHttpRedirects(): IHttpRedirectInfo[] {
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
}
// =========================================================================
// Route CRUD
// =========================================================================
@@ -131,6 +142,10 @@ export class RouteConfigManager {
): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
if (sourceBindingsPayloadError) {
throw new Error(sourceBindingsPayloadError);
}
// Ensure route has a name
if (!route.name) {
@@ -144,6 +159,10 @@ export class RouteConfigManager {
route = resolved.route;
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route);
if (sourceBindingsValidationError) {
throw new Error(sourceBindingsValidationError);
}
const stored: IRoute = {
id,
@@ -174,6 +193,14 @@ export class RouteConfigManager {
if (!stored) {
return { success: false, message: 'Route not found' };
}
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings);
if (sourceBindingsPayloadError) {
return { success: false, message: sourceBindingsPayloadError };
}
const previousRoute = structuredClone(stored.route);
const previousMetadata = structuredClone(stored.metadata);
const previousEnabled = stored.enabled;
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
@@ -225,6 +252,14 @@ export class RouteConfigManager {
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route);
if (sourceBindingsValidationError) {
stored.route = previousRoute;
stored.metadata = previousMetadata;
stored.enabled = previousEnabled;
return { success: false, message: sourceBindingsValidationError };
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
@@ -444,9 +479,8 @@ export class RouteConfigManager {
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
sourceBindings: this.normalizeSourceBindings(metadata.sourceBindings),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
? metadata.lastResolvedAt
@@ -467,13 +501,10 @@ export class RouteConfigManager {
externalKey: normalizeString(metadata.externalKey),
};
if (!normalized.sourceProfileRef) {
normalized.sourceProfileName = undefined;
}
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
@@ -498,6 +529,127 @@ export class RouteConfigManager {
return normalized;
}
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): IRouteSourceBinding[] | undefined {
if (!Array.isArray(sourceBindings)) {
return undefined;
}
const normalizedBindings: IRouteSourceBinding[] = [];
for (const binding of sourceBindings) {
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
? binding.sourceProfileRef.trim()
: '';
if (!sourceProfileRef) {
continue;
}
const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit);
const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies);
normalizedBindings.push({
...(typeof binding.id === 'string' && binding.id.trim() ? { id: binding.id.trim() } : {}),
sourceProfileRef,
...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim()
? { sourceProfileName: binding.sourceProfileName.trim() }
: {}),
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0
? { maxConnections: binding.maxConnections }
: {}),
...(binding.onExceeded?.type === '429'
? {
onExceeded: {
type: '429' as const,
...(typeof binding.onExceeded.errorMessage === 'string' && binding.onExceeded.errorMessage.trim()
? { errorMessage: binding.onExceeded.errorMessage.trim() }
: {}),
},
}
: {}),
...(normalizedPathPolicies ? { pathPolicies: normalizedPathPolicies } : {}),
});
}
return normalizedBindings.length > 0 ? normalizedBindings : undefined;
}
private normalizePathPolicies(
pathPolicies?: IRoutePathPolicyBinding[],
): IRoutePathPolicyBinding[] | undefined {
if (!Array.isArray(pathPolicies)) {
return undefined;
}
const validClasses = new Set<string>(routePathClasses);
const normalizedPathPolicies: IRoutePathPolicyBinding[] = [];
for (const pathPolicy of pathPolicies) {
if (!validClasses.has(pathPolicy.pathClass)) {
continue;
}
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
const pathPatterns = Array.isArray(pathPolicy.pathPatterns)
? [...new Set(pathPolicy.pathPatterns
.map((pattern) => typeof pattern === 'string' ? pattern.trim() : '')
.filter(Boolean))]
: undefined;
normalizedPathPolicies.push({
...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}),
pathClass: pathPolicy.pathClass,
...(pathPatterns?.length ? { pathPatterns } : {}),
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0
? { maxConnections: pathPolicy.maxConnections }
: {}),
...(pathPolicy.onExceeded?.type === '429'
? {
onExceeded: {
type: '429' as const,
...(typeof pathPolicy.onExceeded.errorMessage === 'string' && pathPolicy.onExceeded.errorMessage.trim()
? { errorMessage: pathPolicy.onExceeded.errorMessage.trim() }
: {}),
},
}
: {}),
});
}
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
}
private validateSourceBindings(
sourceBindings: IRouteSourceBinding[] | undefined,
route: IDcRouterRouteConfig,
): string | undefined {
const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route);
if (shapeError) {
return shapeError;
}
return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver);
}
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const maxRequests = Number(rateLimit.maxRequests);
const window = Number(rateLimit.window);
if (!Number.isFinite(maxRequests) || maxRequests < 0 || !Number.isFinite(window) || window < 0) {
return undefined;
}
return {
enabled: rateLimit.enabled !== false,
maxRequests,
window,
keyBy: 'ip',
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
? { errorMessage: rateLimit.errorMessage.trim() }
: {}),
};
}
// =========================================================================
// Private: warnings
// =========================================================================
@@ -558,16 +710,9 @@ export class RouteConfigManager {
const smartProxy = this.getSmartProxy();
if (!smartProxy) return;
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
// Add all enabled routes with HTTP/3 and VPN augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
enabledRoutes.push(this.prepareStoredRouteForApply(route));
}
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
@@ -583,9 +728,43 @@ export class RouteConfigManager {
});
}
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
private getPreparedEnabledRoutesForApply(): plugins.smartproxy.IRouteConfig[] {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add all enabled routes with HTTP/3, VPN, and source-policy augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
}
}
return enabledRoutes;
}
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
if (this.isManagedAccessRoute(storedRoute) && !storedRoute.metadata?.sourceBindings?.length) {
return [];
}
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
const sourceBoundRoutes = SourcePolicyCompiler.compileRoute(
hydratedRoute || storedRoute.route,
storedRoute.metadata,
this.referenceResolver,
storedRoute.id,
);
return sourceBoundRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
}
private isManagedAccessRoute(storedRoute: IRoute): boolean {
const metadata = storedRoute.metadata;
if (storedRoute.origin !== 'api' || !metadata) {
return false;
}
return metadata.ownerType === 'gatewayClient'
|| metadata.ownerType === 'workhoster'
|| Boolean(metadata.gatewayClientId)
|| Boolean(metadata.workHosterId)
|| Boolean(metadata.externalKey);
}
private prepareRouteForApply(
+731
View File
@@ -0,0 +1,731 @@
import * as plugins from '../plugins.js';
import {
giteaRoutePathClassLabels,
giteaRoutePathClassPatterns,
routePathClasses,
} from '../../ts_interfaces/data/route-management.js';
import type {
IRoutePathPolicyBinding,
IRouteMetadata,
IRouteSecurity,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
const MIN_ROUTE_PRIORITY = 0;
const MAX_ROUTE_PRIORITY = 10000;
const SOURCE_PRIORITY_BAND = 0.0008;
const PATH_PRIORITY_BAND = 0.0001;
export const sourcePolicyLimits = {
maxBindings: 16,
maxPathPoliciesPerBinding: 12,
maxPathPatternsPerPolicy: 64,
maxPathPatternLength: 256,
maxPathPatternWildcards: 8,
maxSourceProfileRefLength: 256,
maxIdLength: 128,
maxExceededMessageLength: 512,
maxCompiledVariantsPerRoute: 512,
} as const;
export class SourcePolicyCompiler {
public static compileRoute(
route: plugins.smartproxy.IRouteConfig,
metadata: IRouteMetadata | undefined,
referenceResolver: ReferenceResolver | undefined,
routeId?: string,
): plugins.smartproxy.IRouteConfig[] {
const bindings = metadata?.sourceBindings || [];
if (bindings.length === 0) {
return [route];
}
if (this.validateSourceBindingsShape(bindings, route)) {
return [];
}
if (!referenceResolver) {
return [];
}
if (this.validateResolvedSourceBindings(bindings, referenceResolver)) {
return [];
}
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const basePriority = route.priority ?? 0;
let hasAllSourcesBinding = false;
bindings.forEach((binding, index) => {
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profile || !profileSecurity) {
return;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return;
}
if (this.matchesAllSources(sourceMatches)) {
hasAllSourcesBinding = true;
}
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
const sourceMatch = this.matchesAllSources(sourceMatches)
? { ...route.match }
: { ...route.match, clientIp: sourceMatches };
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
return;
}
let hasSourceFallback = false;
pathPolicies.forEach((pathPolicy, pathIndex) => {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length === 0) {
hasSourceFallback = true;
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
}));
return;
}
pathPatterns.forEach((pathPattern, pathPatternIndex) => {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
pathPattern,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
pathPatternIndex,
pathPatternCount: pathPatterns.length,
}));
});
});
if (!hasSourceFallback) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
}
});
if (compiledRoutes.length > 0 && !hasAllSourcesBinding) {
compiledRoutes.push(this.buildDenyFallbackRoute(route, basePriority, routeId));
}
return this.applyIntegerPriorities(compiledRoutes, basePriority);
}
public static validateSourceBindingsPayload(sourceBindings?: Partial<IRouteSourceBinding>[]): string | undefined {
if (sourceBindings === undefined) {
return undefined;
}
if (!Array.isArray(sourceBindings)) {
return 'Source bindings must be an array';
}
if (sourceBindings.length === 0) {
return undefined;
}
if (sourceBindings.length > sourcePolicyLimits.maxBindings) {
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
}
const validClasses = new Set<string>(routePathClasses);
for (const binding of sourceBindings) {
if (!binding || typeof binding !== 'object') {
return 'Source binding must be an object';
}
if (typeof binding.sourceProfileRef !== 'string') {
return 'Source binding requires a source profile';
}
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
return `Source binding source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
}
if (binding.sourceProfileRef.trim().length === 0) {
return 'Source binding requires a source profile';
}
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
return `Source binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
return 'Source policy maxConnections must be non-negative';
}
const bindingRateLimitError = this.validateRateLimitPayload(binding.rateLimit);
if (bindingRateLimitError) {
return bindingRateLimitError;
}
const bindingMessage = binding.onExceeded?.errorMessage;
if (typeof bindingMessage === 'string' && bindingMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPolicies = binding.pathPolicies;
if (pathPolicies === undefined) {
continue;
}
if (!Array.isArray(pathPolicies)) {
return 'Source policy path policies must be an array';
}
if (pathPolicies.length > sourcePolicyLimits.maxPathPoliciesPerBinding) {
return `Source policy binding exceeds ${sourcePolicyLimits.maxPathPoliciesPerBinding} path policies`;
}
for (const pathPolicy of pathPolicies) {
if (!pathPolicy || typeof pathPolicy !== 'object') {
return 'Source policy path policy must be an object';
}
if (!validClasses.has(pathPolicy.pathClass)) {
return 'Source policy path policy uses an unsupported path class';
}
if (typeof pathPolicy.id === 'string' && pathPolicy.id.length > sourcePolicyLimits.maxIdLength) {
return `Source policy path policy id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof pathPolicy.maxConnections === 'number' && pathPolicy.maxConnections < 0) {
return 'Source policy path policy maxConnections must be non-negative';
}
const pathRateLimitError = this.validateRateLimitPayload(pathPolicy.rateLimit);
if (pathRateLimitError) {
return pathRateLimitError;
}
const pathMessage = pathPolicy.onExceeded?.errorMessage;
if (typeof pathMessage === 'string' && pathMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPatterns = pathPolicy.pathPatterns;
if (pathPatterns === undefined) {
continue;
}
if (!Array.isArray(pathPatterns)) {
return 'Source policy path patterns must be an array';
}
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class exceeds ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
for (const pattern of pathPatterns) {
if (typeof pattern !== 'string') {
return 'Source policy path pattern must be a string';
}
if (pattern.length > sourcePolicyLimits.maxPathPatternLength) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternLength} characters`;
}
const wildcardCount = pattern.split('*').length - 1;
if (wildcardCount > sourcePolicyLimits.maxPathPatternWildcards) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternWildcards} wildcards`;
}
}
}
}
return undefined;
}
private static validateRateLimitPayload(rateLimit: IRouteSecurity['rateLimit'] | undefined): string | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const rawRateLimit = rateLimit as unknown as Record<string, unknown>;
for (const key of ['maxRequests', 'window'] as const) {
const value = rawRateLimit[key];
if (typeof value === 'string' && value.length > 32) {
return `Source policy rate limit ${key} exceeds 32 characters`;
}
}
if (
typeof rateLimit.errorMessage === 'string'
&& rateLimit.errorMessage.length > sourcePolicyLimits.maxExceededMessageLength
) {
return `Source policy rate limit error message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
return undefined;
}
public static validateSourcePolicyShape(
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
return this.validateSourceBindingsShape(sourceBindings, route);
}
public static validateSourceBindingsShape(
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
const payloadError = this.validateSourceBindingsPayload(sourceBindings);
if (payloadError) {
return payloadError;
}
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
let estimatedCompiledRoutes = 0;
for (const binding of bindings) {
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
estimatedCompiledRoutes++;
} else {
let hasSourceFallback = false;
for (const pathPolicy of pathPolicies) {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class expands beyond ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
if (pathPatterns.length === 0) {
hasSourceFallback = true;
estimatedCompiledRoutes++;
} else {
estimatedCompiledRoutes += pathPatterns.length;
}
}
if (!hasSourceFallback) {
estimatedCompiledRoutes++;
}
}
if (estimatedCompiledRoutes > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route variants`;
}
}
// Private-only source bindings add one terminal deny route to prevent fall-through
// to broader routes with the same host/path/port scope.
estimatedCompiledRoutes++;
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
}
if (route && typeof route.priority === 'number' && Number.isFinite(route.priority)) {
const integerBasePriority = Math.trunc(this.clampPriority(route.priority));
if (integerBasePriority + estimatedCompiledRoutes > MAX_ROUTE_PRIORITY) {
return `Source policy route priority leaves no priority headroom for ${estimatedCompiledRoutes} compiled variants`;
}
}
return undefined;
}
public static validateResolvedSourcePolicy(
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
return this.validateResolvedSourceBindings(sourceBindings, referenceResolver);
}
public static validateResolvedSourceBindings(
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
if (!referenceResolver) {
return 'Source policy requires source profile resolution';
}
for (let index = 0; index < bindings.length; index++) {
const binding = bindings[index];
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
if (!profile) {
return `Source profile '${binding.sourceProfileRef}' not found`;
}
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profileSecurity) {
return `Source profile '${profile.name}' could not be resolved`;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return `Source profile '${profile.name}' has no source matches`;
}
const matchesAllSources = this.matchesAllSources(sourceMatches);
if (matchesAllSources && index < bindings.length - 1) {
return 'Wildcard source profile bindings must be last in source bindings';
}
}
return undefined;
}
private static buildCompiledRoute(options: {
route: plugins.smartproxy.IRouteConfig;
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
profileName: string;
profileSecurity: IRouteSecurity;
binding: IRouteSourceBinding;
pathPolicy?: IRoutePathPolicyBinding;
pathPattern?: string;
sourcePriority: number;
routeId?: string;
sourceIndex: number;
pathIndex?: number;
pathPolicyCount?: number;
pathPatternIndex?: number;
pathPatternCount?: number;
}): plugins.smartproxy.IRouteConfig {
const routeKey = options.route.id || options.routeId || options.route.name || 'route';
const bindingKey = options.binding.id || options.binding.sourceProfileRef || String(options.sourceIndex + 1);
const pathPolicyKey = options.pathPolicy
? options.pathPolicy.id || options.pathPolicy.pathClass
: undefined;
const pathLabel = options.pathPolicy
? giteaRoutePathClassLabels[options.pathPolicy.pathClass]
: undefined;
const pathPatternSuffix = options.pathPatternCount && options.pathPatternCount > 1
? `:${(options.pathPatternIndex || 0) + 1}`
: '';
const pathPriority = options.pathPolicy
? this.calculatePathPriorityOffset(
options.pathPattern,
options.pathIndex || 0,
options.pathPolicyCount || 1,
options.pathPatternIndex || 0,
options.pathPatternCount || 1,
)
: 0;
return {
...options.route,
id: pathPolicyKey
? `${routeKey}:source:${bindingKey}:path:${pathPolicyKey}${pathPatternSuffix}`
: `${routeKey}:source:${bindingKey}`,
name: pathLabel
? `${options.route.name || routeKey}:source:${options.profileName}:path:${pathLabel}${pathPatternSuffix}`
: `${options.route.name || routeKey}:source:${options.profileName}`,
match: options.pathPattern
? { ...options.sourceMatch, path: options.pathPattern }
: { ...options.sourceMatch },
priority: this.clampPriority(options.sourcePriority + pathPriority),
security: this.buildBindingSecurity(
options.route.security,
options.profileSecurity,
options.binding,
options.pathPolicy,
),
};
}
private static buildDenyFallbackRoute(
route: plugins.smartproxy.IRouteConfig,
basePriority: number,
routeId?: string,
): plugins.smartproxy.IRouteConfig {
const routeKey = route.id || routeId || route.name || 'route';
return {
...route,
id: `${routeKey}:source:deny-fallback`,
name: `${route.name || routeKey}:source:deny-fallback`,
match: { ...route.match },
priority: this.clampPriority(basePriority - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND),
action: {
type: 'socket-handler',
socketHandler: (socket) => this.denySocket(socket),
},
security: undefined,
};
}
private static denySocket(socket: plugins.net.Socket): void {
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (chunk: string | Uint8Array) => {
cleanup();
if (this.looksLikeHttpRequest(chunk)) {
socket.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: close\r\n\r\nForbidden');
return;
}
socket.destroy();
};
timeout = setTimeout(() => {
cleanup();
socket.destroy();
}, 2000) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
}
private static looksLikeHttpRequest(chunk: string | Uint8Array): boolean {
const prefix = typeof chunk === 'string'
? chunk.slice(0, 16)
: String.fromCharCode(...chunk.subarray(0, 16));
return /^(GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s/.test(prefix)
|| prefix.startsWith('PRI * HTTP/2.0');
}
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
const patterns: string[] = pathPolicy.pathPatterns?.length
? pathPolicy.pathPatterns
: giteaRoutePathClassPatterns[pathPolicy.pathClass];
return [...new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))];
}
private static calculatePathPriorityOffset(
pathPattern: string | undefined,
pathIndex: number,
pathPolicyCount: number,
pathPatternIndex: number,
pathPatternCount: number,
): number {
if (!pathPattern) {
return 0;
}
const pathPolicyOffset = ((pathPolicyCount - pathIndex) / (pathPolicyCount + 1))
* (PATH_PRIORITY_BAND * 0.9);
const pathPatternOffset = ((pathPatternCount - pathPatternIndex) / (pathPatternCount + 1))
* (PATH_PRIORITY_BAND * 0.1 / (pathPolicyCount + 1));
return pathPolicyOffset + pathPatternOffset;
}
private static calculateSourcePriority(
basePriority: number,
sourceIndex: number,
sourceCount: number,
): number {
const safeBasePriority = this.clampPriority(
basePriority,
MIN_ROUTE_PRIORITY,
MAX_ROUTE_PRIORITY - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND,
);
const sourceStep = SOURCE_PRIORITY_BAND / (sourceCount + 1);
return safeBasePriority + ((sourceCount - sourceIndex) * sourceStep);
}
private static applyIntegerPriorities(
routes: plugins.smartproxy.IRouteConfig[],
basePriority: number,
): plugins.smartproxy.IRouteConfig[] {
if (routes.length === 0) {
return routes;
}
const priorityOrder = routes
.map((route, originalIndex) => ({
originalIndex,
priority: typeof route.priority === 'number' && Number.isFinite(route.priority)
? route.priority
: basePriority,
}))
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
const topPriority = Math.trunc(this.clampPriority(
basePriority + routes.length,
MIN_ROUTE_PRIORITY + routes.length,
MAX_ROUTE_PRIORITY,
));
const integerPriorities = new Map<number, number>();
priorityOrder.forEach((entry, index) => {
integerPriorities.set(entry.originalIndex, topPriority - index);
});
return routes.map((route, index) => ({
...route,
priority: integerPriorities.get(index) ?? MIN_ROUTE_PRIORITY,
}));
}
private static clampPriority(
priority: number,
min = MIN_ROUTE_PRIORITY,
max = MAX_ROUTE_PRIORITY,
): number {
if (!Number.isFinite(priority)) {
return min;
}
return Math.min(max, Math.max(min, priority));
}
private static getExpandedPortCount(portRange: plugins.smartproxy.IRouteConfig['match']['ports'] | undefined): number {
if (portRange === undefined) {
return 1;
}
if (typeof portRange === 'number') {
return Number.isFinite(portRange) ? 1 : sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (!Array.isArray(portRange)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
let count = 0;
for (const portEntry of portRange) {
if (typeof portEntry === 'number') {
if (!Number.isFinite(portEntry)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
count++;
} else if (
portEntry
&& typeof portEntry === 'object'
&& Number.isFinite(portEntry.from)
&& Number.isFinite(portEntry.to)
&& portEntry.from <= portEntry.to
) {
count += Math.floor(portEntry.to) - Math.floor(portEntry.from) + 1;
} else {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (count > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return count;
}
}
return Math.max(1, count);
}
private static normalizeMaxConnections(value: IRouteSecurity['maxConnections']): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
private static forceIpRateLimit(
rateLimit: IRouteSecurity['rateLimit'] | undefined,
): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit) {
return undefined;
}
const { headerName: _headerName, ...rest } = structuredClone(rateLimit as Record<string, any>);
return {
...rest,
keyBy: 'ip',
} as IRouteSecurity['rateLimit'];
}
private static sanitizeSourcePolicySecurity(security: IRouteSecurity): IRouteSecurity {
const sanitized = structuredClone(security);
const maxConnections = this.normalizeMaxConnections(sanitized.maxConnections);
if (maxConnections === undefined) {
delete sanitized.maxConnections;
} else {
sanitized.maxConnections = maxConnections;
}
if (sanitized.rateLimit) {
sanitized.rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
}
return sanitized;
}
private static isEmptySecurity(security: IRouteSecurity): boolean {
return Object.keys(security).length === 0;
}
private static getSourceMatchEntries(security: IRouteSecurity): string[] {
const entries = security.ipAllowList || [];
const normalizedEntries: string[] = [];
for (const entry of entries) {
const rawEntry = typeof entry === 'string' ? entry : entry.ip;
if (typeof rawEntry !== 'string') continue;
const normalizedEntry = rawEntry.trim();
if (normalizedEntry) {
normalizedEntries.push(normalizedEntry);
}
}
return [...new Set(normalizedEntries)];
}
private static matchesAllSources(sourceMatches: string[]): boolean {
return sourceMatches.includes('*')
|| (sourceMatches.includes('0.0.0.0/0') && sourceMatches.includes('::/0'));
}
private static buildBindingSecurity(
routeSecurity: IRouteSecurity | undefined,
profileSecurity: IRouteSecurity,
binding: IRouteSourceBinding,
pathPolicy?: IRoutePathPolicyBinding,
): IRouteSecurity | undefined {
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
const sourceSecurity = this.omitSourceMatchFields(profileSecurity);
if (binding.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(binding.rateLimit);
}
if (binding.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(binding.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (binding.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: binding.onExceeded.errorMessage,
};
}
if (pathPolicy?.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
}
if (pathPolicy?.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(pathPolicy.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (pathPolicy?.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: pathPolicy.onExceeded.errorMessage,
};
}
const mergedSecurity = this.sanitizeSourcePolicySecurity({
...baseSecurity,
...sourceSecurity,
});
return this.isEmptySecurity(mergedSecurity) ? undefined : mergedSecurity;
}
private static omitSourceMatchFields(security: IRouteSecurity): IRouteSecurity {
const { ipAllowList: _ipAllowList, ...controls } = security;
return this.sanitizeSourcePolicySecurity(controls);
}
}
+462
View File
@@ -0,0 +1,462 @@
import * as plugins from '../plugins.js';
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
const REDIRECT_STATUS_CODE = 301;
const REDIRECT_PRIORITY = 0;
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
interface IRedirectCandidate {
key: string;
id: string;
domainPattern: string;
pathPattern?: string;
sourceRouteNames: Set<string>;
sourceRouteIds: Set<string>;
remoteIngress?: IRouteRemoteIngress;
}
interface IRedirectConflict {
routeName: string;
covers: boolean;
}
export interface IHttpRedirectDerivationResult {
redirects: IHttpRedirectInfo[];
runtimeRoutes: IDcRouterRouteConfig[];
}
export function deriveHttpRedirectConfiguration(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectDerivationResult {
const candidates = collectRedirectCandidates(routes);
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
const redirects: IHttpRedirectInfo[] = [];
const runtimeRoutes: IDcRouterRouteConfig[] = [];
for (const candidate of candidates) {
const conflict = findHttpConflict(candidate, httpRoutes);
const redirectInfo: IHttpRedirectInfo = {
id: candidate.id,
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
domainPattern: candidate.domainPattern,
pathPattern: candidate.pathPattern,
fromTemplate: 'http://{domain}{path}',
toTemplate: REDIRECT_TARGET_TEMPLATE,
statusCode: REDIRECT_STATUS_CODE,
priority: REDIRECT_PRIORITY,
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
coveredByRouteNames: conflict ? [conflict.routeName] : [],
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
notes: conflict
? conflict.covers
? 'An explicit HTTP route already covers this redirect scope.'
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
: undefined,
};
redirects.push(redirectInfo);
if (redirectInfo.status === 'active') {
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
}
}
return { redirects, runtimeRoutes };
}
export function deriveHttpRedirects(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectInfo[] {
return deriveHttpRedirectConfiguration(routes).redirects;
}
export function buildHttpRedirectRuntimeRoutes(
routes: plugins.smartproxy.IRouteConfig[],
): IDcRouterRouteConfig[] {
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
}
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
const candidates = new Map<string, IRedirectCandidate>();
for (const route of routes) {
if (!isHttpsRedirectSource(route)) {
continue;
}
for (const domainPattern of getDomainPatterns(route)) {
const key = createRedirectKey(domainPattern, route.match.path);
const existing = candidates.get(key);
if (existing) {
existing.sourceRouteNames.add(getRouteDisplayName(route));
if (route.id) existing.sourceRouteIds.add(route.id);
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
continue;
}
const id = createRedirectRouteName(domainPattern, route.match.path);
candidates.set(key, {
key,
id,
domainPattern,
pathPattern: route.match.path,
sourceRouteNames: new Set([getRouteDisplayName(route)]),
sourceRouteIds: new Set(route.id ? [route.id] : []),
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
});
}
}
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
}
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (route.action.type !== 'forward') return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
if (!route.action.tls) return false;
if (!route.match.domains) return false;
if (route.match.transport === 'udp') return false;
if (route.match.protocol && route.match.protocol !== 'http') return false;
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
return true;
}
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
if (route.match.transport === 'udp') return false;
return true;
}
function findHttpConflict(
candidate: IRedirectCandidate,
httpRoutes: plugins.smartproxy.IRouteConfig[],
): IRedirectConflict | undefined {
for (const route of httpRoutes) {
if (!httpRouteOverlapsCandidate(route, candidate)) {
continue;
}
return {
routeName: getRouteDisplayName(route),
covers: httpRouteCoversCandidate(route, candidate),
};
}
return undefined;
}
function httpRouteOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
&& pathOverlaps(route.match.path, candidate.pathPattern);
}
function httpRouteCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
return false;
}
return routeDomainCoversCandidate(route, candidate.domainPattern)
&& pathCovers(route.match.path, candidate.pathPattern);
}
function routeDomainOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
}
function routeDomainCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
}
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
if (!route.match.domains) return [];
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
}
function normalizePattern(pattern: string): string {
return pattern.trim().toLowerCase().replace(/\.$/, '');
}
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
const cover = normalizePattern(coverPattern);
const candidate = normalizePattern(candidatePattern);
if (cover === candidate) return true;
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
const coverSuffix = getLeadingWildcardSuffix(cover);
const candidateSuffix = getLeadingWildcardSuffix(candidate);
if (coverSuffix && candidateSuffix) {
return candidateSuffix.endsWith(coverSuffix);
}
return false;
}
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
const first = normalizePattern(firstPattern);
const second = normalizePattern(secondPattern);
if (first === second) return true;
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
const firstSuffix = getLeadingWildcardSuffix(first);
const secondSuffix = getLeadingWildcardSuffix(second);
if (firstSuffix && secondSuffix) {
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
}
return false;
}
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
const regex = wildcardPatternToRegex(normalizePattern(pattern));
return regex.test(normalizePattern(hostname));
}
function wildcardPatternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
}
function getLeadingWildcardSuffix(pattern: string): string | undefined {
if (!pattern.startsWith('*')) return undefined;
if (pattern.slice(1).includes('*')) return undefined;
return pattern.slice(1);
}
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
if (!coverPath) return true;
if (!candidatePath) return false;
if (coverPath === candidatePath) return true;
if (!coverPath.includes('*')) return false;
const coverPrefix = coverPath.split('*')[0];
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
const candidatePrefix = candidatePath.split('*')[0];
return candidatePrefix.startsWith(coverPrefix);
}
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
if (!firstPath || !secondPath) return true;
if (firstPath === secondPath) return true;
const firstPrefix = firstPath.split('*')[0];
const secondPrefix = secondPath.split('*')[0];
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
}
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
return {
id: candidate.id,
name: candidate.id,
description: 'Generated HTTP to HTTPS redirect',
priority: REDIRECT_PRIORITY,
tags: ['system', 'redirect', 'auto'],
match: {
ports: 80,
domains: candidate.domainPattern,
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
},
action: {
type: 'socket-handler',
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
},
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
};
}
function mergeRemoteIngress(
current: IRouteRemoteIngress | undefined,
next: IRouteRemoteIngress | undefined,
): IRouteRemoteIngress | undefined {
if (!next?.enabled) return current;
if (!current?.enabled) {
return {
enabled: true,
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
};
}
const currentFilter = current.edgeFilter || [];
const nextFilter = next.edgeFilter || [];
if (currentFilter.length === 0 || nextFilter.length === 0) {
return { enabled: true };
}
return {
enabled: true,
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
};
}
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
}
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
const key = createRedirectKey(domainPattern, pathPattern);
const slug = key
.replace(/\*/g, 'wildcard')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'route';
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
}
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
return route.name || route.id || 'unnamed-route';
}
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
}
function createHttpRedirectHandler(
locationTemplate: string,
statusCode: number,
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
return (socket, context) => {
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (data: string | Uint8Array) => {
cleanup();
const request = parseHttpRequest(data);
if (!request) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
return;
}
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
const finalLocation = locationTemplate
.replace('{domain}', domain)
.replace('{port}', String(context.port))
.replace('{path}', request.path || '/')
.replace('{clientIp}', context.clientIp);
const message = `Redirecting to ${finalLocation}`;
const response = [
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
`Location: ${finalLocation}`,
'Content-Type: text/plain',
`Content-Length: ${message.length}`,
'Connection: close',
'',
message,
].join('\r\n');
socket.end(response);
};
const timeout = setTimeout(() => {
cleanup();
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
};
}
function parseHttpRequest(data: string | Uint8Array): {
method: string;
path: string;
headers: Record<string, string>;
} | undefined {
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
const headerEnd = requestText.indexOf('\r\n\r\n');
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
const lines = headerText.split('\r\n');
const [method, rawPath] = (lines[0] || '').split(' ');
if (!method || !rawPath) return undefined;
const headers: Record<string, string> = {};
for (const line of lines.slice(1)) {
const colonIndex = line.indexOf(':');
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim().toLowerCase();
const value = line.slice(colonIndex + 1).trim();
headers[key] = value;
}
return {
method,
path: normalizeRequestPath(rawPath),
headers,
};
}
function normalizeRequestPath(rawPath: string): string {
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
try {
const url = new URL(rawPath);
return `${url.pathname}${url.search}` || '/';
} catch {
return '/';
}
}
return rawPath.startsWith('/') ? rawPath : '/';
}
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
if (!hostHeader) return undefined;
const host = hostHeader.split(',')[0].trim();
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
if (host.startsWith('[')) {
const bracketIndex = host.indexOf(']');
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
}
return host.replace(/:(80|443)$/, '');
}
function getHttpStatusText(statusCode: number): string {
switch (statusCode) {
case 301:
return 'Moved Permanently';
case 302:
return 'Found';
case 307:
return 'Temporary Redirect';
case 308:
return 'Permanent Redirect';
default:
return 'Redirect';
}
}
+2
View File
@@ -4,5 +4,7 @@ export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { GatewayClientManager } from './classes.gateway-client-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
export * from './helpers.http-redirects.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
@@ -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>(
@@ -42,6 +42,21 @@ export class RouteManagementHandler {
),
);
// Get generated HTTP redirects
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetHttpRedirects>(
'getHttpRedirects',
async (dataArg) => {
await this.requireAuth(dataArg, 'routes:read');
const manager = this.opsServerRef.dcRouterRef.routeConfigManager;
if (!manager) {
return { redirects: [] };
}
return { redirects: manager.getHttpRedirects() };
},
),
);
// Create route
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateRoute>(
+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(
+26 -2
View File
@@ -587,7 +587,13 @@ export class WorkHosterHandler {
return { success: false, message: 'route is required unless delete=true' };
}
const sourceBindings = this.getManagedRouteSourceBindings();
if (!sourceBindings) {
return { success: false, message: 'STANDARD source profile not found' };
}
const metadata: interfaces.data.IRouteMetadata = {
sourceBindings,
ownerType: 'gatewayClient',
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
@@ -600,8 +606,10 @@ export class WorkHosterHandler {
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const routePatch: Partial<interfaces.data.IDcRouterRouteConfig> = { ...normalizedRoute };
(routePatch as any).security = null;
const result = await manager.updateRoute(existingRoute.id, {
route: normalizedRoute,
route: routePatch,
enabled: enabled ?? true,
metadata,
});
@@ -640,10 +648,26 @@ export class WorkHosterHandler {
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
const normalizedRoute = structuredClone(route);
delete normalizedRoute.security;
if (!normalizedRoute.name) {
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
private getManagedRouteSourceBindings(): interfaces.data.IRouteSourceBinding[] | undefined {
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const standardProfile = resolver?.listProfiles().find((profile: interfaces.data.ISourceProfile) => {
return profile.id.trim().toLowerCase() === 'standard'
|| profile.name.trim().toLowerCase() === 'standard';
});
if (!standardProfile) {
return undefined;
}
return [{
sourceProfileRef: standardProfile.id,
sourceProfileName: standardProfile.name,
}];
}
}
+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}`);
+1 -1
View File
@@ -66,7 +66,7 @@ await router.start();
- System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
- API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
- The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
## Use Another Module When...
+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 {
+134 -4
View File
@@ -104,6 +104,116 @@ export interface ISourceProfile {
createdBy: string;
}
export interface IRouteSourcePolicyExceededAction {
type: '429';
errorMessage?: string;
}
export const routePathClasses = [
'git-smart-http',
'static',
'normal-html',
'expensive-html',
'raw',
'archive',
] as const;
export type TRoutePathClass = typeof routePathClasses[number];
export const giteaRoutePathClassLabels: Record<TRoutePathClass, string> = {
'git-smart-http': 'Git Smart HTTP',
static: 'Static Assets',
'normal-html': 'Normal HTML',
'expensive-html': 'Expensive HTML',
raw: 'Raw Files',
archive: 'Archives',
};
export const giteaRoutePathClassPatterns: Record<TRoutePathClass, string[]> = {
'git-smart-http': [
'/*/*.git/info/refs',
'/*/*.git/git-upload-pack',
'/*/*.git/git-receive-pack',
'/*/*.git/info/lfs',
'/*/*.git/info/lfs/*',
],
static: [
'/assets/*',
'/avatars/*',
'/repo-avatars/*',
'/user/avatar/*',
'/img/*',
'/css/*',
'/js/*',
'/fonts/*',
'/favicon.ico',
],
'normal-html': [],
'expensive-html': [
'/explore/*',
'/issues',
'/issues/*',
'/pulls',
'/pulls/*',
'/search',
'/*/*/commits/*',
'/*/*/graph',
'/*/*/activity',
'/*/*/stars',
'/*/*/forks',
'/*/*/watchers',
'/*/*/issues',
'/*/*/pulls',
'/*/*/projects',
'/*/*/actions',
'/*/*/packages',
],
raw: [
'/*/*/raw/*',
'/*/*/src/*',
],
archive: [
'/*/*/archive/*',
'/*/*/releases/download/*',
],
};
export interface IRoutePathPolicyBinding {
id?: string;
pathClass: TRoutePathClass;
/** Optional custom patterns. When omitted, the Gitea defaults for the class are used. */
pathPatterns?: string[];
/** Optional path-class override for the source binding's rate limit. */
rateLimit?: IRouteSecurity['rateLimit'];
/** Optional path-class override for the source binding's connection limit. */
maxConnections?: IRouteSecurity['maxConnections'];
onExceeded?: IRouteSourcePolicyExceededAction;
}
export interface IRouteSourceBinding {
id?: string;
sourceProfileRef: string;
/** Snapshot of the profile name at resolution time, for display. */
sourceProfileName?: string;
/** Optional route-level override for the referenced profile's rate limit. */
rateLimit?: IRouteSecurity['rateLimit'];
/** Optional route-level override for the referenced profile's connection limit. */
maxConnections?: IRouteSecurity['maxConnections'];
/** Initial source-policy slice only supports explicit 429 behavior. */
onExceeded?: IRouteSourcePolicyExceededAction;
/** Optional path-class variants inside this source binding. Path-specific variants win over fallback variants. */
pathPolicies?: IRoutePathPolicyBinding[];
}
/** @deprecated Use IRouteSourceBinding and IRouteMetadata.sourceBindings. */
export type IRouteSourcePolicyBinding = IRouteSourceBinding;
/** @deprecated Use IRouteMetadata.sourceBindings. */
export interface IRouteSourcePolicy {
/** Ordered source profile bindings. The first matching binding wins. */
bindings: IRouteSourceBinding[];
}
// ============================================================================
// Network Target Types
// ============================================================================
@@ -130,12 +240,10 @@ export interface INetworkTarget {
* Metadata on a stored route tracking where its resolved values came from.
*/
export interface IRouteMetadata {
/** ID of the SourceProfileDoc used to resolve this route's security. */
sourceProfileRef?: string;
/** Ordered source profile bindings. The first matching source profile wins. */
sourceBindings?: IRouteSourceBinding[];
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
networkTargetRef?: string;
/** Snapshot of the profile name at resolution time, for display. */
sourceProfileName?: string;
/** Snapshot of the target name at resolution time, for display. */
networkTargetName?: string;
/** Timestamp of last reference resolution. */
@@ -177,6 +285,28 @@ export interface IRouteWarning {
message: string;
}
export type THttpRedirectStatus = 'active' | 'covered' | 'skipped';
/**
* Derived HTTP-to-HTTPS redirect shown in the Ops UI.
* These entries are generated from configured HTTPS routes and are not stored as routes.
*/
export interface IHttpRedirectInfo {
id: string;
status: THttpRedirectStatus;
domainPattern: string;
pathPattern?: string;
fromTemplate: string;
toTemplate: string;
statusCode: number;
priority: number;
sourceRouteNames: string[];
sourceRouteIds: string[];
coveredByRouteNames: string[];
remoteIngress: boolean;
notes?: string;
}
/**
* Public info about an API token (never includes the hash).
*/
+16 -3
View File
@@ -22,7 +22,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Export | Purpose |
| --- | --- |
| `data` | Shared runtime-shaped models such as identities, routes, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
| `data` | Shared runtime-shaped models such as identities, routes, route source bindings, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
@@ -31,7 +31,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Area | Examples |
| --- | --- |
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata |
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path bindings |
| Access | API tokens, source profiles, target profiles, network targets |
| DNS and domains | DNS providers, domains, DNS records, ACME config |
| Email | email-domain management and email operations |
@@ -39,6 +39,19 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Observability | stats, combined stats, logs, configuration |
| WorkHoster | external app/workhoster route ownership contracts |
## Route Source Binding Contracts
`data/route-management.ts` exports the source-binding contracts used by the dashboard, API client, and route runtime compiler:
- `IRouteMetadata.sourceBindings` stores ordered route-level source bindings.
- `IRouteSourceBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
- `IRoutePathPolicyBinding` applies path-class-specific overrides within a source binding.
- `IRouteSourcePolicyExceededAction` describes terminal exceeded-limit behavior, currently explicit `429` handling.
- `IRouteSourcePolicy` and `IRouteSourcePolicyBinding` remain deprecated type aliases for old integrations; active route metadata uses `sourceBindings[]`.
- `TRoutePathClass` is the string-union type derived from `routePathClasses`.
- `routePathClasses` lists the supported classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`.
- `giteaRoutePathClassLabels` and `giteaRoutePathClassPatterns` provide the built-in Gitea labels and path patterns, including Git Smart HTTP and Git LFS patterns.
## Raw TypedRequest Example
```typescript
@@ -86,7 +99,7 @@ Useful source entry points:
- `index.ts` exports `data` and `requests` namespaces.
- `data/index.ts` groups shared data models.
- `requests/index.ts` groups TypedRequest contracts.
- `data/route-management.ts` defines route ownership, API token scopes, profiles, and network target shapes.
- `data/route-management.ts` defines route ownership, source/path policies, API token scopes, profiles, and network target shapes.
## License and Legal Information
+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;
};
}
+18 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
import type { IHttpRedirectInfo, IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
@@ -26,6 +26,23 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
};
}
/**
* Get derived HTTP-to-HTTPS redirects.
*/
export interface IReq_GetHttpRedirects extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetHttpRedirects
> {
method: 'getHttpRedirects';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
redirects: IHttpRedirectInfo[];
};
}
/**
* Create a new route.
*/
+327
View File
@@ -19,6 +19,172 @@ export interface IMigrationRunner {
run(): Promise<IMigrationRunResult>;
}
type TMigrationSecurity = Record<string, any>;
const DEFAULT_SOURCE_PROFILES: Array<{
name: string;
description: string;
security: TMigrationSecurity;
}> = [
{
name: 'TRUSTED NETWORKS',
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
security: {
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
maxConnections: 5000,
},
},
{
name: 'AI CRAWLERS',
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
security: {
ipAllowList: [],
rateLimit: {
enabled: true,
maxRequests: 30,
window: 60,
keyBy: 'ip',
},
},
},
{
name: 'PUBLIC',
description: 'Public fallback source profile with per-IP request limiting',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
},
},
},
];
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 };
@@ -70,6 +236,149 @@ async function backfillSystemRouteKeys(ctx: {
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
}
async function seedMissingDefaultSourceProfiles(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const collection = ctx.mongo!.collection('SourceProfileDoc');
const now = Date.now();
let inserted = 0;
let existing = 0;
for (const profile of DEFAULT_SOURCE_PROFILES) {
const existingProfile = await collection.findOne({ name: profile.name });
if (existingProfile) {
existing++;
continue;
}
await collection.insertOne({
id: globalThis.crypto.randomUUID(),
name: profile.name,
description: profile.description,
security: structuredClone(profile.security),
createdAt: now,
updatedAt: now,
createdBy: 'system',
});
inserted++;
}
ctx.log.log(
'info',
`seed-missing-default-source-profiles: inserted ${inserted}, already present ${existing}`,
);
}
function normalizeMigrationSourceBinding(binding: any, profiles: Map<string, any>): any | undefined {
if (!binding || typeof binding !== 'object') return undefined;
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
? binding.sourceProfileRef.trim()
: '';
if (!sourceProfileRef) return undefined;
const profile = profiles.get(sourceProfileRef);
const normalizedBinding = structuredClone(binding);
normalizedBinding.sourceProfileRef = sourceProfileRef;
const sourceProfileName = typeof normalizedBinding.sourceProfileName === 'string'
? normalizedBinding.sourceProfileName.trim()
: '';
if (sourceProfileName) {
normalizedBinding.sourceProfileName = sourceProfileName;
} else if (typeof profile?.name === 'string' && profile.name.trim()) {
normalizedBinding.sourceProfileName = profile.name.trim();
} else {
delete normalizedBinding.sourceProfileName;
}
return normalizedBinding;
}
async function convertRouteAccessMetadataToSourceBindings(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>();
const now = Date.now();
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;
for await (const routeDoc of routeCollection.find({})) {
const metadata = (routeDoc as any).metadata || {};
const existingSourceBindings = Array.isArray(metadata.sourceBindings)
? metadata.sourceBindings
: [];
const legacyPolicyBindings = Array.isArray(metadata.sourcePolicy?.bindings)
? metadata.sourcePolicy.bindings
: [];
const legacySourceProfileRef = typeof metadata.sourceProfileRef === 'string'
? metadata.sourceProfileRef.trim()
: '';
const hasLegacyAccessFields = legacyPolicyBindings.length > 0
|| legacySourceProfileRef.length > 0
|| metadata.sourcePolicy !== undefined
|| metadata.sourceProfileRef !== undefined
|| metadata.sourceProfileName !== undefined;
if (!hasLegacyAccessFields && existingSourceBindings.length === 0) {
continue;
}
inspected++;
const sourceBindings = existingSourceBindings.length > 0
? existingSourceBindings
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
.filter(Boolean)
: legacyPolicyBindings.length > 0
? legacyPolicyBindings
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
.filter(Boolean)
: legacySourceProfileRef
? [normalizeMigrationSourceBinding({
sourceProfileRef: legacySourceProfileRef,
sourceProfileName: metadata.sourceProfileName,
}, profiles)].filter(Boolean)
: [];
const $set: Record<string, any> = { updatedAt: now };
const $unset: Record<string, ''> = {
'metadata.sourcePolicy': '',
'metadata.sourceProfileRef': '',
'metadata.sourceProfileName': '',
};
if (sourceBindings.length > 0) {
$set['metadata.sourceBindings'] = sourceBindings;
$set['metadata.lastResolvedAt'] = now;
} else if (existingSourceBindings.length === 0) {
$unset['metadata.sourceBindings'] = '';
}
if (existingSourceBindings.length === 0 && legacyPolicyBindings.length === 0 && legacySourceProfileRef) {
$unset['route.security'] = '';
}
const query = (routeDoc as any)._id
? { _id: (routeDoc as any)._id }
: { id: (routeDoc as any).id };
await routeCollection.updateOne(query, { $set, $unset });
migrated++;
}
ctx.log.log(
'info',
`convert-route-access-metadata-to-source-bindings: migrated ${migrated}/${inspected} route(s)`,
);
}
/**
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
*
@@ -167,6 +476,24 @@ 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);
})
.step('seed-missing-default-source-profiles')
.from('13.40.2').to('13.42.0')
.description('Seed missing default source profiles for source-policy presets')
.up(async (ctx) => {
await seedMissingDefaultSourceProfiles(ctx);
})
.step('convert-route-access-metadata-to-source-bindings')
.from('13.42.0').to('13.43.2')
.description('Convert route sourceProfileRef/sourcePolicy metadata to canonical sourceBindings')
.up(async (ctx) => {
await convertRouteAccessMetadataToSourceBindings(ctx);
});
return migration;
+4 -1
View File
@@ -25,7 +25,7 @@ If you boot `DcRouter`, you usually do not install or call this package directly
```typescript
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
const migration = await createMigrationRunner(db, '13.25.0');
const migration = await createMigrationRunner(db, '<current-version>');
const result = await migration.run();
console.log(result.currentVersionBefore, result.currentVersionAfter);
@@ -41,6 +41,9 @@ The current migration chain covers:
- route collection unification from `StoredRouteDoc` to `RouteDoc`
- route `origin` backfill for migrated API routes
- `systemKey` backfill for persisted config, email, and DNS routes
- source-profile route-security rematerialization for routes with legacy `metadata.sourceProfileRef`
- `seed-missing-default-source-profiles` from `13.40.2` to `13.42.0`, which inserts missing `TRUSTED NETWORKS`, `AI CRAWLERS`, and `PUBLIC` source profiles by name without mutating existing profiles
- `convert-route-access-metadata-to-source-bindings` from `13.42.0` to `13.43.2`, which converts legacy `metadata.sourceProfileRef`, `metadata.sourceProfileName`, and `metadata.sourcePolicy.bindings` to canonical `metadata.sourceBindings[]` and removes legacy access metadata fields
## Migration Rules
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.38.3',
version: '13.43.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+77 -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,
@@ -288,6 +290,7 @@ export const remoteIngressStatePart = await appState.getStatePart<IRemoteIngress
export interface IRouteManagementState {
mergedRoutes: interfaces.data.IMergedRoute[];
warnings: interfaces.data.IRouteWarning[];
httpRedirects: interfaces.data.IHttpRedirectInfo[];
apiTokens: interfaces.data.IApiTokenInfo[];
gatewayClients: interfaces.data.IGatewayClient[];
isLoading: boolean;
@@ -300,6 +303,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
{
mergedRoutes: [],
warnings: [],
httpRedirects: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
@@ -1094,15 +1098,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 +1130,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 +1146,7 @@ export const createRemoteIngressAction = remoteIngressStatePart.createAction<{
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
performance: dataArg.performance,
tags: dataArg.tags,
});
@@ -1187,6 +1199,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 +1216,7 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
name: dataArg.name,
listenPorts: dataArg.listenPorts,
autoDerivePorts: dataArg.autoDerivePorts,
performance: dataArg.performance,
tags: dataArg.tags,
});
@@ -1215,6 +1229,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();
@@ -2430,6 +2476,36 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
}
});
export const fetchHttpRedirectsAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetHttpRedirects
>('/typedrequest', 'getHttpRedirects');
const response = await request.fire({
identity: context.identity,
});
return {
...currentState,
httpRedirects: response.redirects,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch HTTP redirects',
};
}
});
export const createRouteAction = routeManagementStatePart.createAction<{
route: any;
enabled?: boolean;
@@ -19,6 +19,7 @@ export class OpsViewApiTokens extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
httpRedirects: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
@@ -17,6 +17,7 @@ export class OpsViewGatewayClients extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
httpRedirects: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
+1
View File
@@ -1,5 +1,6 @@
export * from './ops-view-network-activity.js';
export * from './ops-view-routes.js';
export * from './ops-view-redirects.js';
export * from './ops-view-sourceprofiles.js';
export * from './ops-view-networktargets.js';
export * from './ops-view-targetprofiles.js';
@@ -0,0 +1,202 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import { type IStatsTile } from '@design.estate/dees-catalog';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
declare global {
interface HTMLElementTagNameMap {
'ops-view-redirects': OpsViewRedirects;
}
}
@customElement('ops-view-redirects')
export class OpsViewRedirects extends DeesElement {
@state()
accessor routeState: appstate.IRouteManagementState = appstate.routeManagementStatePart.getState()!;
constructor() {
super();
const routeSub = appstate.routeManagementStatePart.select().subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(routeSub);
const loginSub = appstate.loginStatePart
.select((state) => state.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
void this.refreshData();
}
});
this.rxSubscriptions.push(loginSub);
}
async connectedCallback() {
await super.connectedCallback();
await this.refreshData();
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.redirectsContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state p {
margin: 8px 0;
}
`,
];
public render(): TemplateResult {
const redirects = this.routeState.httpRedirects || [];
const activeCount = redirects.filter((redirect) => redirect.status === 'active').length;
const coveredCount = redirects.filter((redirect) => redirect.status === 'covered').length;
const skippedCount = redirects.filter((redirect) => redirect.status === 'skipped').length;
const remoteIngressCount = redirects.filter((redirect) => redirect.remoteIngress).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalRedirects',
title: 'Total Redirects',
type: 'number',
value: redirects.length,
icon: 'lucide:CornerDownRight',
description: 'Derived HTTP to HTTPS scopes',
color: '#3b82f6',
},
{
id: 'activeRedirects',
title: 'Active',
type: 'number',
value: activeCount,
icon: 'lucide:CircleCheck',
description: 'Generated at runtime',
color: '#22c55e',
},
{
id: 'coveredRedirects',
title: 'Covered',
type: 'number',
value: coveredCount,
icon: 'lucide:ShieldCheck',
description: 'Handled by explicit HTTP routes',
color: '#8b5cf6',
},
{
id: 'skippedRedirects',
title: 'Skipped',
type: 'number',
value: skippedCount,
icon: 'lucide:AlertTriangle',
description: 'Overlaps explicit HTTP routes',
color: skippedCount > 0 ? '#f59e0b' : '#6b7280',
},
{
id: 'remoteIngressRedirects',
title: 'Remote Ingress',
type: 'number',
value: remoteIngressCount,
icon: 'lucide:Globe',
description: 'Also exposed to edge nodes',
color: '#0ea5e9',
},
];
return html`
<dees-heading level="3">Redirects</dees-heading>
<div class="redirectsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
${redirects.length > 0
? html`
<dees-table
.heading1=${'HTTP to HTTPS Redirects'}
.heading2=${'Runtime redirects derived from enabled HTTPS routes'}
.data=${redirects}
.showColumnFilters=${true}
.displayFunction=${(redirect: interfaces.data.IHttpRedirectInfo) => ({
Status: this.formatStatus(redirect.status),
'Domain Pattern': redirect.domainPattern,
Path: redirect.pathPattern || '*',
From: this.formatHttpTemplate(redirect, 'http'),
To: this.formatHttpTemplate(redirect, 'https'),
Code: redirect.statusCode,
Priority: redirect.priority,
'Source HTTPS Route': redirect.sourceRouteNames.join(', ') || '-',
'Covered By': redirect.coveredByRouteNames.join(', ') || '-',
Notes: this.formatNotes(redirect),
})}
.dataActions=${[
{
name: 'Refresh',
iconName: 'lucide:RefreshCw',
type: ['header' as const],
actionFunc: async () => this.refreshData(),
},
]}
></dees-table>
`
: html`
<dees-table
.heading1=${'HTTP to HTTPS Redirects'}
.heading2=${'Runtime redirects derived from enabled HTTPS routes'}
.data=${[]}
.displayFunction=${() => ({})}
.dataActions=${[
{
name: 'Refresh',
iconName: 'lucide:RefreshCw',
type: ['header' as const],
actionFunc: async () => this.refreshData(),
},
]}
></dees-table>
<div class="empty-state">
<p>No derived redirects</p>
<p>Enable HTTPS routes with explicit domains to generate HTTP to HTTPS redirects.</p>
</div>
`}
</div>
`;
}
private async refreshData(): Promise<void> {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchHttpRedirectsAction, null);
}
private formatStatus(status: interfaces.data.THttpRedirectStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
private formatHttpTemplate(redirect: interfaces.data.IHttpRedirectInfo, protocol: 'http' | 'https'): string {
return `${protocol}://${redirect.domainPattern}${redirect.pathPattern || '{path}'}`;
}
private formatNotes(redirect: interfaces.data.IHttpRedirectInfo): string {
const notes = redirect.notes ? [redirect.notes] : [];
if (redirect.remoteIngress) {
notes.push('Remote Ingress enabled');
}
return notes.join(' ') || 'Generated from HTTPS route';
}
}
@@ -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);
}
}
+236 -12
View File
@@ -24,11 +24,164 @@ const tlsCertOptions = [
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
{ key: 'custom', option: 'Custom certificate' },
];
const maxSourceBindingRows = 16;
const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
}
function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
function getSourceBindingRefsFromFormData(formData: Record<string, any>): string[] {
const refs: string[] = [];
for (let index = 0; index < maxSourceBindingRows; index++) {
const ref = getDropdownKey(formData[`sourceBindingProfileRef${index}`]);
if (ref && !refs.includes(ref)) {
refs.push(ref);
}
}
return refs;
}
function buildSourceBindingsMetadata(
profileRefs: string[],
existingSourceBindings?: interfaces.data.IRouteSourceBinding[],
): interfaces.data.IRouteSourceBinding[] {
return profileRefs.map((sourceProfileRef) => {
const existingBinding = existingSourceBindings?.find((binding) => binding.sourceProfileRef === sourceProfileRef);
return existingBinding
? {
...existingBinding,
sourceProfileRef,
onExceeded: existingBinding.onExceeded || { type: '429' as const },
}
: {
sourceProfileRef,
onExceeded: { type: '429' as const },
};
});
}
function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
refs: string[];
missingNames: string[];
} {
const refs: string[] = [];
const missingNames: string[] = [];
for (const profileName of giteaSourcePolicyProfileNames) {
const profile = profiles.find((item) => item.name.trim().toUpperCase() === profileName);
if (profile) {
refs.push(profile.id);
} else {
missingNames.push(profileName);
}
}
return { refs, missingNames };
}
function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.data.IRouteSourceBinding[] {
const [trustedRef, aiRef, publicRef] = profileRefs;
return [
{
sourceProfileRef: trustedRef,
onExceeded: { type: '429' as const },
},
{
sourceProfileRef: aiRef,
onExceeded: { type: '429' as const },
pathPolicies: [
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
{ pathClass: 'static', rateLimit: rateLimit(240) },
{ pathClass: 'raw', rateLimit: rateLimit(20) },
{ pathClass: 'archive', rateLimit: rateLimit(6) },
{ pathClass: 'expensive-html', rateLimit: rateLimit(6) },
{ pathClass: 'normal-html', rateLimit: rateLimit(20) },
],
},
{
sourceProfileRef: publicRef,
onExceeded: { type: '429' as const },
pathPolicies: [
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
{ pathClass: 'static', rateLimit: rateLimit(600) },
{ pathClass: 'raw', rateLimit: rateLimit(120) },
{ pathClass: 'archive', rateLimit: rateLimit(30) },
{ pathClass: 'expensive-html', rateLimit: rateLimit(30) },
{ pathClass: 'normal-html', rateLimit: rateLimit(120) },
],
},
];
}
function getGiteaPresetSourceBindings(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourceBinding[] | null {
const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
if (missingNames.length > 0) {
alert(`Gitea source-policy preset needs these seeded profiles: ${missingNames.join(', ')}`);
return null;
}
if (!validateSourceBindingSelection(refs, profiles)) {
return null;
}
return buildGiteaSourceBindingsMetadata(refs);
}
function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
return Boolean(metadata?.sourceBindings?.some((binding) => binding.pathPolicies?.length));
}
function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
return (profile.security?.ipAllowList || []).some((entry) => {
const source = typeof entry === 'string' ? entry : entry.ip;
return ['*', '0.0.0.0/0', '::/0'].includes(source.trim());
});
}
function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean {
return (profile.security?.ipAllowList || []).some((entry) => {
const source = typeof entry === 'string' ? entry : entry.ip;
return source.trim().length > 0;
});
}
function validateSourceBindingSelection(
profileRefs: string[],
profiles: interfaces.data.ISourceProfile[],
): boolean {
if (profileRefs.length === 0) {
return true;
}
const selectedProfiles = profileRefs
.map((profileRef) => profiles.find((profile) => profile.id === profileRef))
.filter(Boolean) as interfaces.data.ISourceProfile[];
if (selectedProfiles.length !== profileRefs.length) {
alert('One or more selected source profiles could not be found. Refresh profiles and try again.');
return false;
}
const profilesWithoutMatches = selectedProfiles.filter((profile) => !sourceProfileHasSourceMatches(profile));
if (profilesWithoutMatches.length > 0) {
alert(`Source profiles need IP/CIDR match entries before use: ${profilesWithoutMatches.map((profile) => profile.name).join(', ')}`);
return false;
}
if (selectedProfiles.slice(0, -1).some((profile) => sourceProfileMatchesAll(profile))) {
alert('Wildcard source profiles must be last. Earlier wildcard profiles would shadow all following profiles.');
return false;
}
const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
if (sourceProfileMatchesAll(fallbackProfile) && fallbackProfile.security?.rateLimit?.enabled !== true) {
return confirm(`The wildcard profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
}
return true;
}
function parseTargetPort(value: any): number | undefined {
const parsed = typeof value === 'number'
? value
@@ -128,6 +281,7 @@ export class OpsViewRoutes extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
httpRedirects: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
@@ -355,6 +509,7 @@ export class OpsViewRoutes extends DeesElement {
const meta = merged.metadata;
const isSystemManaged = this.isSystemManagedRoute(merged);
const sourceBindingSummary = this.describeSourcePolicy(meta);
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
@@ -364,7 +519,7 @@ export class OpsViewRoutes extends DeesElement {
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
<p>ID: <code style="color: #888;">${merged.id}</code></p>
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
${sourceBindingSummary ? html`<p>Source Bindings: <strong style="color: #a78bfa;">${sourceBindingSummary}</strong></p>` : ''}
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div>
`,
@@ -496,6 +651,7 @@ export class OpsViewRoutes extends DeesElement {
const currentVpnOnly = route.vpnOnly === true;
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
const currentSourceBindingRefs = this.getSourceBindingRefs(merged.metadata);
// Compute current TLS state for pre-population
const currentTls = (route.action as any).tls;
@@ -516,7 +672,24 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
<strong>Source Bindings</strong>
<small>First matching source profile wins. Leave all rows empty to remove route-level source access control.</small>
<dees-input-checkbox
.key=${'useGiteaTemplate'}
.label=${'Apply Gitea bot protection template on save'}
.description=${'Replaces these rows with TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
.value=${false}
></dees-input-checkbox>
${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
<dees-input-dropdown
.key=${`sourceBindingProfileRef${index}`}
.label=${`Binding ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions.find((o) => o.key === (currentSourceBindingRefs[index] || '')) || profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
@@ -557,7 +730,11 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
const sourceBindingRefs = useGiteaTemplate
? []
: getSourceBindingRefsFromFormData(formData);
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -621,11 +798,14 @@ export class OpsViewRoutes extends DeesElement {
}
const metadata: any = {};
if (profileKey) {
metadata.sourceProfileRef = profileKey;
} else if (merged.metadata?.sourceProfileRef) {
metadata.sourceProfileRef = '';
metadata.sourceProfileName = '';
if (useGiteaTemplate) {
const sourceBindings = getGiteaPresetSourceBindings(profiles);
if (!sourceBindings) return;
metadata.sourceBindings = sourceBindings;
} else if (sourceBindingRefs.length > 0) {
metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs, merged.metadata?.sourceBindings);
} else if (merged.metadata?.sourceBindings) {
metadata.sourceBindings = [];
}
if (targetKey) {
metadata.networkTargetRef = targetKey;
@@ -685,7 +865,24 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
<strong>Source Bindings</strong>
<small>First matching source profile wins. Leave all rows empty for no route-level source access control.</small>
<dees-input-checkbox
.key=${'useGiteaTemplate'}
.label=${'Apply Gitea bot protection template on save'}
.description=${'Writes TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
.value=${false}
></dees-input-checkbox>
${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
<dees-input-dropdown
.key=${`sourceBindingProfileRef${index}`}
.label=${`Binding ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
@@ -726,7 +923,11 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
const sourceBindingRefs = useGiteaTemplate
? []
: getSourceBindingRefsFromFormData(formData);
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -791,8 +992,12 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected
const metadata: any = {};
if (profileKey) {
metadata.sourceProfileRef = profileKey;
if (useGiteaTemplate) {
const sourceBindings = getGiteaPresetSourceBindings(profiles);
if (!sourceBindings) return;
metadata.sourceBindings = sourceBindings;
} else if (sourceBindingRefs.length > 0) {
metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs);
}
if (targetKey) {
metadata.networkTargetRef = targetKey;
@@ -823,6 +1028,25 @@ export class OpsViewRoutes extends DeesElement {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
private getSourceBindingRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
const bindingRefs = metadata?.sourceBindings
?.map((binding) => binding.sourceProfileRef)
.filter(Boolean) || [];
return bindingRefs;
}
private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string {
const refs = this.getSourceBindingRefs(metadata);
if (refs.length === 0) {
return '';
}
return refs.map((ref) => {
const binding = metadata?.sourceBindings?.find((item) => item.sourceProfileRef === ref);
const profile = this.profilesTargetsState.profiles.find((item) => item.id === ref);
return binding?.sourceProfileName || profile?.name || ref.slice(0, 8);
}).join(' → ');
}
private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined {
if (clickedRoute.id) {
const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id);
@@ -12,6 +12,33 @@ import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
function parseOptionalPositiveInteger(value: unknown): number | undefined {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? parseInt(value.trim(), 10)
: Number.NaN;
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function buildRateLimitFromFormData(data: Record<string, any>) {
if (!Boolean(data.rateLimitEnabled)) {
return undefined;
}
const maxRequests = parseOptionalPositiveInteger(data.rateLimitMaxRequests);
const window = parseOptionalPositiveInteger(data.rateLimitWindow);
if (!maxRequests || !window) {
alert('Rate limit requires positive Max Requests and Window values.');
return null;
}
return {
enabled: true,
maxRequests,
window,
keyBy: 'ip' as const,
};
}
declare global {
interface HTMLElementTagNameMap {
'ops-view-sourceprofiles': OpsViewSourceProfiles;
@@ -138,6 +165,9 @@ export class OpsViewSourceProfiles extends DeesElement {
<dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
<dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'}></dees-input-text>
<dees-input-checkbox .key=${'rateLimitEnabled'} .label=${'Enable Rate Limit'} .description=${'Per source IP. Exceeded requests receive 429.'} .value=${false}></dees-input-checkbox>
<dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'}></dees-input-text>
<dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'}></dees-input-text>
</dees-form>
`,
menuOptions: [
@@ -150,8 +180,9 @@ export class OpsViewSourceProfiles extends DeesElement {
const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
const rateLimit = buildRateLimitFromFormData(data);
if (rateLimit === null) return;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
name: String(data.name),
@@ -160,6 +191,7 @@ export class OpsViewSourceProfiles extends DeesElement {
...(ipAllowList.length > 0 ? { ipAllowList } : {}),
...(ipBlockList.length > 0 ? { ipBlockList } : {}),
...(maxConnections ? { maxConnections } : {}),
...(rateLimit ? { rateLimit } : {}),
},
});
modalArg.destroy();
@@ -180,6 +212,9 @@ export class OpsViewSourceProfiles extends DeesElement {
<dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipAllowList || []}></dees-input-list>
<dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipBlockList || []}></dees-input-list>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'} .value=${String(profile.security?.maxConnections || '')}></dees-input-text>
<dees-input-checkbox .key=${'rateLimitEnabled'} .label=${'Enable Rate Limit'} .description=${'Per source IP. Exceeded requests receive 429.'} .value=${profile.security?.rateLimit?.enabled === true}></dees-input-checkbox>
<dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'} .value=${String(profile.security?.rateLimit?.maxRequests || '')}></dees-input-text>
<dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'} .value=${String(profile.security?.rateLimit?.window || '')}></dees-input-text>
</dees-form>
`,
menuOptions: [
@@ -192,8 +227,9 @@ export class OpsViewSourceProfiles extends DeesElement {
const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
const rateLimit = buildRateLimitFromFormData(data);
if (rateLimit === null) return;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
id: profile.id,
@@ -203,6 +239,7 @@ export class OpsViewSourceProfiles extends DeesElement {
ipAllowList,
ipBlockList,
...(maxConnections ? { maxConnections } : {}),
...(rateLimit ? { rateLimit } : {}),
},
});
modalArg.destroy();
+2
View File
@@ -23,6 +23,7 @@ import { OpsViewConfig } from './overview/ops-view-config.js';
// Network group
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
import { OpsViewRoutes } from './network/ops-view-routes.js';
import { OpsViewRedirects } from './network/ops-view-redirects.js';
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
@@ -100,6 +101,7 @@ export class OpsDashboard extends DeesElement {
subViews: [
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
{ slug: 'redirects', name: 'Redirects', iconName: 'lucide:CornerDownRight', element: OpsViewRedirects },
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
@@ -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' } },
];
+1 -1
View File
@@ -9,7 +9,7 @@ const flatViews = ['logs'] as const;
// Tabbed views and their valid subviews
const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
network: ['activity', 'routes', 'redirects', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security', 'domains'] as const,
access: ['gatewayclients', 'apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,