Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eef053bd66 | |||
| ccb4dea91e | |||
| b0b480873f | |||
| 496dba94b1 | |||
| 69dbc29662 | |||
| 3bd6d2f2de | |||
| 2c8cc93952 | |||
| 3f50518b80 | |||
| 15ca5d137c | |||
| 16a4b04dfb | |||
| 03b494018a | |||
| 9c08384df0 | |||
| 9286f56316 | |||
| 1c4caf2b85 | |||
| 4a09b273df | |||
| 4ceb46b509 | |||
| 0aa1cde5eb | |||
| 584782dcb7 | |||
| 810ecf46f8 | |||
| 6d5d23a691 | |||
| c6617c79f5 | |||
| 135432260d | |||
| b55d2ac61d | |||
| c88e8e1758 | |||
| 6ee716e4ef | |||
| 1d4ed9af2c | |||
| d2331fdcbe | |||
| 0e7765c740 | |||
| 1a381df937 | |||
| 38e2f3cee1 |
@@ -1,7 +1,15 @@
|
||||
node_modules/
|
||||
.nogit/
|
||||
.git/
|
||||
.cache/
|
||||
.rpt2_cache
|
||||
.yarn/
|
||||
.playwright-mcp/
|
||||
.vscode/
|
||||
coverage/
|
||||
dist/
|
||||
dist_*/
|
||||
pages/
|
||||
public/
|
||||
test/
|
||||
test_watch/
|
||||
|
||||
+101
@@ -7,16 +7,117 @@
|
||||
|
||||
|
||||
|
||||
## 2026-06-04 - 13.44.0
|
||||
|
||||
### Features
|
||||
|
||||
- add DB-backed email and RemoteIngress hub settings (settings)
|
||||
- Add persisted email server settings with ops API handlers and web UI controls.
|
||||
- Extend RemoteIngress hub settings to manage enabled state, tunnel port, hub domain, and performance from the database.
|
||||
- Backfill email and RemoteIngress singleton settings from legacy bootstrap configuration during migrations.
|
||||
- Serialize SmartProxy, RemoteIngress, and email lifecycle updates to avoid overlapping runtime reconfiguration.
|
||||
|
||||
## 2026-06-03 - 13.43.5
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @serve.zone/catalog to ^2.12.8 (deps)
|
||||
- Updated @serve.zone/catalog from ^2.12.7 to ^2.12.8.
|
||||
|
||||
## 2026-06-03 - 13.43.4
|
||||
|
||||
### Fixes
|
||||
|
||||
- track tunnel streams using summary events (remoteingress)
|
||||
- Enable summary stream event mode for the RemoteIngress hub.
|
||||
- Synchronize active tunnel counts and stream totals from stream summary events.
|
||||
- Bump @serve.zone/remoteingress to ^4.23.0.
|
||||
- Remove obsolete Deno import map entries.
|
||||
|
||||
## 2026-06-03 - 13.43.3
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @push.rocks/smartproxy to ^27.12.6 (deps)
|
||||
- Updates package and Deno import dependencies from @push.rocks/smartproxy ^27.12.4 to ^27.12.6.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,49 +1,10 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.41.1",
|
||||
"version": "13.44.0",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
"dist_serve"
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
|
||||
"@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",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/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/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",
|
||||
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
|
||||
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
|
||||
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
|
||||
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
|
||||
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
|
||||
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.2",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
|
||||
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@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.3",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.4.0",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.41.1",
|
||||
"version": "13.44.0",
|
||||
"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.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,7 +61,7 @@
|
||||
"@push.rocks/smartnetwork": "^4.7.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartproxy": "^27.12.2",
|
||||
"@push.rocks/smartproxy": "^27.12.6",
|
||||
"@push.rocks/smartradius": "^1.3.0",
|
||||
"@push.rocks/smartrequest": "^5.0.3",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
@@ -69,12 +69,12 @@
|
||||
"@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.3",
|
||||
"@serve.zone/catalog": "^2.12.8",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@serve.zone/remoteingress": "^4.23.0",
|
||||
"@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"
|
||||
},
|
||||
|
||||
Generated
+143
-373
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -98,6 +110,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
expect(customPortRoute).toBeTruthy();
|
||||
expect(customPortRoute?.name).toEqual('custom-smtp-route');
|
||||
expect(customPortRoute?.action.targets[0].port).toEqual(12525);
|
||||
expect(customPortRoute?.remoteIngress).toBeUndefined();
|
||||
|
||||
// Check standard port mappings
|
||||
const smtpRoute = routes.find((r: any) => {
|
||||
@@ -114,7 +127,32 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email routes are exposed through RemoteIngress when enabled', async () => {
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [25, 587, 465],
|
||||
hostname: 'mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
const router = new DcRouter({
|
||||
emailConfig,
|
||||
remoteIngressConfig: {
|
||||
enabled: true,
|
||||
tunnelPort: 8443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
});
|
||||
|
||||
const routes = (router as any)['generateEmailRoutes'](emailConfig);
|
||||
expect(routes.length).toEqual(3);
|
||||
for (const route of routes) {
|
||||
expect(route.remoteIngress).toEqual({ enabled: true });
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
@@ -129,7 +167,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
opsServerPort: 3104,
|
||||
opsServerPort,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
@@ -151,6 +189,54 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
await router.stop();
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config updates are serialized', async () => {
|
||||
const router = new DcRouter({
|
||||
tls: {
|
||||
contactEmail: 'test@example.com',
|
||||
},
|
||||
});
|
||||
const delay = async () => await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||
let activeLifecycleSteps = 0;
|
||||
let overlapped = false;
|
||||
|
||||
const enterLifecycleStep = async () => {
|
||||
activeLifecycleSteps++;
|
||||
if (activeLifecycleSteps > 1) {
|
||||
overlapped = true;
|
||||
}
|
||||
await delay();
|
||||
activeLifecycleSteps--;
|
||||
};
|
||||
|
||||
(router as any).stopUnifiedEmailComponents = async () => {
|
||||
await enterLifecycleStep();
|
||||
};
|
||||
(router as any).setupUnifiedEmailHandling = async () => {
|
||||
await enterLifecycleStep();
|
||||
};
|
||||
|
||||
const firstConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
hostname: 'first.mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
const secondConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2526],
|
||||
hostname: 'second.mail.example.com',
|
||||
domains: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
router.updateEmailConfig(firstConfig),
|
||||
router.updateEmailConfig(secondConfig),
|
||||
]);
|
||||
|
||||
expect(overlapped).toEqual(false);
|
||||
expect(router.options.emailConfig?.hostname).toEqual('second.mail.example.com');
|
||||
});
|
||||
|
||||
// Final clean-up test
|
||||
tap.test('clean up after tests', async () => {
|
||||
// No-op
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -183,6 +183,50 @@ tap.test('EmailDomainManager start merges persisted managed domains after restar
|
||||
expect(managedDomain?.dnsMode).toEqual('internal-dns');
|
||||
});
|
||||
|
||||
tap.test('EmailDomainManager can resync managed domains after email settings replace runtime config', async () => {
|
||||
await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
const linkedDomain = await createDomainDoc('resync-domain', 'resync.example.com', 'provider');
|
||||
const stored = new EmailDomainDoc();
|
||||
stored.id = 'resync-email-domain';
|
||||
stored.domain = 'mail.resync.example.com';
|
||||
stored.linkedDomainId = linkedDomain.id;
|
||||
stored.subdomain = 'mail';
|
||||
stored.dkim = {
|
||||
selector: 'default',
|
||||
keySize: 2048,
|
||||
rotateKeys: false,
|
||||
rotationIntervalDays: 90,
|
||||
};
|
||||
stored.dnsStatus = {
|
||||
mx: 'unchecked',
|
||||
spf: 'unchecked',
|
||||
dkim: 'unchecked',
|
||||
dmarc: 'unchecked',
|
||||
};
|
||||
stored.createdAt = new Date().toISOString();
|
||||
stored.updatedAt = new Date().toISOString();
|
||||
await stored.save();
|
||||
|
||||
const dcRouterStub = {
|
||||
options: {
|
||||
emailConfig: createBaseEmailConfig(),
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailDomainManager(dcRouterStub);
|
||||
await manager.start();
|
||||
expect(dcRouterStub.options.emailConfig.domains.some((domain) => domain.domain === 'mail.resync.example.com')).toEqual(true);
|
||||
|
||||
dcRouterStub.options.emailConfig = createBaseEmailConfig();
|
||||
manager.setBaseEmailDomains(dcRouterStub.options.emailConfig.domains);
|
||||
await manager.syncManagedDomainsToRuntime();
|
||||
|
||||
const resyncedDomains = dcRouterStub.options.emailConfig.domains.map((domain) => domain.domain).sort();
|
||||
expect(resyncedDomains).toEqual(['mail.resync.example.com', 'static.example.com']);
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearTestState();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { DcRouterDb, EmailServerSettingsDoc } from '../ts/db/index.js';
|
||||
import { EmailSettingsManager } from '../ts/email/index.js';
|
||||
import type { IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
|
||||
const createTestDb = async () => {
|
||||
const storagePath = plugins.path.join(
|
||||
plugins.os.tmpdir(),
|
||||
`dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
);
|
||||
|
||||
DcRouterDb.resetInstance();
|
||||
const db = DcRouterDb.getInstance({
|
||||
storagePath,
|
||||
dbName: `dcrouter-email-settings-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
});
|
||||
await db.start();
|
||||
await db.getDb().mongoDb.createCollection('__test_init');
|
||||
|
||||
return {
|
||||
async cleanup() {
|
||||
await db.stop();
|
||||
DcRouterDb.resetInstance();
|
||||
await plugins.fs.promises.rm(storagePath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const testDbPromise = createTestDb();
|
||||
|
||||
const clearSettings = async () => {
|
||||
for (const doc of await EmailServerSettingsDoc.findAll()) {
|
||||
await doc.delete();
|
||||
}
|
||||
};
|
||||
|
||||
tap.test('EmailSettingsManager does not backfill from legacy constructor options', async () => {
|
||||
await testDbPromise;
|
||||
await clearSettings();
|
||||
|
||||
const options: IDcRouterOptions = {
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587],
|
||||
domains: [],
|
||||
routes: [],
|
||||
maxMessageSize: 1024,
|
||||
},
|
||||
emailPortConfig: {
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
},
|
||||
};
|
||||
|
||||
const manager = new EmailSettingsManager(options);
|
||||
await manager.start();
|
||||
|
||||
expect(manager.getPublicSettings().enabled).toEqual(false);
|
||||
expect(manager.getPublicSettings().hostname).toEqual(null);
|
||||
expect(options.emailConfig).toBeUndefined();
|
||||
expect(options.emailPortConfig).toBeUndefined();
|
||||
|
||||
await clearSettings();
|
||||
const migratedDoc = new EmailServerSettingsDoc();
|
||||
migratedDoc.settingsId = 'email-server-settings';
|
||||
migratedDoc.enabled = true;
|
||||
migratedDoc.emailConfig = {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587],
|
||||
domains: [],
|
||||
routes: [],
|
||||
maxMessageSize: 1024,
|
||||
};
|
||||
migratedDoc.emailPortConfig = {
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
};
|
||||
migratedDoc.updatedAt = Date.now();
|
||||
migratedDoc.updatedBy = 'migration';
|
||||
await migratedDoc.save();
|
||||
|
||||
const secondOptions: IDcRouterOptions = {
|
||||
emailConfig: {
|
||||
hostname: 'ignored.example.com',
|
||||
ports: [2525],
|
||||
domains: [],
|
||||
routes: [],
|
||||
},
|
||||
};
|
||||
const secondManager = new EmailSettingsManager(secondOptions);
|
||||
await secondManager.start();
|
||||
|
||||
expect(secondManager.getPublicSettings().hostname).toEqual('mail.example.com');
|
||||
expect(secondOptions.emailConfig?.hostname).toEqual('mail.example.com');
|
||||
});
|
||||
|
||||
tap.test('EmailSettingsManager updates redacted mutable server settings', async () => {
|
||||
await testDbPromise;
|
||||
await clearSettings();
|
||||
|
||||
const options: IDcRouterOptions = {};
|
||||
const manager = new EmailSettingsManager(options);
|
||||
await manager.start();
|
||||
expect(manager.getPublicSettings().enabled).toEqual(false);
|
||||
expect(options.emailConfig).toBeUndefined();
|
||||
|
||||
const settings = await manager.updateSettings(
|
||||
{
|
||||
enabled: true,
|
||||
hostname: 'smtp.example.com',
|
||||
ports: [587, 25, 587],
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
maxMessageSize: 2048,
|
||||
},
|
||||
'tester',
|
||||
);
|
||||
|
||||
expect(settings.enabled).toEqual(true);
|
||||
expect(settings.ports).toEqual([25, 587]);
|
||||
expect(settings.portMapping?.[587]).toEqual(10587);
|
||||
expect(options.emailConfig?.hostname).toEqual('smtp.example.com');
|
||||
expect(options.emailConfig?.maxMessageSize).toEqual(2048);
|
||||
|
||||
await manager.updateSettings({ enabled: false }, 'tester');
|
||||
expect(manager.getPublicSettings().enabled).toEqual(false);
|
||||
expect(options.emailConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('cleanup', async () => {
|
||||
const testDb = await testDbPromise;
|
||||
await clearSettings();
|
||||
await testDb.cleanup();
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,24 @@ function applySet(document: Record<string, any>, set: Record<string, unknown>):
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -52,6 +70,10 @@ function matchesQuery(document: Record<string, any>, query: Record<string, any>)
|
||||
|
||||
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) {
|
||||
@@ -61,11 +83,16 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
|
||||
}
|
||||
},
|
||||
}),
|
||||
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 };
|
||||
@@ -74,6 +101,7 @@ function createFakeCollection(documents: Array<Record<string, 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 };
|
||||
},
|
||||
};
|
||||
@@ -122,12 +150,19 @@ function createFakeDb(
|
||||
}
|
||||
|
||||
tap.test('migration runner applies schema steps through the current target', async () => {
|
||||
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.40.2');
|
||||
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.40.2');
|
||||
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 () => {
|
||||
@@ -179,4 +214,205 @@ tap.test('migration runner rematerializes source-profile-backed route security',
|
||||
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');
|
||||
});
|
||||
|
||||
tap.test('migration runner backfills RemoteIngress hub settings from legacy config seed', async () => {
|
||||
const hubSettingsDocs: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'remote-ingress-settings-1',
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
performance: undefined,
|
||||
updatedAt: 1,
|
||||
updatedBy: '',
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.5', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
|
||||
'13.43.6',
|
||||
{
|
||||
remoteIngressHubSettings: {
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: '203.0.113.10',
|
||||
performance: {
|
||||
profile: 'balanced',
|
||||
maxStreamsPerEdge: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(hubSettingsDocs[0].enabled).toEqual(true);
|
||||
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
|
||||
expect(hubSettingsDocs[0].hubDomain).toEqual('203.0.113.10');
|
||||
expect(hubSettingsDocs[0].performance.profile).toEqual('balanced');
|
||||
expect(hubSettingsDocs[0].performance.maxStreamsPerEdge).toEqual(10000);
|
||||
expect(hubSettingsDocs[0].updatedAt).not.toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('migration runner backfills RemoteIngress hub settings at current package target', async () => {
|
||||
const hubSettingsDocs: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'remote-ingress-settings-current',
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
updatedAt: 1,
|
||||
updatedBy: '',
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.2', { RemoteIngressHubSettingsDoc: hubSettingsDocs }),
|
||||
'13.43.5',
|
||||
{
|
||||
remoteIngressHubSettings: {
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(hubSettingsDocs[0].enabled).toEqual(true);
|
||||
expect(hubSettingsDocs[0].tunnelPort).toEqual(29443);
|
||||
expect(hubSettingsDocs[0].hubDomain).toEqual('ingress.example.com');
|
||||
});
|
||||
|
||||
tap.test('migration runner backfills Email server settings from legacy config seed', async () => {
|
||||
const emailSettingsDocs: Array<Record<string, any>> = [];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.2', { EmailServerSettingsDoc: emailSettingsDocs }),
|
||||
'13.43.5',
|
||||
{
|
||||
emailServerSettings: {
|
||||
enabled: true,
|
||||
emailConfig: {
|
||||
hostname: 'mail.example.com',
|
||||
ports: [25, 587],
|
||||
domains: [],
|
||||
routes: [],
|
||||
},
|
||||
emailPortConfig: {
|
||||
portMapping: { 25: 10025, 587: 10587 },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(emailSettingsDocs).toHaveLength(1);
|
||||
expect(emailSettingsDocs[0].enabled).toEqual(true);
|
||||
expect(emailSettingsDocs[0].emailConfig.hostname).toEqual('mail.example.com');
|
||||
expect(emailSettingsDocs[0].emailPortConfig.portMapping[25]).toEqual(10025);
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
@@ -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
-211
@@ -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,92 +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 replace inline route security when source profile is selected', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
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!.includes('127.0.0.1')).toBeFalse();
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should deduplicate IP lists during merge', async () => {
|
||||
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',
|
||||
@@ -173,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);
|
||||
@@ -222,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',
|
||||
@@ -313,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' }),
|
||||
@@ -351,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();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { RemoteIngressManager } from '../ts/remoteingress/index.js';
|
||||
import { RemoteIngressHubSettingsDoc } from '../ts/db/index.js';
|
||||
|
||||
tap.test('RemoteIngressManager preserves omitted hub settings on partial update', async () => {
|
||||
const originalLoad = RemoteIngressHubSettingsDoc.load;
|
||||
const fakeDoc: any = {
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
performance: {
|
||||
totalWindowBudgetBytes: 134217728,
|
||||
},
|
||||
updatedAt: 1,
|
||||
updatedBy: 'seed',
|
||||
save: async () => undefined,
|
||||
};
|
||||
|
||||
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
|
||||
try {
|
||||
const manager = new RemoteIngressManager();
|
||||
const settings = await manager.updateHubSettings({
|
||||
performance: {
|
||||
maxStreamsPerEdge: 10000,
|
||||
},
|
||||
}, 'test-user');
|
||||
|
||||
expect(settings.enabled).toEqual(true);
|
||||
expect(settings.tunnelPort).toEqual(29443);
|
||||
expect(settings.hubDomain).toEqual('ingress.example.com');
|
||||
expect(settings.performance?.maxStreamsPerEdge).toEqual(10000);
|
||||
} finally {
|
||||
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('RemoteIngressManager clears optional hub settings explicitly', async () => {
|
||||
const originalLoad = RemoteIngressHubSettingsDoc.load;
|
||||
const fakeDoc: any = {
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
enabled: true,
|
||||
tunnelPort: 29443,
|
||||
hubDomain: 'ingress.example.com',
|
||||
performance: {
|
||||
maxStreamsPerEdge: 10000,
|
||||
},
|
||||
updatedAt: 1,
|
||||
updatedBy: 'seed',
|
||||
save: async () => undefined,
|
||||
};
|
||||
|
||||
(RemoteIngressHubSettingsDoc as any).load = async () => fakeDoc;
|
||||
try {
|
||||
const manager = new RemoteIngressManager();
|
||||
const settings = await manager.updateHubSettings({
|
||||
hubDomain: null,
|
||||
performance: null,
|
||||
}, 'test-user');
|
||||
|
||||
expect(settings.enabled).toEqual(true);
|
||||
expect(settings.tunnelPort).toEqual(29443);
|
||||
expect(settings.hubDomain).toBeUndefined();
|
||||
expect(settings.performance).toBeUndefined();
|
||||
} finally {
|
||||
(RemoteIngressHubSettingsDoc as any).load = originalLoad;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -159,10 +179,12 @@ tap.test('WorkHosterHandler exposes capabilities and managed domains with workho
|
||||
scopes: ['workhosters:read'],
|
||||
dcRouterRef: {
|
||||
options: {
|
||||
remoteIngressConfig: { enabled: true },
|
||||
dnsScopes: ['example.com'],
|
||||
http3: { enabled: false },
|
||||
},
|
||||
remoteIngressManager: {
|
||||
getHubSettings: () => ({ enabled: true }),
|
||||
},
|
||||
routeConfigManager: {
|
||||
getMergedRoutes: () => ({ routes: [] }),
|
||||
},
|
||||
@@ -244,6 +266,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 +276,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 +299,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',
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.41.1',
|
||||
version: '13.44.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+363
-102
@@ -25,15 +25,16 @@ 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';
|
||||
import { DnsManager } from './dns/manager.dns.js';
|
||||
import { AcmeConfigManager } from './acme/manager.acme-config.js';
|
||||
import { EmailDomainManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import { EmailDomainManager, EmailSettingsManager, SmartMtaStorageManager, WorkAppMailManager, buildEmailDnsRecords } from './email/index.js';
|
||||
import type { IRoute } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig } from '../ts_interfaces/data/remoteingress.js';
|
||||
import type { IEmailPortConfig, IEmailServerSettings, IEmailServerSettingsSeed, TEmailServerSettingsUpdate } from '../ts_interfaces/data/email-settings.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate } from '../ts_interfaces/data/remoteingress.js';
|
||||
import type { ISecurityCompiledPolicy } from '../ts_interfaces/data/security-policy.js';
|
||||
|
||||
export interface IDcRouterOptions {
|
||||
@@ -57,14 +58,7 @@ export interface IDcRouterOptions {
|
||||
* Allows configuring specific ports for email handling
|
||||
* This overrides the default port mapping in the emailConfig
|
||||
*/
|
||||
emailPortConfig?: {
|
||||
/** External to internal port mapping */
|
||||
portMapping?: Record<number, number>;
|
||||
/** Custom port configuration for specific ports */
|
||||
portSettings?: Record<number, any>;
|
||||
/** Path to store received emails */
|
||||
receivedEmailsPath?: string;
|
||||
};
|
||||
emailPortConfig?: IEmailPortConfig;
|
||||
|
||||
/** TLS/certificate configuration */
|
||||
tls?: {
|
||||
@@ -282,6 +276,8 @@ export class DcRouter {
|
||||
public remoteIngressManager?: RemoteIngressManager;
|
||||
public tunnelManager?: TunnelManager;
|
||||
private remoteIngressHubLifecycleChain: Promise<void> = Promise.resolve();
|
||||
private smartProxyLifecycleChain: Promise<void> = Promise.resolve();
|
||||
private emailLifecycleChain: Promise<void> = Promise.resolve();
|
||||
private remoteIngressHubStopping = false;
|
||||
private remoteIngressHubGeneration = 0;
|
||||
|
||||
@@ -300,6 +296,7 @@ export class DcRouter {
|
||||
|
||||
// ACME configuration (DB-backed singleton, replaces tls.contactEmail)
|
||||
public acmeConfigManager?: AcmeConfigManager;
|
||||
public emailSettingsManager?: EmailSettingsManager;
|
||||
public emailDomainManager?: EmailDomainManager;
|
||||
public workAppMailManager: WorkAppMailManager;
|
||||
public securityPolicyManager?: SecurityPolicyManager;
|
||||
@@ -341,7 +338,7 @@ export class DcRouter {
|
||||
|
||||
// Seed routes assembled during setupSmartProxy, passed to RouteConfigManager for DB seeding
|
||||
private seedConfigRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private seedEmailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private seedEmailRoutes: IDcRouterRouteConfig[] = [];
|
||||
private seedDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
// Live DoH routes used during SmartProxy bootstrap before RouteConfigManager re-applies stored routes.
|
||||
private runtimeDnsRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
@@ -482,7 +479,7 @@ export class DcRouter {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailDomainManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.dependsOn('DcRouterDb', 'EmailSettingsManager')
|
||||
.withStart(async () => {
|
||||
this.emailDomainManager = new EmailDomainManager(this);
|
||||
await this.emailDomainManager.start();
|
||||
@@ -496,6 +493,28 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// EmailSettingsManager: optional, depends on DcRouterDb — owns the DB-backed
|
||||
// singleton email server config and projects it into runtime options before
|
||||
// SmartProxy and EmailDomainManager read email settings.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('EmailSettingsManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.emailSettingsManager = new EmailSettingsManager(this.options);
|
||||
await this.emailSettingsManager.start();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailSettingsManager) {
|
||||
await this.emailSettingsManager.stop();
|
||||
this.emailSettingsManager = undefined;
|
||||
}
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// SecurityPolicyManager: optional, depends on DcRouterDb — owns IP intelligence
|
||||
// and compiles the global block policy for SmartProxy and remote ingress edges.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
@@ -519,13 +538,34 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// RemoteIngressManager: optional, depends on DcRouterDb — owns DB-backed
|
||||
// hub settings and edge registrations. It starts before SmartProxy so
|
||||
// SmartProxy can use the DB-backed enabled flag for PROXY protocol setup.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('RemoteIngressManager')
|
||||
.optional()
|
||||
.dependsOn('DcRouterDb')
|
||||
.withStart(async () => {
|
||||
this.remoteIngressManager = new RemoteIngressManager();
|
||||
await this.remoteIngressManager.initialize();
|
||||
})
|
||||
.withStop(async () => {
|
||||
this.remoteIngressManager = undefined;
|
||||
})
|
||||
.withRetry({ maxRetries: 1, baseDelayMs: 500 }),
|
||||
);
|
||||
}
|
||||
|
||||
// SmartProxy: critical, depends on DcRouterDb + DnsManager + AcmeConfigManager (if enabled)
|
||||
const smartProxyDeps: string[] = [];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
smartProxyDeps.push('DcRouterDb');
|
||||
smartProxyDeps.push('DnsManager');
|
||||
smartProxyDeps.push('AcmeConfigManager');
|
||||
smartProxyDeps.push('EmailSettingsManager');
|
||||
smartProxyDeps.push('SecurityPolicyManager');
|
||||
smartProxyDeps.push('RemoteIngressManager');
|
||||
}
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('SmartProxy')
|
||||
@@ -535,11 +575,20 @@ export class DcRouter {
|
||||
await this.setupSmartProxy();
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.smartProxy) {
|
||||
this.smartProxy.removeAllListeners();
|
||||
await this.smartProxy.stop();
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
await this.queueSmartProxyLifecycleTask(async () => {
|
||||
try {
|
||||
if (this.smartProxy) {
|
||||
const existingSmartProxy = this.smartProxy;
|
||||
existingSmartProxy.removeAllListeners();
|
||||
await existingSmartProxy.stop();
|
||||
if (this.smartProxy === existingSmartProxy) {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await this.stopSmartAcme();
|
||||
}
|
||||
});
|
||||
})
|
||||
.withRetry({ maxRetries: 0 }),
|
||||
);
|
||||
@@ -597,7 +646,7 @@ export class DcRouter {
|
||||
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();
|
||||
@@ -630,7 +679,7 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Email Server: optional, depends on SmartProxy
|
||||
if (this.options.emailConfig) {
|
||||
if (this.options.dbConfig?.enabled !== false || this.options.emailConfig) {
|
||||
const emailServiceDeps = ['SmartProxy', 'MetricsManager'];
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
emailServiceDeps.push('EmailDomainManager');
|
||||
@@ -640,14 +689,18 @@ export class DcRouter {
|
||||
.optional()
|
||||
.dependsOn(...emailServiceDeps)
|
||||
.withStart(async () => {
|
||||
await this.setupUnifiedEmailHandling();
|
||||
await this.queueEmailLifecycleTask(async () => {
|
||||
if (!this.options.emailConfig) {
|
||||
logger.log('info', 'EmailServer: no email settings configured, skipping startup');
|
||||
return;
|
||||
}
|
||||
await this.setupUnifiedEmailHandling();
|
||||
});
|
||||
})
|
||||
.withStop(async () => {
|
||||
if (this.emailServer) {
|
||||
this.clearEmailEventSubscriptions();
|
||||
await this.emailServer.stop();
|
||||
this.emailServer = undefined;
|
||||
}
|
||||
await this.queueEmailLifecycleTask(async () => {
|
||||
await this.stopUnifiedEmailComponents();
|
||||
});
|
||||
})
|
||||
.withRetry({ maxRetries: 3, baseDelayMs: 2000, maxDelayMs: 30_000 }),
|
||||
);
|
||||
@@ -658,7 +711,7 @@ export class DcRouter {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('DnsServer')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy', ...(this.options.emailConfig ? ['EmailServer'] : []))
|
||||
.dependsOn('SmartProxy', ...((this.options.dbConfig?.enabled !== false || this.options.emailConfig) ? ['EmailServer'] : []))
|
||||
.withStart(async () => {
|
||||
await this.setupDnsWithSocketHandler();
|
||||
})
|
||||
@@ -702,12 +755,14 @@ export class DcRouter {
|
||||
);
|
||||
}
|
||||
|
||||
// Remote Ingress: optional, depends on SmartProxy
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
// Remote Ingress: optional, depends on SmartProxy and DB-backed settings.
|
||||
// The service starts as a no-op when the DB setting is disabled, so the UI
|
||||
// can still manage edge registrations and hub settings.
|
||||
if (this.options.dbConfig?.enabled !== false) {
|
||||
this.serviceManager.addService(
|
||||
new plugins.taskbuffer.Service('RemoteIngress')
|
||||
.optional()
|
||||
.dependsOn('SmartProxy')
|
||||
.dependsOn('SmartProxy', 'RemoteIngressManager')
|
||||
.withStart(async () => {
|
||||
await this.setupRemoteIngress();
|
||||
})
|
||||
@@ -752,6 +807,42 @@ export class DcRouter {
|
||||
});
|
||||
}
|
||||
|
||||
private isRemoteIngressHubEnabled(): boolean {
|
||||
return this.remoteIngressManager?.getHubSettings().enabled
|
||||
?? this.options.remoteIngressConfig?.enabled
|
||||
?? false;
|
||||
}
|
||||
|
||||
private getRemoteIngressHubSettingsLegacySeed(): TRemoteIngressHubSettingsUpdate {
|
||||
const remoteIngressConfig = this.options.remoteIngressConfig;
|
||||
const seed: TRemoteIngressHubSettingsUpdate = {};
|
||||
if (remoteIngressConfig?.enabled !== undefined) {
|
||||
seed.enabled = remoteIngressConfig.enabled;
|
||||
}
|
||||
if (remoteIngressConfig?.tunnelPort !== undefined) {
|
||||
seed.tunnelPort = remoteIngressConfig.tunnelPort;
|
||||
}
|
||||
if (remoteIngressConfig?.hubDomain !== undefined) {
|
||||
seed.hubDomain = remoteIngressConfig.hubDomain;
|
||||
}
|
||||
if (remoteIngressConfig?.performance !== undefined) {
|
||||
seed.performance = remoteIngressConfig.performance;
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
private getEmailSettingsLegacySeed(): IEmailServerSettingsSeed {
|
||||
const seed: IEmailServerSettingsSeed = {};
|
||||
if (this.options.emailConfig) {
|
||||
seed.enabled = true;
|
||||
seed.emailConfig = JSON.parse(JSON.stringify(this.options.emailConfig));
|
||||
}
|
||||
if (this.options.emailPortConfig) {
|
||||
seed.emailPortConfig = JSON.parse(JSON.stringify(this.options.emailPortConfig));
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
private startSmartAcmeInBackground(): void {
|
||||
if (!this.smartAcme) {
|
||||
this.smartAcmeReady = false;
|
||||
@@ -965,10 +1056,11 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Remote Ingress summary
|
||||
if (this.tunnelManager && this.options.remoteIngressConfig?.enabled) {
|
||||
const remoteIngressHubSettings = this.remoteIngressManager?.getHubSettings();
|
||||
if (this.tunnelManager && remoteIngressHubSettings?.enabled) {
|
||||
const edgeCount = this.remoteIngressManager?.getAllEdges().length || 0;
|
||||
const connectedCount = this.tunnelManager.getConnectedCount();
|
||||
logger.log('info', `Remote Ingress: tunnel port=${this.options.remoteIngressConfig.tunnelPort || 8443}, edges=${edgeCount} registered/${connectedCount} connected`);
|
||||
logger.log('info', `Remote Ingress: tunnel port=${remoteIngressHubSettings.tunnelPort}, edges=${edgeCount} registered/${connectedCount} connected`);
|
||||
}
|
||||
|
||||
// Database summary
|
||||
@@ -1013,7 +1105,10 @@ export class DcRouter {
|
||||
|
||||
// Run any pending data migrations before anything else reads from the DB.
|
||||
// This must complete before ConfigManagers loads profiles.
|
||||
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version);
|
||||
const migration = await createMigrationRunner(this.dcRouterDb.getDb(), commitinfo.version, {
|
||||
remoteIngressHubSettings: this.getRemoteIngressHubSettingsLegacySeed(),
|
||||
emailServerSettings: this.getEmailSettingsLegacySeed(),
|
||||
});
|
||||
const migrationResult = await migration.run();
|
||||
if (migrationResult.stepsApplied.length > 0) {
|
||||
logger.log('info',
|
||||
@@ -1043,8 +1138,16 @@ export class DcRouter {
|
||||
|
||||
// Clean up any existing SmartProxy instance (e.g. from a retry)
|
||||
if (this.smartProxy) {
|
||||
this.smartProxy.removeAllListeners();
|
||||
this.smartProxy = undefined;
|
||||
const existingSmartProxy = this.smartProxy;
|
||||
try {
|
||||
existingSmartProxy.removeAllListeners();
|
||||
await existingSmartProxy.stop();
|
||||
if (this.smartProxy === existingSmartProxy) {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
} finally {
|
||||
await this.stopSmartAcme();
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble serializable seed routes from constructor config — these will be seeded into DB
|
||||
@@ -1279,7 +1382,7 @@ export class DcRouter {
|
||||
|
||||
// When remoteIngress is enabled, the hub binary forwards tunneled connections
|
||||
// to SmartProxy with PROXY protocol v1 headers to preserve client IPs.
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
if (this.isRemoteIngressHubEnabled()) {
|
||||
smartProxyConfig.acceptProxyProtocol = true;
|
||||
if (!smartProxyConfig.proxyIPs) {
|
||||
smartProxyConfig.proxyIPs = [];
|
||||
@@ -1303,16 +1406,17 @@ export class DcRouter {
|
||||
// Create SmartProxy instance
|
||||
logger.log('info', `Creating SmartProxy instance: routes=${smartProxyConfig.routes?.length}, acme=${smartProxyConfig.acme?.enabled}, certProvisionFunction=${!!smartProxyConfig.certProvisionFunction}`);
|
||||
|
||||
this.smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
|
||||
const smartProxy = new plugins.smartproxy.SmartProxy(smartProxyConfig);
|
||||
this.smartProxy = smartProxy;
|
||||
|
||||
// Set up event listeners
|
||||
this.smartProxy.on('error', (err) => {
|
||||
smartProxy.on('error', (err) => {
|
||||
logger.log('error', `SmartProxy error: ${err.message}`, { stack: err.stack });
|
||||
});
|
||||
|
||||
// Always listen for certificate events — emitted by both ACME and certProvisionFunction paths
|
||||
// Events are keyed by domain for domain-centric certificate tracking
|
||||
this.smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||
smartProxy.on('certificate-issued', (event: plugins.smartproxy.ICertificateIssuedEvent) => {
|
||||
logger.log('info', `Certificate issued for ${event.domain} via ${event.source}, expires ${event.expiryDate}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
@@ -1326,7 +1430,7 @@ export class DcRouter {
|
||||
// Renewals come through 'certificate-issued' (with optional isRenewal? in the payload).
|
||||
// The vestigial 'certificate-renewed' event from common-types.ts is never emitted.
|
||||
|
||||
this.smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
||||
smartProxy.on('certificate-failed', (event: plugins.smartproxy.ICertificateFailedEvent) => {
|
||||
logger.log('error', `Certificate failed for ${event.domain} (${event.source}): ${event.error}`);
|
||||
const routeNames = this.findRouteNamesForDomain(event.domain);
|
||||
this.certificateStatusMap.set(event.domain, {
|
||||
@@ -1337,7 +1441,23 @@ export class DcRouter {
|
||||
|
||||
// Start SmartProxy
|
||||
logger.log('info', 'Starting SmartProxy...');
|
||||
await this.smartProxy.start();
|
||||
try {
|
||||
await smartProxy.start();
|
||||
} catch (err) {
|
||||
smartProxy.removeAllListeners();
|
||||
if (this.smartProxy === smartProxy) {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
await this.stopSmartAcme();
|
||||
if (this.certProvisionScheduler) {
|
||||
this.certProvisionScheduler.clear();
|
||||
this.certProvisionScheduler = undefined;
|
||||
}
|
||||
await smartProxy.stop().catch((stopErr) => {
|
||||
logger.log('warn', `Failed to clean up SmartProxy after startup failure: ${(stopErr as Error).message}`);
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
logger.log('info', 'SmartProxy started successfully');
|
||||
|
||||
// Populate certificateStatusMap for certs loaded from store at startup
|
||||
@@ -1460,8 +1580,8 @@ export class DcRouter {
|
||||
/**
|
||||
* Generate SmartProxy routes for email configuration
|
||||
*/
|
||||
private generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): plugins.smartproxy.IRouteConfig[] {
|
||||
const emailRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
private generateEmailRoutes(emailConfig: IUnifiedEmailServerOptions): IDcRouterRouteConfig[] {
|
||||
const emailRoutes: IDcRouterRouteConfig[] = [];
|
||||
|
||||
// Create routes for each email port
|
||||
for (const port of emailConfig.ports) {
|
||||
@@ -1535,13 +1655,17 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Create the route configuration
|
||||
const routeConfig: plugins.smartproxy.IRouteConfig = {
|
||||
const routeConfig: IDcRouterRouteConfig = {
|
||||
name: routeName,
|
||||
match: {
|
||||
ports: [port]
|
||||
},
|
||||
action: action
|
||||
};
|
||||
|
||||
if (this.isRemoteIngressHubEnabled()) {
|
||||
routeConfig.remoteIngress = { enabled: true };
|
||||
}
|
||||
|
||||
// Add the route to our list
|
||||
emailRoutes.push(routeConfig);
|
||||
@@ -1768,19 +1892,33 @@ export class DcRouter {
|
||||
});
|
||||
|
||||
// Create unified email server
|
||||
this.emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
const emailServer = new UnifiedEmailServer(this, emailConfig);
|
||||
this.emailServer = emailServer;
|
||||
this.clearEmailEventSubscriptions();
|
||||
|
||||
// Set up error handling
|
||||
this.addEmailEventSubscription(this.emailServer, 'error', (err: Error) => {
|
||||
this.addEmailEventSubscription(emailServer, 'error', (err: Error) => {
|
||||
logger.log('error', `UnifiedEmailServer error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Start the server
|
||||
await this.emailServer.start();
|
||||
try {
|
||||
await emailServer.start();
|
||||
} catch (error: unknown) {
|
||||
this.clearEmailEventSubscriptions();
|
||||
try {
|
||||
await emailServer.stop();
|
||||
} catch (stopError: unknown) {
|
||||
logger.log('warn', `Error cleaning up failed UnifiedEmailServer start: ${(stopError as Error).message}`);
|
||||
}
|
||||
if (this.emailServer === emailServer) {
|
||||
this.emailServer = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wire delivery events to MetricsManager and logger using smartmta's public queue APIs.
|
||||
if (this.metricsManager && this.emailServer) {
|
||||
if (this.metricsManager) {
|
||||
const getEnvelope = (item: { processingResult?: any; lastError?: string }) => {
|
||||
const emailLike = item?.processingResult;
|
||||
const from = emailLike?.from || emailLike?.email?.from || '';
|
||||
@@ -1795,34 +1933,34 @@ export class DcRouter {
|
||||
};
|
||||
};
|
||||
const updateQueueSize = () => {
|
||||
this.metricsManager!.updateQueueSize(this.emailServer!.getQueueStats().queueSize);
|
||||
this.metricsManager!.updateQueueSize(emailServer.getQueueStats().queueSize);
|
||||
};
|
||||
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemEnqueued', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailReceived(envelope.from);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email queued: ${envelope.from} → ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDelivered', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailSent(envelope.recipients[0]);
|
||||
updateQueueSize();
|
||||
logger.log('info', `Email delivered to ${envelope.recipients.join(', ') || 'unknown'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemFailed', (item: any) => {
|
||||
const envelope = getEnvelope(item);
|
||||
this.metricsManager!.trackEmailFailed(envelope.recipients[0], item?.lastError);
|
||||
updateQueueSize();
|
||||
logger.log('warn', `Email delivery failed to ${envelope.recipients.join(', ') || 'unknown'}: ${item?.lastError || 'unknown error'}`, { zone: 'email' });
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemDeferred', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||
this.addEmailEventSubscription(emailServer.deliveryQueue, 'itemRemoved', () => {
|
||||
updateQueueSize();
|
||||
});
|
||||
this.addEmailEventSubscription(this.emailServer, 'bounceProcessed', () => {
|
||||
this.addEmailEventSubscription(emailServer, 'bounceProcessed', () => {
|
||||
this.metricsManager!.trackEmailBounced();
|
||||
logger.log('warn', 'Email bounce processed', { zone: 'email' });
|
||||
});
|
||||
@@ -1837,16 +1975,57 @@ export class DcRouter {
|
||||
* @param config New email configuration
|
||||
*/
|
||||
public async updateEmailConfig(config: IUnifiedEmailServerOptions): Promise<void> {
|
||||
// Stop existing email components
|
||||
await this.stopUnifiedEmailComponents();
|
||||
|
||||
// Update configuration
|
||||
this.options.emailConfig = config;
|
||||
|
||||
// Start email handling with new configuration
|
||||
await this.setupUnifiedEmailHandling();
|
||||
|
||||
logger.log('info', 'Unified email configuration updated');
|
||||
await this.queueEmailLifecycleTask(async () => {
|
||||
// Stop existing email components
|
||||
await this.stopUnifiedEmailComponents();
|
||||
|
||||
// Update configuration
|
||||
this.options.emailConfig = config;
|
||||
this.emailDomainManager?.setBaseEmailDomains(config.domains as IEmailDomainConfig[] | undefined);
|
||||
await this.emailDomainManager?.syncManagedDomainsToRuntime();
|
||||
|
||||
// Start email handling with new configuration
|
||||
await this.setupUnifiedEmailHandling();
|
||||
|
||||
logger.log('info', 'Unified email configuration updated');
|
||||
});
|
||||
}
|
||||
|
||||
public async updateEmailServerSettings(
|
||||
settings: TEmailServerSettingsUpdate,
|
||||
updatedBy = 'system',
|
||||
): Promise<IEmailServerSettings> {
|
||||
return await this.queueEmailLifecycleTask(async () => {
|
||||
if (!this.emailSettingsManager) {
|
||||
throw new Error('EmailSettingsManager is not initialized');
|
||||
}
|
||||
|
||||
const updatedSettings = await this.emailSettingsManager.updateSettings(settings, updatedBy);
|
||||
this.emailDomainManager?.setBaseEmailDomains(this.options.emailConfig?.domains as IEmailDomainConfig[] | undefined);
|
||||
await this.emailDomainManager?.syncManagedDomainsToRuntime();
|
||||
this.seedEmailRoutes = this.options.emailConfig
|
||||
? this.generateEmailRoutes(this.options.emailConfig)
|
||||
: [];
|
||||
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.initialize(
|
||||
this.seedConfigRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedEmailRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
this.seedDnsRoutes as import('../ts_interfaces/data/remoteingress.js').IDcRouterRouteConfig[],
|
||||
);
|
||||
}
|
||||
|
||||
if (this.options.emailConfig) {
|
||||
if (this.emailServer) {
|
||||
await this.stopUnifiedEmailComponents();
|
||||
}
|
||||
await this.setupUnifiedEmailHandling();
|
||||
} else if (this.emailServer) {
|
||||
await this.stopUnifiedEmailComponents();
|
||||
}
|
||||
|
||||
return updatedSettings;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2438,7 +2617,14 @@ export class DcRouter {
|
||||
* Set up Remote Ingress hub for edge tunnel connections
|
||||
*/
|
||||
private async setupRemoteIngress(): Promise<void> {
|
||||
if (!this.options.remoteIngressConfig?.enabled) {
|
||||
const remoteIngressManager = this.remoteIngressManager;
|
||||
if (!remoteIngressManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hubSettings = remoteIngressManager.getHubSettings();
|
||||
if (!hubSettings.enabled) {
|
||||
logger.log('info', 'Remote Ingress hub is disabled in DB settings');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2446,14 +2632,6 @@ export class DcRouter {
|
||||
this.remoteIngressHubStopping = false;
|
||||
const generation = ++this.remoteIngressHubGeneration;
|
||||
|
||||
// Initialize the edge registration manager
|
||||
const remoteIngressManager = new RemoteIngressManager(this.options.remoteIngressConfig.performance);
|
||||
this.remoteIngressManager = remoteIngressManager;
|
||||
await remoteIngressManager.initialize();
|
||||
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
|
||||
if (!this.isRemoteIngressHubGenerationCurrent(generation, remoteIngressManager)) {
|
||||
return;
|
||||
@@ -2483,7 +2661,7 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
const edgeCount = remoteIngressManager.getAllEdges().length;
|
||||
logger.log('info', `Remote Ingress hub started on port ${this.options.remoteIngressConfig.tunnelPort || 8443} with ${edgeCount} registered edge(s)`);
|
||||
logger.log('info', `Remote Ingress hub started on port ${hubSettings.tunnelPort} with ${edgeCount} registered edge(s)`);
|
||||
}
|
||||
|
||||
private isRemoteIngressHubGenerationCurrent(generation: number, manager: RemoteIngressManager): boolean {
|
||||
@@ -2498,17 +2676,30 @@ export class DcRouter {
|
||||
return run;
|
||||
}
|
||||
|
||||
private queueSmartProxyLifecycleTask<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.smartProxyLifecycleChain.then(task);
|
||||
this.smartProxyLifecycleChain = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private queueEmailLifecycleTask<T>(task: () => Promise<T>): Promise<T> {
|
||||
const run = this.emailLifecycleChain.then(task);
|
||||
this.emailLifecycleChain = run.then(() => undefined, () => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async stopRemoteIngress(): Promise<void> {
|
||||
this.remoteIngressHubStopping = true;
|
||||
this.remoteIngressHubGeneration++;
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
const currentTunnelManager = this.tunnelManager;
|
||||
this.tunnelManager = undefined;
|
||||
if (currentTunnelManager) {
|
||||
await currentTunnelManager.stop();
|
||||
if (this.tunnelManager === currentTunnelManager) {
|
||||
this.tunnelManager = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.remoteIngressManager = undefined;
|
||||
}
|
||||
|
||||
public async mutateRemoteIngressEdges<T>(
|
||||
@@ -2544,35 +2735,96 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
public async updateRemoteIngressHubSettings(
|
||||
updates: { performance?: IRemoteIngressPerformanceConfig },
|
||||
updates: TRemoteIngressHubSettingsUpdate,
|
||||
updatedBy: string,
|
||||
): Promise<IRemoteIngressHubSettings> {
|
||||
return await this.queueRemoteIngressHubTask(async () => {
|
||||
if (this.remoteIngressHubStopping) {
|
||||
throw new Error('RemoteIngress is stopping');
|
||||
}
|
||||
if (!this.remoteIngressManager) {
|
||||
throw new Error('RemoteIngress is not configured');
|
||||
const manager = this.remoteIngressManager;
|
||||
if (!manager) {
|
||||
throw new Error('RemoteIngress is not configured');
|
||||
}
|
||||
|
||||
const previousSettings = manager.getHubSettings();
|
||||
const settings = await manager.updateHubSettings(updates, updatedBy);
|
||||
const enabledChanged = previousSettings.enabled !== settings.enabled;
|
||||
|
||||
if (!settings.enabled) {
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
await this.stopRemoteIngressTunnelHubLocked();
|
||||
});
|
||||
}
|
||||
|
||||
if (enabledChanged) {
|
||||
await this.restartSmartProxyForRemoteIngressSettings();
|
||||
}
|
||||
|
||||
if (settings.enabled) {
|
||||
await this.queueRemoteIngressHubTask(async () => {
|
||||
await this.restartRemoteIngressTunnelHubLocked();
|
||||
});
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private async restartSmartProxyForRemoteIngressSettings(): Promise<void> {
|
||||
await this.queueSmartProxyLifecycleTask(async () => {
|
||||
const restartSmartProxy = async () => {
|
||||
try {
|
||||
if (this.smartProxy) {
|
||||
const existingSmartProxy = this.smartProxy;
|
||||
existingSmartProxy.removeAllListeners();
|
||||
await existingSmartProxy.stop();
|
||||
if (this.smartProxy === existingSmartProxy) {
|
||||
this.smartProxy = undefined;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await this.stopSmartAcme();
|
||||
}
|
||||
await this.setupSmartProxy();
|
||||
};
|
||||
|
||||
if (this.routeConfigManager) {
|
||||
await this.routeConfigManager.runExclusiveRouteUpdate(restartSmartProxy);
|
||||
} else {
|
||||
await restartSmartProxy();
|
||||
}
|
||||
|
||||
const settings = await this.remoteIngressManager.updateHubSettings(updates, updatedBy);
|
||||
if (this.options.remoteIngressConfig?.enabled) {
|
||||
await this.restartRemoteIngressTunnelHubLocked();
|
||||
if (!this.routeConfigManager) {
|
||||
return;
|
||||
}
|
||||
return settings;
|
||||
await this.routeConfigManager.initialize(
|
||||
this.seedConfigRoutes as IDcRouterRouteConfig[],
|
||||
this.seedEmailRoutes as IDcRouterRouteConfig[],
|
||||
this.seedDnsRoutes as IDcRouterRouteConfig[],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async stopRemoteIngressTunnelHubLocked(): Promise<void> {
|
||||
this.remoteIngressHubGeneration++;
|
||||
const currentTunnelManager = this.tunnelManager;
|
||||
if (currentTunnelManager) {
|
||||
await currentTunnelManager.stop();
|
||||
if (this.tunnelManager === currentTunnelManager) {
|
||||
this.tunnelManager = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async restartRemoteIngressTunnelHubLocked(): Promise<void> {
|
||||
const generation = ++this.remoteIngressHubGeneration;
|
||||
if (!this.remoteIngressManager || !this.options.remoteIngressConfig?.enabled || this.remoteIngressHubStopping) {
|
||||
const hubSettings = this.remoteIngressManager?.getHubSettings();
|
||||
if (!this.remoteIngressManager || !hubSettings?.enabled || this.remoteIngressHubStopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTunnelManager = this.tunnelManager;
|
||||
this.tunnelManager = undefined;
|
||||
if (currentTunnelManager) {
|
||||
await currentTunnelManager.stop();
|
||||
if (this.tunnelManager === currentTunnelManager) {
|
||||
this.tunnelManager = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
|
||||
@@ -2582,19 +2834,25 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
private async startRemoteIngressTunnelHubLocked(generation: number): Promise<void> {
|
||||
const riCfg = this.options.remoteIngressConfig;
|
||||
const manager = this.remoteIngressManager;
|
||||
if (!riCfg?.enabled || !manager || this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
|
||||
const hubSettings = manager?.getHubSettings();
|
||||
if (!manager || !hubSettings?.enabled || this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tlsConfig = await this.resolveRemoteIngressTlsConfig(riCfg);
|
||||
const firewallConfig = await this.securityPolicyManager?.compileRemoteIngressFirewall();
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
|
||||
return;
|
||||
}
|
||||
manager.setFirewallConfig(firewallConfig);
|
||||
|
||||
const tlsConfig = await this.resolveRemoteIngressTlsConfig(hubSettings.hubDomain);
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnelManager = new TunnelManager(manager, {
|
||||
tunnelPort: riCfg.tunnelPort ?? 8443,
|
||||
tunnelPort: hubSettings.tunnelPort,
|
||||
targetHost: '127.0.0.1',
|
||||
tls: tlsConfig,
|
||||
performance: manager.getHubPerformanceConfig(),
|
||||
@@ -2607,23 +2865,26 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
if (this.remoteIngressHubStopping || generation !== this.remoteIngressHubGeneration || this.remoteIngressManager !== manager) {
|
||||
await tunnelManager.stop();
|
||||
await tunnelManager.stop().catch((err) => {
|
||||
logger.log('warn', `Failed to stop stale RemoteIngress tunnel hub: ${(err as Error).message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.tunnelManager = tunnelManager;
|
||||
}
|
||||
|
||||
private async resolveRemoteIngressTlsConfig(
|
||||
riCfg: NonNullable<IDcRouterOptions['remoteIngressConfig']>,
|
||||
hubDomain?: string,
|
||||
): Promise<{ certPem: string; keyPem: string } | undefined> {
|
||||
// Resolve TLS certs for tunnel: explicit paths > ACME for hubDomain > self-signed (Rust default)
|
||||
let tlsConfig: { certPem: string; keyPem: string } | undefined;
|
||||
|
||||
// Priority 1: Explicit cert/key file paths
|
||||
if (riCfg.tls?.certPath && riCfg.tls?.keyPath) {
|
||||
const explicitTls = this.options.remoteIngressConfig?.tls;
|
||||
if (explicitTls?.certPath && explicitTls?.keyPath) {
|
||||
try {
|
||||
const certPem = plugins.fs.readFileSync(riCfg.tls.certPath, 'utf8');
|
||||
const keyPem = plugins.fs.readFileSync(riCfg.tls.keyPath, 'utf8');
|
||||
const certPem = plugins.fs.readFileSync(explicitTls.certPath, 'utf8');
|
||||
const keyPem = plugins.fs.readFileSync(explicitTls.keyPath, 'utf8');
|
||||
tlsConfig = { certPem, keyPem };
|
||||
logger.log('info', 'Using explicit TLS cert/key for RemoteIngress tunnel');
|
||||
} catch (err: unknown) {
|
||||
@@ -2632,12 +2893,12 @@ export class DcRouter {
|
||||
}
|
||||
|
||||
// Priority 2: Existing cert from SmartProxy cert store for hubDomain
|
||||
if (!tlsConfig && riCfg.hubDomain) {
|
||||
if (!tlsConfig && hubDomain) {
|
||||
try {
|
||||
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||
const stored = await ProxyCertDoc.findByDomain(hubDomain);
|
||||
if (stored?.publicKey && stored?.privateKey) {
|
||||
tlsConfig = { certPem: stored.publicKey, keyPem: stored.privateKey };
|
||||
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${riCfg.hubDomain}`);
|
||||
logger.log('info', `Using stored ACME cert for RemoteIngress tunnel TLS: ${hubDomain}`);
|
||||
}
|
||||
} catch { /* no stored cert, fall through */ }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,8 +288,8 @@ export class ReferenceResolver {
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* When a source profile is selected, it owns the route security fully.
|
||||
* 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(
|
||||
@@ -290,18 +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);
|
||||
route = {
|
||||
...route,
|
||||
security: this.cloneSecurityFields(resolvedSecurity),
|
||||
};
|
||||
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(),
|
||||
@@ -550,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);
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -78,6 +85,10 @@ export class RouteConfigManager {
|
||||
this.getVpnClientAccessForRoute = resolver;
|
||||
}
|
||||
|
||||
public async runExclusiveRouteUpdate<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return await this.routeUpdateMutex.runExclusive(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted routes, seed serializable config/email/dns routes,
|
||||
* compute warnings, and apply the combined DB-backed + runtime route set to SmartProxy.
|
||||
@@ -119,6 +130,10 @@ export class RouteConfigManager {
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
public getHttpRedirects(): IHttpRedirectInfo[] {
|
||||
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
@@ -131,6 +146,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 +163,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,8 +197,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 previousSourceProfileRef = stored.metadata?.sourceProfileRef;
|
||||
const previousRoute = structuredClone(stored.route);
|
||||
const previousMetadata = structuredClone(stored.metadata);
|
||||
const previousEnabled = stored.enabled;
|
||||
|
||||
const isToggleOnlyPatch = patch.enabled !== undefined
|
||||
&& patch.route === undefined
|
||||
@@ -218,13 +247,6 @@ export class RouteConfigManager {
|
||||
...stored.metadata,
|
||||
...patch.metadata,
|
||||
});
|
||||
if (
|
||||
previousSourceProfileRef
|
||||
&& !stored.metadata?.sourceProfileRef
|
||||
&& !patch.route?.security
|
||||
) {
|
||||
delete stored.route.security;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve if metadata refs exist and resolver is available
|
||||
@@ -234,6 +256,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);
|
||||
@@ -453,9 +483,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
|
||||
@@ -476,13 +505,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') {
|
||||
@@ -507,6 +533,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
|
||||
// =========================================================================
|
||||
@@ -567,16 +714,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));
|
||||
}
|
||||
@@ -592,9 +732,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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DcRouterDb } from '../classes.dcrouter-db.js';
|
||||
import type { IEmailPortConfig } from '../../../ts_interfaces/data/email-settings.js';
|
||||
|
||||
const getDb = () => DcRouterDb.getInstance().getDb();
|
||||
|
||||
@plugins.smartdata.Collection(() => getDb())
|
||||
export class EmailServerSettingsDoc extends plugins.smartdata.SmartDataDbDoc<EmailServerSettingsDoc, EmailServerSettingsDoc> {
|
||||
@plugins.smartdata.unI()
|
||||
@plugins.smartdata.svDb()
|
||||
public settingsId: string = 'email-server-settings';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public emailConfig?: IUnifiedEmailServerOptions;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public emailPortConfig?: IEmailPortConfig;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedAt: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public updatedBy: string = '';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static async load(): Promise<EmailServerSettingsDoc | null> {
|
||||
return await EmailServerSettingsDoc.getInstance({ settingsId: 'email-server-settings' });
|
||||
}
|
||||
|
||||
public static async findAll(): Promise<EmailServerSettingsDoc[]> {
|
||||
return await EmailServerSettingsDoc.getInstances({});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,15 @@ export class RemoteIngressHubSettingsDoc extends plugins.smartdata.SmartDataDbDo
|
||||
@plugins.smartdata.svDb()
|
||||
public settingsId: string = 'remote-ingress-hub-settings';
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public enabled?: boolean;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public tunnelPort?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public hubDomain?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public performance?: IRemoteIngressPerformanceConfig;
|
||||
|
||||
|
||||
@@ -40,3 +40,4 @@ export * from './classes.acme-config.doc.js';
|
||||
|
||||
// Email domain management
|
||||
export * from './classes.email-domain.doc.js';
|
||||
export * from './classes.email-server-settings.doc.js';
|
||||
|
||||
@@ -17,11 +17,15 @@ import { buildEmailDnsRecords } from './email-dns-records.js';
|
||||
*/
|
||||
export class EmailDomainManager {
|
||||
private dcRouter: any; // DcRouter — avoids circular import
|
||||
private readonly baseEmailDomains: IEmailDomainConfig[];
|
||||
private baseEmailDomains: IEmailDomainConfig[] = [];
|
||||
|
||||
constructor(dcRouterRef: any) {
|
||||
this.dcRouter = dcRouterRef;
|
||||
this.baseEmailDomains = ((this.dcRouter.options?.emailConfig?.domains || []) as IEmailDomainConfig[])
|
||||
this.setBaseEmailDomains(this.dcRouter.options?.emailConfig?.domains as IEmailDomainConfig[] | undefined);
|
||||
}
|
||||
|
||||
public setBaseEmailDomains(domains: IEmailDomainConfig[] | undefined): void {
|
||||
this.baseEmailDomains = (domains || [])
|
||||
.map((domainConfig) => JSON.parse(JSON.stringify(domainConfig)) as IEmailDomainConfig);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
import { EmailServerSettingsDoc } from '../db/index.js';
|
||||
import type { IDcRouterOptions } from '../classes.dcrouter.js';
|
||||
import type {
|
||||
IEmailPortConfig,
|
||||
IEmailServerSettings,
|
||||
TEmailServerSettingsUpdate,
|
||||
} from '../../ts_interfaces/data/email-settings.js';
|
||||
|
||||
const defaultEmailPorts = [25, 587, 465];
|
||||
|
||||
function clonePlain<T>(value: T | undefined): T | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function hasOwn(objectArg: object, keyArg: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
|
||||
}
|
||||
|
||||
export class EmailSettingsManager {
|
||||
private cachedEmailConfig?: IUnifiedEmailServerOptions;
|
||||
private cachedEmailPortConfig?: IEmailPortConfig;
|
||||
private enabled = false;
|
||||
private updatedAt = 0;
|
||||
private updatedBy = 'default';
|
||||
|
||||
constructor(private options: IDcRouterOptions) {}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
let doc = await EmailServerSettingsDoc.load();
|
||||
|
||||
if (!doc) {
|
||||
doc = new EmailServerSettingsDoc();
|
||||
doc.settingsId = 'email-server-settings';
|
||||
doc.enabled = false;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'default';
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
this.loadFromDoc(doc);
|
||||
this.applyToRuntimeOptions();
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
this.cachedEmailConfig = undefined;
|
||||
this.cachedEmailPortConfig = undefined;
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
public isEnabled(): boolean {
|
||||
return this.enabled && Boolean(this.cachedEmailConfig);
|
||||
}
|
||||
|
||||
public getEmailConfig(): IUnifiedEmailServerOptions | undefined {
|
||||
return this.isEnabled() ? clonePlain(this.cachedEmailConfig) : undefined;
|
||||
}
|
||||
|
||||
public getEmailPortConfig(): IEmailPortConfig | undefined {
|
||||
return this.isEnabled() ? clonePlain(this.cachedEmailPortConfig) : undefined;
|
||||
}
|
||||
|
||||
public getPublicSettings(): IEmailServerSettings {
|
||||
const emailConfig = this.cachedEmailConfig;
|
||||
const emailPortConfig = this.cachedEmailPortConfig;
|
||||
return {
|
||||
enabled: this.isEnabled(),
|
||||
hostname: emailConfig?.hostname || null,
|
||||
ports: [...(emailConfig?.ports || [])],
|
||||
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
|
||||
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
|
||||
maxMessageSize: emailConfig?.maxMessageSize ?? null,
|
||||
domainCount: emailConfig?.domains?.length || 0,
|
||||
routeCount: emailConfig?.routes?.length || 0,
|
||||
authUserCount: emailConfig?.auth?.users?.length || 0,
|
||||
updatedAt: this.updatedAt,
|
||||
updatedBy: this.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
public async updateSettings(
|
||||
updates: TEmailServerSettingsUpdate,
|
||||
updatedBy: string,
|
||||
): Promise<IEmailServerSettings> {
|
||||
let doc = await EmailServerSettingsDoc.load();
|
||||
if (!doc) {
|
||||
doc = new EmailServerSettingsDoc();
|
||||
doc.settingsId = 'email-server-settings';
|
||||
}
|
||||
|
||||
const nextEnabled = hasOwn(updates, 'enabled') ? Boolean(updates.enabled) : doc.enabled;
|
||||
const nextEmailConfig = this.patchEmailConfig(doc.emailConfig, updates, nextEnabled);
|
||||
const nextEmailPortConfig = this.patchEmailPortConfig(doc.emailPortConfig, updates);
|
||||
|
||||
doc.enabled = nextEnabled;
|
||||
doc.emailConfig = nextEmailConfig;
|
||||
doc.emailPortConfig = nextEmailPortConfig;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = updatedBy;
|
||||
await doc.save();
|
||||
|
||||
this.loadFromDoc(doc);
|
||||
this.applyToRuntimeOptions();
|
||||
return this.getPublicSettings();
|
||||
}
|
||||
|
||||
private loadFromDoc(doc: EmailServerSettingsDoc): void {
|
||||
this.enabled = doc.enabled;
|
||||
this.cachedEmailConfig = clonePlain(doc.emailConfig);
|
||||
this.cachedEmailPortConfig = clonePlain(doc.emailPortConfig);
|
||||
this.updatedAt = doc.updatedAt;
|
||||
this.updatedBy = doc.updatedBy;
|
||||
}
|
||||
|
||||
private applyToRuntimeOptions(): void {
|
||||
this.options.emailConfig = this.getEmailConfig();
|
||||
this.options.emailPortConfig = this.getEmailPortConfig();
|
||||
}
|
||||
|
||||
private patchEmailConfig(
|
||||
existingConfig: IUnifiedEmailServerOptions | undefined,
|
||||
updates: TEmailServerSettingsUpdate,
|
||||
nextEnabled: boolean,
|
||||
): IUnifiedEmailServerOptions | undefined {
|
||||
const nextConfig: IUnifiedEmailServerOptions | undefined = clonePlain(existingConfig) || (nextEnabled ? {
|
||||
hostname: 'localhost',
|
||||
ports: [...defaultEmailPorts],
|
||||
domains: [],
|
||||
routes: [],
|
||||
} : undefined);
|
||||
|
||||
if (!nextConfig) return undefined;
|
||||
|
||||
if (hasOwn(updates, 'hostname')) {
|
||||
const hostname = updates.hostname?.trim() || '';
|
||||
if (nextEnabled && !hostname) {
|
||||
throw new Error('Email hostname is required when email is enabled');
|
||||
}
|
||||
nextConfig.hostname = hostname || nextConfig.hostname;
|
||||
}
|
||||
|
||||
if (hasOwn(updates, 'ports')) {
|
||||
nextConfig.ports = this.normalizePorts(updates.ports || []);
|
||||
}
|
||||
|
||||
if (hasOwn(updates, 'maxMessageSize')) {
|
||||
if (updates.maxMessageSize === null || updates.maxMessageSize === undefined) {
|
||||
delete nextConfig.maxMessageSize;
|
||||
} else {
|
||||
const maxMessageSize = Number(updates.maxMessageSize);
|
||||
if (!Number.isInteger(maxMessageSize) || maxMessageSize <= 0) {
|
||||
throw new Error('maxMessageSize must be a positive integer');
|
||||
}
|
||||
nextConfig.maxMessageSize = maxMessageSize;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextEnabled) {
|
||||
if (!nextConfig.hostname?.trim()) {
|
||||
throw new Error('Email hostname is required when email is enabled');
|
||||
}
|
||||
nextConfig.ports = this.normalizePorts(nextConfig.ports || []);
|
||||
}
|
||||
|
||||
nextConfig.domains = nextConfig.domains || [];
|
||||
nextConfig.routes = nextConfig.routes || [];
|
||||
return nextConfig;
|
||||
}
|
||||
|
||||
private patchEmailPortConfig(
|
||||
existingPortConfig: IEmailPortConfig | undefined,
|
||||
updates: TEmailServerSettingsUpdate,
|
||||
): IEmailPortConfig | undefined {
|
||||
const nextPortConfig: IEmailPortConfig = clonePlain(existingPortConfig) || {};
|
||||
if (hasOwn(updates, 'portMapping')) {
|
||||
if (updates.portMapping === null) {
|
||||
delete nextPortConfig.portMapping;
|
||||
} else {
|
||||
nextPortConfig.portMapping = this.normalizePortMapping(updates.portMapping || {});
|
||||
}
|
||||
}
|
||||
if (hasOwn(updates, 'receivedEmailsPath')) {
|
||||
const receivedEmailsPath = updates.receivedEmailsPath?.trim() || '';
|
||||
if (receivedEmailsPath) {
|
||||
nextPortConfig.receivedEmailsPath = receivedEmailsPath;
|
||||
} else {
|
||||
delete nextPortConfig.receivedEmailsPath;
|
||||
}
|
||||
}
|
||||
return Object.keys(nextPortConfig).length > 0 ? nextPortConfig : undefined;
|
||||
}
|
||||
|
||||
private normalizePorts(ports: number[]): number[] {
|
||||
const normalized = [...new Set(ports.map((port) => Number(port)))];
|
||||
if (normalized.length === 0) {
|
||||
throw new Error('At least one email port is required when email is enabled');
|
||||
}
|
||||
for (const port of normalized) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid email port: ${port}`);
|
||||
}
|
||||
}
|
||||
return normalized.sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private normalizePortMapping(portMapping: Record<number, number>): Record<number, number> {
|
||||
const normalized: Record<number, number> = {};
|
||||
for (const [externalPortString, internalPortValue] of Object.entries(portMapping)) {
|
||||
const externalPort = Number(externalPortString);
|
||||
const internalPort = Number(internalPortValue);
|
||||
for (const port of [externalPort, internalPort]) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`Invalid email port mapping value: ${port}`);
|
||||
}
|
||||
}
|
||||
normalized[externalPort] = internalPort;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './classes.email-domain.manager.js';
|
||||
export * from './classes.email-settings.manager.js';
|
||||
export * from './classes.smartmta-storage-manager.js';
|
||||
export * from './classes.workapp-mail-manager.js';
|
||||
export * from './email-dns-records.js';
|
||||
|
||||
@@ -23,6 +23,7 @@ export class OpsServer {
|
||||
private statsHandler!: handlers.StatsHandler;
|
||||
private radiusHandler!: handlers.RadiusHandler;
|
||||
private emailOpsHandler!: handlers.EmailOpsHandler;
|
||||
private emailSettingsHandler!: handlers.EmailSettingsHandler;
|
||||
private certificateHandler!: handlers.CertificateHandler;
|
||||
private remoteIngressHandler!: handlers.RemoteIngressHandler;
|
||||
private routeManagementHandler!: handlers.RouteManagementHandler;
|
||||
@@ -82,6 +83,7 @@ export class OpsServer {
|
||||
this.statsHandler = new handlers.StatsHandler(this);
|
||||
this.radiusHandler = new handlers.RadiusHandler(this);
|
||||
this.emailOpsHandler = new handlers.EmailOpsHandler(this);
|
||||
this.emailSettingsHandler = new handlers.EmailSettingsHandler(this);
|
||||
this.certificateHandler = new handlers.CertificateHandler(this);
|
||||
this.remoteIngressHandler = new handlers.RemoteIngressHandler(this);
|
||||
this.routeManagementHandler = new handlers.RouteManagementHandler(this);
|
||||
|
||||
@@ -100,21 +100,23 @@ export class ConfigHandler {
|
||||
}
|
||||
|
||||
let portMapping: Record<string, number> | null = null;
|
||||
if (opts.emailPortConfig?.portMapping) {
|
||||
const emailSettings = dcRouter.emailSettingsManager?.getPublicSettings();
|
||||
const rawPortMapping = emailSettings?.portMapping || opts.emailPortConfig?.portMapping;
|
||||
if (rawPortMapping) {
|
||||
portMapping = {};
|
||||
for (const [ext, int] of Object.entries(opts.emailPortConfig.portMapping)) {
|
||||
for (const [ext, int] of Object.entries(rawPortMapping)) {
|
||||
portMapping[String(ext)] = int as number;
|
||||
}
|
||||
}
|
||||
|
||||
const email: interfaces.requests.IConfigData['email'] = {
|
||||
enabled: !!dcRouter.emailServer,
|
||||
ports: opts.emailConfig?.ports || [],
|
||||
enabled: emailSettings?.enabled ?? !!dcRouter.emailServer,
|
||||
ports: emailSettings?.ports || opts.emailConfig?.ports || [],
|
||||
portMapping,
|
||||
hostname: opts.emailConfig?.hostname || null,
|
||||
hostname: emailSettings?.hostname || opts.emailConfig?.hostname || null,
|
||||
domains: emailDomains,
|
||||
emailRouteCount: opts.emailConfig?.routes?.length || 0,
|
||||
receivedEmailsPath: opts.emailPortConfig?.receivedEmailsPath || null,
|
||||
emailRouteCount: emailSettings?.routeCount ?? opts.emailConfig?.routes?.length ?? 0,
|
||||
receivedEmailsPath: emailSettings?.receivedEmailsPath || opts.emailPortConfig?.receivedEmailsPath || null,
|
||||
};
|
||||
|
||||
// --- DNS ---
|
||||
@@ -186,16 +188,17 @@ export class ConfigHandler {
|
||||
|
||||
// --- Remote Ingress ---
|
||||
const riCfg = opts.remoteIngressConfig;
|
||||
const riSettings = dcRouter.remoteIngressManager?.getHubSettings();
|
||||
const connectedEdgeIps = dcRouter.tunnelManager?.getConnectedEdgeIps() || [];
|
||||
|
||||
// Determine TLS mode: custom certs > ACME from cert store > self-signed fallback
|
||||
let tlsMode: 'custom' | 'acme' | 'self-signed' = 'self-signed';
|
||||
if (riCfg?.tls?.certPath && riCfg?.tls?.keyPath) {
|
||||
tlsMode = 'custom';
|
||||
} else if (riCfg?.hubDomain) {
|
||||
} else if (riSettings?.hubDomain) {
|
||||
try {
|
||||
const { ProxyCertDoc } = await import('../../db/index.js');
|
||||
const stored = await ProxyCertDoc.findByDomain(riCfg.hubDomain);
|
||||
const stored = await ProxyCertDoc.findByDomain(riSettings.hubDomain);
|
||||
if (stored?.publicKey && stored?.privateKey) {
|
||||
tlsMode = 'acme';
|
||||
}
|
||||
@@ -203,12 +206,12 @@ export class ConfigHandler {
|
||||
}
|
||||
|
||||
const remoteIngress: interfaces.requests.IConfigData['remoteIngress'] = {
|
||||
enabled: !!dcRouter.remoteIngressManager,
|
||||
tunnelPort: riCfg?.tunnelPort || null,
|
||||
hubDomain: riCfg?.hubDomain || null,
|
||||
enabled: !!riSettings?.enabled,
|
||||
tunnelPort: riSettings?.tunnelPort || null,
|
||||
hubDomain: riSettings?.hubDomain || null,
|
||||
tlsMode,
|
||||
connectedEdgeIps,
|
||||
performance: dcRouter.remoteIngressManager?.getHubPerformanceConfig() || riCfg?.performance,
|
||||
performance: riSettings?.performance,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
export class EmailSettingsHandler {
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
this.registerHandlers();
|
||||
}
|
||||
|
||||
private registerHandlers(): void {
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetEmailServerSettings>(
|
||||
'getEmailServerSettings',
|
||||
async (dataArg) => {
|
||||
await requireOpsAuth(this.opsServerRef, dataArg, { scope: 'email-domains:read' as any });
|
||||
return { settings: this.getSettings() };
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_UpdateEmailServerSettings>(
|
||||
'updateEmailServerSettings',
|
||||
async (dataArg) => {
|
||||
const auth = await requireOpsAuth(this.opsServerRef, dataArg, {
|
||||
scope: 'email-domains:write' as any,
|
||||
requireAdminIdentity: true,
|
||||
});
|
||||
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
|
||||
if (!manager) {
|
||||
return { success: false, message: 'EmailSettingsManager not initialized' };
|
||||
}
|
||||
try {
|
||||
const settings = await this.opsServerRef.dcRouterRef.updateEmailServerSettings(
|
||||
dataArg.settings,
|
||||
auth.userId,
|
||||
);
|
||||
return { success: true, settings };
|
||||
} catch (err: unknown) {
|
||||
return { success: false, message: (err as Error).message };
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private getSettings(): interfaces.data.IEmailServerSettings {
|
||||
const manager = this.opsServerRef.dcRouterRef.emailSettingsManager;
|
||||
if (manager) {
|
||||
return manager.getPublicSettings();
|
||||
}
|
||||
const emailConfig = this.opsServerRef.dcRouterRef.options.emailConfig;
|
||||
const emailPortConfig = this.opsServerRef.dcRouterRef.options.emailPortConfig;
|
||||
return {
|
||||
enabled: Boolean(emailConfig),
|
||||
hostname: emailConfig?.hostname || null,
|
||||
ports: [...(emailConfig?.ports || [])],
|
||||
portMapping: emailPortConfig?.portMapping ? { ...emailPortConfig.portMapping } : null,
|
||||
receivedEmailsPath: emailPortConfig?.receivedEmailsPath || null,
|
||||
maxMessageSize: emailConfig?.maxMessageSize ?? null,
|
||||
domainCount: emailConfig?.domains?.length || 0,
|
||||
routeCount: emailConfig?.routes?.length || 0,
|
||||
authUserCount: emailConfig?.auth?.users?.length || 0,
|
||||
updatedAt: 0,
|
||||
updatedBy: 'legacy-options',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from './security.handler.js';
|
||||
export * from './stats.handler.js';
|
||||
export * from './radius.handler.js';
|
||||
export * from './email-ops.handler.js';
|
||||
export * from './email-settings.handler.js';
|
||||
export * from './certificate.handler.js';
|
||||
export * from './remoteingress.handler.js';
|
||||
export * from './route-management.handler.js';
|
||||
|
||||
@@ -3,6 +3,10 @@ import type { OpsServer } from '../classes.opsserver.js';
|
||||
import * as interfaces from '../../../ts_interfaces/index.js';
|
||||
import { requireOpsAuth } from '../helpers/auth.js';
|
||||
|
||||
function hasOwn(objectArg: object, keyArg: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
|
||||
}
|
||||
|
||||
export class RemoteIngressHandler {
|
||||
constructor(private opsServerRef: OpsServer) {
|
||||
this.registerHandlers();
|
||||
@@ -197,6 +201,8 @@ export class RemoteIngressHandler {
|
||||
const manager = this.opsServerRef.dcRouterRef.remoteIngressManager;
|
||||
return {
|
||||
settings: manager?.getHubSettings() || {
|
||||
enabled: false,
|
||||
tunnelPort: 8443,
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
},
|
||||
@@ -216,8 +222,22 @@ export class RemoteIngressHandler {
|
||||
});
|
||||
|
||||
try {
|
||||
const updates: interfaces.data.TRemoteIngressHubSettingsUpdate = {};
|
||||
if (hasOwn(dataArg, 'enabled') && dataArg.enabled !== undefined) {
|
||||
updates.enabled = dataArg.enabled;
|
||||
}
|
||||
if (hasOwn(dataArg, 'tunnelPort') && dataArg.tunnelPort !== undefined) {
|
||||
updates.tunnelPort = dataArg.tunnelPort;
|
||||
}
|
||||
if (hasOwn(dataArg, 'hubDomain')) {
|
||||
updates.hubDomain = dataArg.hubDomain ?? null;
|
||||
}
|
||||
if (hasOwn(dataArg, 'performance')) {
|
||||
updates.performance = dataArg.performance ?? null;
|
||||
}
|
||||
|
||||
const settings = await this.opsServerRef.dcRouterRef.updateRemoteIngressHubSettings(
|
||||
{ performance: dataArg.performance },
|
||||
updates,
|
||||
auth.userId,
|
||||
);
|
||||
return { success: true, settings };
|
||||
@@ -250,16 +270,16 @@ export class RemoteIngressHandler {
|
||||
return { success: false, message: 'Edge is disabled' };
|
||||
}
|
||||
|
||||
const hubHost = dataArg.hubHost
|
||||
|| this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.hubDomain;
|
||||
const hubSettings = manager.getHubSettings();
|
||||
const hubHost = dataArg.hubHost || hubSettings.hubDomain;
|
||||
if (!hubHost) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No hub hostname configured. Set hubDomain in remoteIngressConfig or provide hubHost.',
|
||||
message: 'No hub hostname configured. Set the RemoteIngress hub domain or provide hubHost.',
|
||||
};
|
||||
}
|
||||
|
||||
const hubPort = this.opsServerRef.dcRouterRef.options.remoteIngressConfig?.tunnelPort ?? 8443;
|
||||
const hubPort = hubSettings.tunnelPort;
|
||||
|
||||
const token = plugins.remoteingress.encodeConnectionToken({
|
||||
hubHost,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -282,7 +282,7 @@ export class WorkHosterHandler {
|
||||
outbound: Boolean(dcRouter.emailServer),
|
||||
},
|
||||
remoteIngress: {
|
||||
enabled: Boolean(dcRouter.options.remoteIngressConfig?.enabled),
|
||||
enabled: Boolean(dcRouter.remoteIngressManager?.getHubSettings().enabled),
|
||||
},
|
||||
dns: {
|
||||
authoritative: Boolean(dcRouter.options.dnsScopes?.length),
|
||||
@@ -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,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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...
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IDcRouterRouteConfig, IRemoteIngress, IRemoteIngressHubSettings, IRemoteIngressPerformanceConfig, TRemoteIngressHubSettingsUpdate, TRemoteIngressPerformanceProfile } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import { RemoteIngressEdgeDoc, RemoteIngressHubSettingsDoc } from '../db/index.js';
|
||||
|
||||
interface IRemoteIngressFirewallConfig {
|
||||
@@ -30,6 +30,11 @@ const performanceIntegerMaxByField: Record<TPerformanceIntegerField, number> = {
|
||||
};
|
||||
|
||||
const maxServerFirstPorts = 128;
|
||||
const defaultTunnelPort = 8443;
|
||||
|
||||
function hasOwn(objectArg: object, keyArg: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(objectArg, keyArg);
|
||||
}
|
||||
|
||||
function extractPorts(portRange: plugins.smartproxy.IRouteConfig['match']['ports']): number[] {
|
||||
const ports = new Set<number>(plugins.smartproxy.expandPortRange(portRange) as number[]);
|
||||
@@ -46,12 +51,13 @@ export class RemoteIngressManager {
|
||||
private routes: IDcRouterRouteConfig[] = [];
|
||||
private firewallConfig?: IRemoteIngressFirewallConfig;
|
||||
private hubSettings: IRemoteIngressHubSettings = {
|
||||
enabled: false,
|
||||
tunnelPort: defaultTunnelPort,
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
};
|
||||
|
||||
constructor(private seedHubPerformance?: IRemoteIngressPerformanceConfig) {
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Load all edge registrations from the database into memory.
|
||||
@@ -86,21 +92,17 @@ export class RemoteIngressManager {
|
||||
private async initializeHubSettings(): Promise<void> {
|
||||
let doc = await RemoteIngressHubSettingsDoc.load();
|
||||
if (!doc) {
|
||||
const seedPerformance = this.normalizePerformanceConfig(this.seedHubPerformance);
|
||||
if (seedPerformance) {
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.performance = seedPerformance;
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'seed';
|
||||
await doc.save();
|
||||
}
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.enabled = false;
|
||||
doc.tunnelPort = defaultTunnelPort;
|
||||
doc.hubDomain = '';
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = 'default';
|
||||
await doc.save();
|
||||
}
|
||||
|
||||
this.hubSettings = doc ? this.toHubSettings(doc) : {
|
||||
updatedAt: 0,
|
||||
updatedBy: 'default',
|
||||
};
|
||||
this.hubSettings = this.toHubSettings(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,16 +133,30 @@ export class RemoteIngressManager {
|
||||
}
|
||||
|
||||
public async updateHubSettings(
|
||||
updates: { performance?: IRemoteIngressPerformanceConfig },
|
||||
updates: TRemoteIngressHubSettingsUpdate,
|
||||
updatedBy: string,
|
||||
): Promise<IRemoteIngressHubSettings> {
|
||||
let doc = await RemoteIngressHubSettingsDoc.load();
|
||||
if (!doc) {
|
||||
doc = new RemoteIngressHubSettingsDoc();
|
||||
doc.settingsId = 'remote-ingress-hub-settings';
|
||||
doc.enabled = false;
|
||||
doc.tunnelPort = defaultTunnelPort;
|
||||
}
|
||||
|
||||
doc.performance = this.normalizePerformanceConfig(updates.performance);
|
||||
const normalized = this.normalizeHubSettingsUpdate(updates);
|
||||
if (hasOwn(normalized, 'enabled')) {
|
||||
doc.enabled = normalized.enabled;
|
||||
}
|
||||
if (hasOwn(normalized, 'tunnelPort')) {
|
||||
doc.tunnelPort = normalized.tunnelPort;
|
||||
}
|
||||
if (hasOwn(updates, 'hubDomain')) {
|
||||
doc.hubDomain = normalized.hubDomain || '';
|
||||
}
|
||||
if (hasOwn(updates, 'performance')) {
|
||||
doc.performance = normalized.performance || undefined;
|
||||
}
|
||||
doc.updatedAt = Date.now();
|
||||
doc.updatedBy = updatedBy;
|
||||
await doc.save();
|
||||
@@ -408,6 +424,34 @@ export class RemoteIngressManager {
|
||||
return result;
|
||||
}
|
||||
|
||||
private normalizeHubSettingsUpdate(
|
||||
updates: TRemoteIngressHubSettingsUpdate,
|
||||
): TRemoteIngressHubSettingsUpdate {
|
||||
const next: TRemoteIngressHubSettingsUpdate = {};
|
||||
|
||||
if (hasOwn(updates, 'enabled') && updates.enabled !== undefined) {
|
||||
next.enabled = Boolean(updates.enabled);
|
||||
}
|
||||
if (hasOwn(updates, 'tunnelPort') && updates.tunnelPort !== undefined) {
|
||||
const tunnelPort = Number(updates.tunnelPort);
|
||||
if (!Number.isInteger(tunnelPort) || tunnelPort < 1 || tunnelPort > 65535) {
|
||||
throw new Error('tunnelPort must be a valid TCP port');
|
||||
}
|
||||
next.tunnelPort = tunnelPort;
|
||||
}
|
||||
if (hasOwn(updates, 'hubDomain')) {
|
||||
const hubDomain = `${updates.hubDomain || ''}`.trim();
|
||||
next.hubDomain = hubDomain || undefined;
|
||||
}
|
||||
if (hasOwn(updates, 'performance')) {
|
||||
next.performance = updates.performance === null
|
||||
? undefined
|
||||
: this.normalizePerformanceConfig(updates.performance || undefined);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private normalizePerformanceConfig(
|
||||
performance?: IRemoteIngressPerformanceConfig,
|
||||
): IRemoteIngressPerformanceConfig | undefined {
|
||||
@@ -488,6 +532,9 @@ export class RemoteIngressManager {
|
||||
|
||||
private toHubSettings(doc: RemoteIngressHubSettingsDoc): IRemoteIngressHubSettings {
|
||||
return {
|
||||
enabled: doc.enabled ?? false,
|
||||
tunnelPort: doc.tunnelPort ?? defaultTunnelPort,
|
||||
hubDomain: doc.hubDomain || undefined,
|
||||
performance: doc.performance,
|
||||
updatedAt: doc.updatedAt,
|
||||
updatedBy: doc.updatedBy,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
|
||||
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
|
||||
|
||||
export interface ITunnelManagerConfig {
|
||||
@@ -9,7 +9,7 @@ export interface ITunnelManagerConfig {
|
||||
certPem?: string;
|
||||
keyPem?: string;
|
||||
};
|
||||
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,18 +46,20 @@ export class TunnelManager {
|
||||
this.edgeStatuses.delete(data.edgeId);
|
||||
});
|
||||
|
||||
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
|
||||
this.hub.on('streamSummary', (data: {
|
||||
edgeId: string;
|
||||
activeStreams: number;
|
||||
streamsOpenedTotal: number;
|
||||
streamsClosedTotal: number;
|
||||
}) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing) {
|
||||
existing.activeTunnels++;
|
||||
existing.activeTunnels = data.activeStreams;
|
||||
existing.lastHeartbeat = Date.now();
|
||||
}
|
||||
});
|
||||
|
||||
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
|
||||
const existing = this.edgeStatuses.get(data.edgeId);
|
||||
if (existing && existing.activeTunnels > 0) {
|
||||
existing.activeTunnels--;
|
||||
if (existing.traffic) {
|
||||
existing.traffic.streamsOpenedTotal = data.streamsOpenedTotal;
|
||||
existing.traffic.streamsClosedTotal = data.streamsClosedTotal;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -73,6 +75,7 @@ export class TunnelManager {
|
||||
targetHost: this.config.targetHost ?? '127.0.0.1',
|
||||
tls: this.config.tls,
|
||||
...(this.config.performance ? { performance: this.config.performance } : {}),
|
||||
streamEventMode: 'summary',
|
||||
} as any);
|
||||
|
||||
if (this.stopped) return;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
export interface IEmailPortConfig {
|
||||
/** External to internal SMTP port mapping. */
|
||||
portMapping?: Record<number, number>;
|
||||
/** Custom route settings for specific external ports. */
|
||||
portSettings?: Record<number, {
|
||||
terminateTls?: boolean;
|
||||
routeName?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
/** Path to store received emails, when configured by the runtime. */
|
||||
receivedEmailsPath?: string;
|
||||
}
|
||||
|
||||
export interface IEmailServerSettings {
|
||||
enabled: boolean;
|
||||
hostname: string | null;
|
||||
ports: number[];
|
||||
portMapping: Record<number, number> | null;
|
||||
receivedEmailsPath: string | null;
|
||||
maxMessageSize: number | null;
|
||||
domainCount: number;
|
||||
routeCount: number;
|
||||
authUserCount: number;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface IEmailServerSettingsSeed {
|
||||
enabled?: boolean;
|
||||
emailConfig?: IUnifiedEmailServerOptions;
|
||||
emailPortConfig?: IEmailPortConfig;
|
||||
}
|
||||
|
||||
export type TEmailServerSettingsUpdate = {
|
||||
enabled?: boolean;
|
||||
hostname?: string | null;
|
||||
ports?: number[];
|
||||
portMapping?: Record<number, number> | null;
|
||||
receivedEmailsPath?: string | null;
|
||||
maxMessageSize?: number | null;
|
||||
};
|
||||
@@ -10,4 +10,5 @@ export * from './workhoster.js';
|
||||
export * from './dns-record.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domain.js';
|
||||
export * from './email-settings.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -64,11 +64,19 @@ export interface IRemoteIngressPerformanceConfig {
|
||||
}
|
||||
|
||||
export interface IRemoteIngressHubSettings {
|
||||
enabled: boolean;
|
||||
tunnelPort: number;
|
||||
hubDomain?: string;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
updatedAt: number;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export type TRemoteIngressHubSettingsUpdate = Partial<Pick<IRemoteIngressHubSettings, 'enabled' | 'tunnelPort'>> & {
|
||||
hubDomain?: string | null;
|
||||
performance?: IRemoteIngressPerformanceConfig | null;
|
||||
};
|
||||
|
||||
export interface IRemoteIngressPerformanceEffective {
|
||||
profile: TRemoteIngressPerformanceProfile;
|
||||
maxStreamsPerEdge: number;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type * as authInterfaces from '../data/auth.js';
|
||||
import type { IEmailServerSettings, TEmailServerSettingsUpdate } from '../data/email-settings.js';
|
||||
|
||||
export interface IReq_GetEmailServerSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_GetEmailServerSettings
|
||||
> {
|
||||
method: 'getEmailServerSettings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
};
|
||||
response: {
|
||||
settings: IEmailServerSettings;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpdateEmailServerSettings extends plugins.typedrequestInterfaces.implementsTR<
|
||||
plugins.typedrequestInterfaces.ITypedRequest,
|
||||
IReq_UpdateEmailServerSettings
|
||||
> {
|
||||
method: 'updateEmailServerSettings';
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
settings: TEmailServerSettingsUpdate;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
settings?: IEmailServerSettings;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
@@ -19,5 +19,6 @@ export * from './domains.js';
|
||||
export * from './dns-records.js';
|
||||
export * from './acme-config.js';
|
||||
export * from './email-domains.js';
|
||||
export * from './email-settings.js';
|
||||
export * from './workhoster.js';
|
||||
export * from './security-policy.js';
|
||||
|
||||
@@ -176,7 +176,10 @@ export interface IReq_UpdateRemoteIngressHubSettings extends plugins.typedreques
|
||||
request: {
|
||||
identity?: authInterfaces.IIdentity;
|
||||
apiToken?: string;
|
||||
performance?: IRemoteIngressPerformanceConfig;
|
||||
enabled?: boolean;
|
||||
tunnelPort?: number;
|
||||
hubDomain?: string | null;
|
||||
performance?: IRemoteIngressPerformanceConfig | null;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,74 @@ export interface IMigrationRunner {
|
||||
|
||||
type TMigrationSecurity = Record<string, any>;
|
||||
|
||||
export interface IDcRouterMigrationOptions {
|
||||
remoteIngressHubSettings?: {
|
||||
enabled?: boolean;
|
||||
tunnelPort?: number;
|
||||
hubDomain?: string | null;
|
||||
performance?: Record<string, any> | null;
|
||||
};
|
||||
emailServerSettings?: {
|
||||
enabled?: boolean;
|
||||
emailConfig?: Record<string, any>;
|
||||
emailPortConfig?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SOURCE_PROFILES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const remoteIngressHubSettingsMigrationBaseVersion = '13.43.5';
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const aParts = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const bParts = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
|
||||
const maxLength = Math.max(aParts.length, bParts.length);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mergeMigrationSecurityFields(
|
||||
base: TMigrationSecurity | undefined,
|
||||
override: TMigrationSecurity | undefined,
|
||||
@@ -195,6 +263,249 @@ 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)`,
|
||||
);
|
||||
}
|
||||
|
||||
async function backfillRemoteIngressHubSettings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}, options: IDcRouterMigrationOptions): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('RemoteIngressHubSettingsDoc');
|
||||
const seed = options.remoteIngressHubSettings || {};
|
||||
const now = Date.now();
|
||||
const doc = await collection.findOne({ settingsId: 'remote-ingress-hub-settings' });
|
||||
|
||||
if (!doc) {
|
||||
await collection.insertOne({
|
||||
settingsId: 'remote-ingress-hub-settings',
|
||||
enabled: seed.enabled ?? false,
|
||||
tunnelPort: seed.tunnelPort ?? 8443,
|
||||
hubDomain: seed.hubDomain || '',
|
||||
performance: seed.performance || undefined,
|
||||
updatedAt: now,
|
||||
updatedBy: 'migration',
|
||||
});
|
||||
ctx.log.log('info', 'backfill-remote-ingress-hub-settings: inserted singleton settings document');
|
||||
return;
|
||||
}
|
||||
|
||||
const $set: Record<string, any> = {};
|
||||
if ((doc as any).enabled === undefined) {
|
||||
$set.enabled = seed.enabled ?? false;
|
||||
}
|
||||
if ((doc as any).tunnelPort === undefined) {
|
||||
$set.tunnelPort = seed.tunnelPort ?? 8443;
|
||||
}
|
||||
if ((doc as any).hubDomain === undefined && seed.hubDomain) {
|
||||
$set.hubDomain = seed.hubDomain;
|
||||
}
|
||||
if ((doc as any).performance === undefined && seed.performance) {
|
||||
$set.performance = seed.performance;
|
||||
}
|
||||
|
||||
if (Object.keys($set).length === 0) {
|
||||
ctx.log.log('info', 'backfill-remote-ingress-hub-settings: no changes needed');
|
||||
return;
|
||||
}
|
||||
|
||||
$set.updatedAt = now;
|
||||
$set.updatedBy = (doc as any).updatedBy || 'migration';
|
||||
|
||||
await collection.updateOne(
|
||||
(doc as any)._id ? { _id: (doc as any)._id } : { settingsId: 'remote-ingress-hub-settings' },
|
||||
{ $set },
|
||||
);
|
||||
ctx.log.log('info', `backfill-remote-ingress-hub-settings: set ${Object.keys($set).length - 2} missing field(s)`);
|
||||
}
|
||||
|
||||
async function backfillEmailServerSettings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}, options: IDcRouterMigrationOptions): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('EmailServerSettingsDoc');
|
||||
const seed = options.emailServerSettings || {};
|
||||
const now = Date.now();
|
||||
const doc = await collection.findOne({ settingsId: 'email-server-settings' });
|
||||
|
||||
if (!doc) {
|
||||
await collection.insertOne({
|
||||
settingsId: 'email-server-settings',
|
||||
enabled: seed.enabled ?? Boolean(seed.emailConfig),
|
||||
emailConfig: seed.emailConfig || undefined,
|
||||
emailPortConfig: seed.emailPortConfig || undefined,
|
||||
updatedAt: now,
|
||||
updatedBy: 'migration',
|
||||
});
|
||||
ctx.log.log('info', 'backfill-email-server-settings: inserted singleton settings document');
|
||||
return;
|
||||
}
|
||||
|
||||
const $set: Record<string, any> = {};
|
||||
if ((doc as any).enabled === undefined) {
|
||||
$set.enabled = seed.enabled ?? Boolean(seed.emailConfig);
|
||||
}
|
||||
if ((doc as any).emailConfig === undefined && seed.emailConfig) {
|
||||
$set.emailConfig = seed.emailConfig;
|
||||
}
|
||||
if ((doc as any).emailPortConfig === undefined && seed.emailPortConfig) {
|
||||
$set.emailPortConfig = seed.emailPortConfig;
|
||||
}
|
||||
|
||||
if (Object.keys($set).length === 0) {
|
||||
ctx.log.log('info', 'backfill-email-server-settings: no changes needed');
|
||||
return;
|
||||
}
|
||||
|
||||
$set.updatedAt = now;
|
||||
$set.updatedBy = (doc as any).updatedBy || 'migration';
|
||||
|
||||
await collection.updateOne(
|
||||
(doc as any)._id ? { _id: (doc as any)._id } : { settingsId: 'email-server-settings' },
|
||||
{ $set },
|
||||
);
|
||||
ctx.log.log('info', `backfill-email-server-settings: set ${Object.keys($set).length - 2} missing field(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -207,6 +518,7 @@ async function backfillSystemRouteKeys(ctx: {
|
||||
export async function createMigrationRunner(
|
||||
db: unknown,
|
||||
targetVersion: string,
|
||||
options: IDcRouterMigrationOptions = {},
|
||||
): Promise<IMigrationRunner> {
|
||||
const sm = await import('@push.rocks/smartmigration');
|
||||
const migration = new sm.SmartMigration({
|
||||
@@ -298,7 +610,37 @@ export async function createMigrationRunner(
|
||||
.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);
|
||||
})
|
||||
.step('backfill-remote-ingress-hub-settings-current')
|
||||
.from('13.43.2').to(remoteIngressHubSettingsMigrationBaseVersion)
|
||||
.description('Backfill RemoteIngress hub singleton settings for current dcrouter 13.43.5 installs')
|
||||
.up(async (ctx) => {
|
||||
await backfillRemoteIngressHubSettings(ctx, options);
|
||||
await backfillEmailServerSettings(ctx, options);
|
||||
});
|
||||
|
||||
if (compareSemver(targetVersion, remoteIngressHubSettingsMigrationBaseVersion) > 0) {
|
||||
migration
|
||||
.step('backfill-remote-ingress-hub-settings')
|
||||
.from(remoteIngressHubSettingsMigrationBaseVersion).to(targetVersion)
|
||||
.description('Backfill DB-backed singleton runtime settings from legacy bootstrap config')
|
||||
.up(async (ctx) => {
|
||||
await backfillRemoteIngressHubSettings(ctx, options);
|
||||
await backfillEmailServerSettings(ctx, options);
|
||||
});
|
||||
}
|
||||
|
||||
return migration;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.41.1',
|
||||
version: '13.44.0',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
+65
-1
@@ -290,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;
|
||||
@@ -302,6 +303,7 @@ export const routeManagementStatePart = await appState.getStatePart<IRouteManage
|
||||
{
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
@@ -1228,7 +1230,10 @@ export const updateRemoteIngressAction = remoteIngressStatePart.createAction<{
|
||||
});
|
||||
|
||||
export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.createAction<{
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig;
|
||||
enabled?: boolean;
|
||||
tunnelPort?: number;
|
||||
hubDomain?: string | null;
|
||||
performance?: interfaces.data.IRemoteIngressPerformanceConfig | null;
|
||||
}>(async (statePartArg, dataArg, actionContext): Promise<IRemoteIngressState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
@@ -1240,6 +1245,9 @@ export const updateRemoteIngressHubSettingsAction = remoteIngressStatePart.creat
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity!,
|
||||
enabled: dataArg.enabled,
|
||||
tunnelPort: dataArg.tunnelPort,
|
||||
hubDomain: dataArg.hubDomain,
|
||||
performance: dataArg.performance,
|
||||
});
|
||||
|
||||
@@ -2474,6 +2482,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;
|
||||
@@ -2924,6 +2962,7 @@ export const toggleApiTokenAction = routeManagementStatePart.createAction<{
|
||||
|
||||
export interface IEmailDomainsState {
|
||||
domains: interfaces.data.IEmailDomain[];
|
||||
settings: interfaces.data.IEmailServerSettings | null;
|
||||
isLoading: boolean;
|
||||
lastUpdated: number;
|
||||
}
|
||||
@@ -2932,6 +2971,7 @@ export const emailDomainsStatePart = await appState.getStatePart<IEmailDomainsSt
|
||||
'emailDomains',
|
||||
{
|
||||
domains: [],
|
||||
settings: null,
|
||||
isLoading: false,
|
||||
lastUpdated: 0,
|
||||
},
|
||||
@@ -2948,10 +2988,15 @@ export const fetchEmailDomainsAction = emailDomainsStatePart.createAction(
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailDomains
|
||||
>('/typedrequest', 'getEmailDomains');
|
||||
const settingsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetEmailServerSettings
|
||||
>('/typedrequest', 'getEmailServerSettings');
|
||||
const response = await request.fire({ identity: context.identity });
|
||||
const settingsResponse = await settingsRequest.fire({ identity: context.identity });
|
||||
return {
|
||||
...currentState,
|
||||
domains: response.domains,
|
||||
settings: settingsResponse.settings,
|
||||
isLoading: false,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
@@ -2982,6 +3027,25 @@ export const createEmailDomainAction = emailDomainsStatePart.createAction<{
|
||||
}
|
||||
});
|
||||
|
||||
export const updateEmailServerSettingsAction = emailDomainsStatePart.createAction<
|
||||
interfaces.data.TEmailServerSettingsUpdate
|
||||
>(async (statePartArg, settings, actionContext) => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_UpdateEmailServerSettings
|
||||
>('/typedrequest', 'updateEmailServerSettings');
|
||||
const response = await request.fire({ identity: context.identity!, settings });
|
||||
if (!response.success) {
|
||||
return currentState;
|
||||
}
|
||||
return await actionContext!.dispatch(fetchEmailDomainsAction, null);
|
||||
} catch {
|
||||
return currentState;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteEmailDomainAction = emailDomainsStatePart.createAction<string>(
|
||||
async (statePartArg, id, actionContext) => {
|
||||
const context = getActionContext();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -101,6 +101,7 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
const domains = this.emailDomainsState.domains;
|
||||
const settings = this.emailDomainsState.settings;
|
||||
const validCount = domains.filter(
|
||||
(d) =>
|
||||
d.dnsStatus.mx === 'valid' &&
|
||||
@@ -127,6 +128,22 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
icon: 'lucide:Check',
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server',
|
||||
value: settings?.enabled ? 'enabled' : 'disabled',
|
||||
type: 'text',
|
||||
icon: 'lucide:mail-check',
|
||||
color: settings?.enabled ? '#22c55e' : '#6b7280',
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
title: 'SMTP Ports',
|
||||
value: settings?.ports?.join(', ') || 'none',
|
||||
type: 'text',
|
||||
icon: 'lucide:plug',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
{
|
||||
id: 'issues',
|
||||
title: 'Issues',
|
||||
@@ -163,6 +180,13 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
action: async () => {
|
||||
await this.showSettingsDialog();
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-statsgrid>
|
||||
|
||||
@@ -258,6 +282,108 @@ export class OpsViewEmailDomains extends DeesElement {
|
||||
return html`<span class="sourceBadge">${label}</span>`;
|
||||
}
|
||||
|
||||
private parsePortList(value: string): number[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((part) => Number.parseInt(part.trim(), 10))
|
||||
.filter((port) => Number.isInteger(port));
|
||||
}
|
||||
|
||||
private parsePortMapping(value: string): Record<number, number> | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const mapping: Record<number, number> = {};
|
||||
for (const pair of trimmed.split(',')) {
|
||||
const [externalPort, internalPort] = pair
|
||||
.split(':')
|
||||
.map((part) => Number.parseInt(part.trim(), 10));
|
||||
if (Number.isInteger(externalPort) && Number.isInteger(internalPort)) {
|
||||
mapping[externalPort] = internalPort;
|
||||
}
|
||||
}
|
||||
return Object.keys(mapping).length > 0 ? mapping : null;
|
||||
}
|
||||
|
||||
private formatPortMapping(mapping: Record<number, number> | null | undefined): string {
|
||||
if (!mapping) return '';
|
||||
return Object.entries(mapping)
|
||||
.map(([externalPort, internalPort]) => `${externalPort}:${internalPort}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
private async showSettingsDialog() {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const settings = this.emailDomainsState.settings;
|
||||
|
||||
DeesModal.createAndShow({
|
||||
heading: 'Email Server Settings',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enable email server'}
|
||||
.value=${settings?.enabled ?? false}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'hostname'}
|
||||
.label=${'SMTP hostname'}
|
||||
.description=${'Public hostname used in SMTP banners and DNS records'}
|
||||
.value=${settings?.hostname || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'ports'}
|
||||
.label=${'Public ports'}
|
||||
.description=${'Comma-separated SMTP ingress ports, e.g. 25, 587, 465'}
|
||||
.value=${settings?.ports?.join(', ') || '25, 587, 465'}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'portMapping'}
|
||||
.label=${'Port mapping'}
|
||||
.description=${'Optional external:internal pairs, e.g. 25:10025, 587:10587'}
|
||||
.value=${this.formatPortMapping(settings?.portMapping)}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'maxMessageSize'}
|
||||
.label=${'Max message size'}
|
||||
.description=${'Bytes; leave empty for smartmta default'}
|
||||
.value=${settings?.maxMessageSize ? String(settings.maxMessageSize) : ''}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'receivedEmailsPath'}
|
||||
.label=${'Received emails path'}
|
||||
.description=${'Optional storage path for received email artifacts'}
|
||||
.value=${settings?.receivedEmailsPath || ''}
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (m: any) => m.destroy() },
|
||||
{
|
||||
name: 'Save',
|
||||
action: async (m: any) => {
|
||||
const form = m.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const data = await form.collectFormData();
|
||||
const maxMessageSizeRaw = String(data.maxMessageSize || '').trim();
|
||||
await appstate.emailDomainsStatePart.dispatchAction(
|
||||
appstate.updateEmailServerSettingsAction,
|
||||
{
|
||||
enabled: Boolean(data.enabled),
|
||||
hostname: String(data.hostname || '').trim() || null,
|
||||
ports: this.parsePortList(String(data.ports || '')),
|
||||
portMapping: this.parsePortMapping(String(data.portMapping || '')),
|
||||
maxMessageSize: maxMessageSizeRaw ? Number.parseInt(maxMessageSizeRaw, 10) : null,
|
||||
receivedEmailsPath: String(data.receivedEmailsPath || '').trim() || null,
|
||||
},
|
||||
);
|
||||
DeesToast.show({ message: 'Email settings saved', type: 'success', duration: 2500 });
|
||||
m.destroy();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showCreateDialog() {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
const domainOptions = this.domainsState.domains.map((d) => ({
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -620,16 +620,34 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
|
||||
private async showHubSettingsDialog(): Promise<void> {
|
||||
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
|
||||
const performance = this.riState.hubSettings?.performance || {};
|
||||
const hubSettings = this.riState.hubSettings;
|
||||
const performance = hubSettings?.performance || {};
|
||||
const selectedProfile = performanceProfileOptions.find((option) => option.key === (performance.profile || '')) || performanceProfileOptions[0];
|
||||
const updatedAt = this.riState.hubSettings?.updatedAt
|
||||
? new Date(this.riState.hubSettings.updatedAt).toLocaleString()
|
||||
const updatedAt = hubSettings?.updatedAt
|
||||
? new Date(hubSettings.updatedAt).toLocaleString()
|
||||
: 'not persisted yet';
|
||||
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'RemoteIngress Hub Settings',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-checkbox
|
||||
.key=${'enabled'}
|
||||
.label=${'Enable RemoteIngress Hub'}
|
||||
.value=${hubSettings?.enabled ?? false}
|
||||
></dees-input-checkbox>
|
||||
<dees-input-text
|
||||
.key=${'tunnelPort'}
|
||||
.label=${'Tunnel Port'}
|
||||
.description=${'TCP/UDP port edges connect to on the hub.'}
|
||||
.value=${(hubSettings?.tunnelPort || 8443).toString()}
|
||||
></dees-input-text>
|
||||
<dees-input-text
|
||||
.key=${'hubDomain'}
|
||||
.label=${'Hub Domain / Address'}
|
||||
.description=${'Public host or IP embedded in edge connection tokens.'}
|
||||
.value=${hubSettings?.hubDomain || ''}
|
||||
></dees-input-text>
|
||||
<dees-input-dropdown
|
||||
.key=${'profile'}
|
||||
.label=${'Performance Profile'}
|
||||
@@ -662,8 +680,8 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
></dees-input-text>
|
||||
</dees-form>
|
||||
<p class="settingsNote">
|
||||
Saving restarts the RemoteIngress hub so connected edges reconnect and pick up the new defaults.
|
||||
Last updated: ${updatedAt} by ${this.riState.hubSettings?.updatedBy || 'default'}.
|
||||
Saving applies DB-backed hub settings. Enabling or disabling the hub restarts SmartProxy so tunneled traffic and route metadata stay consistent.
|
||||
Last updated: ${updatedAt} by ${hubSettings?.updatedBy || 'default'}.
|
||||
</p>
|
||||
`,
|
||||
menuOptions: [
|
||||
@@ -679,9 +697,11 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await form.collectFormData();
|
||||
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | undefined;
|
||||
let performanceSettings: interfaces.data.IRemoteIngressPerformanceConfig | null;
|
||||
let tunnelPort: number;
|
||||
try {
|
||||
performanceSettings = this.collectHubPerformanceSettings(formData);
|
||||
tunnelPort = this.parseRequiredPort(formData.tunnelPort, 'Tunnel Port');
|
||||
performanceSettings = this.collectHubPerformanceSettings(formData, performance);
|
||||
} catch (err: unknown) {
|
||||
DeesToast.show({ message: (err as Error).message, type: 'error', duration: 4000 });
|
||||
return;
|
||||
@@ -689,7 +709,12 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
|
||||
const nextState = await appstate.remoteIngressStatePart.dispatchAction(
|
||||
appstate.updateRemoteIngressHubSettingsAction,
|
||||
{ performance: performanceSettings },
|
||||
{
|
||||
enabled: formData.enabled !== false,
|
||||
tunnelPort,
|
||||
hubDomain: `${formData.hubDomain || ''}`.trim() || null,
|
||||
performance: performanceSettings,
|
||||
},
|
||||
);
|
||||
if (nextState.error) {
|
||||
DeesToast.show({ message: nextState.error, type: 'error', duration: 4000 });
|
||||
@@ -703,29 +728,37 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
});
|
||||
}
|
||||
|
||||
private collectHubPerformanceSettings(formData: Record<string, any>): interfaces.data.IRemoteIngressPerformanceConfig | undefined {
|
||||
const next: interfaces.data.IRemoteIngressPerformanceConfig = {};
|
||||
private collectHubPerformanceSettings(
|
||||
formData: Record<string, any>,
|
||||
currentPerformance: interfaces.data.IRemoteIngressPerformanceConfig,
|
||||
): interfaces.data.IRemoteIngressPerformanceConfig | null {
|
||||
const next: interfaces.data.IRemoteIngressPerformanceConfig = { ...currentPerformance };
|
||||
const profile = getDropdownKey(formData.profile) as interfaces.data.TRemoteIngressPerformanceProfile | '';
|
||||
if (profile) {
|
||||
next.profile = profile;
|
||||
} else {
|
||||
delete next.profile;
|
||||
}
|
||||
|
||||
this.assignPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
|
||||
this.assignPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
|
||||
this.assignPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
|
||||
this.assignOptionalPositiveIntegerSetting(next, 'maxStreamsPerEdge', formData.maxStreamsPerEdge, 'Max Connections / Edge');
|
||||
this.assignOptionalPositiveIntegerSetting(next, 'clientWriteTimeoutMs', formData.clientWriteTimeoutMs, 'Client Write Timeout');
|
||||
this.assignOptionalPositiveIntegerSetting(next, 'firstDataConnectTimeoutMs', formData.firstDataConnectTimeoutMs, 'First Data Timeout');
|
||||
|
||||
const serverFirstPorts = this.parsePortList(formData.serverFirstPorts, 'Server-first Ports');
|
||||
if (serverFirstPorts.length > 0) {
|
||||
const serverFirstPortsText = `${formData.serverFirstPorts || ''}`.trim();
|
||||
if (serverFirstPortsText) {
|
||||
const serverFirstPorts = this.parsePortList(serverFirstPortsText, 'Server-first Ports');
|
||||
if (serverFirstPorts.includes(443)) {
|
||||
throw new Error('Port 443 is client-first TLS and must not be listed as server-first');
|
||||
}
|
||||
next.serverFirstPorts = serverFirstPorts;
|
||||
} else {
|
||||
delete next.serverFirstPorts;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
return Object.keys(next).length > 0 ? next : null;
|
||||
}
|
||||
|
||||
private assignPositiveIntegerSetting(
|
||||
private assignOptionalPositiveIntegerSetting(
|
||||
target: interfaces.data.IRemoteIngressPerformanceConfig,
|
||||
key: 'maxStreamsPerEdge' | 'clientWriteTimeoutMs' | 'firstDataConnectTimeoutMs',
|
||||
value: any,
|
||||
@@ -733,6 +766,7 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
): void {
|
||||
const text = `${value || ''}`.trim();
|
||||
if (!text) {
|
||||
delete target[key];
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(text, 10);
|
||||
@@ -755,4 +789,12 @@ export class OpsViewRemoteIngress extends DeesElement {
|
||||
}
|
||||
return [...new Set(ports)].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private parseRequiredPort(value: any, label: string): number {
|
||||
const port = Number.parseInt(`${value || ''}`.trim(), 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`${label} must be a valid port number`);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user