Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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/
|
||||
|
||||
@@ -3,20 +3,83 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-06-03 - 13.43.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- enforce canonical source bindings for route access (route-management)
|
||||
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
|
||||
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
|
||||
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
|
||||
|
||||
## 2026-06-03 - 13.43.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- ignore generated artifacts and caches in Docker build context (dockerignore)
|
||||
- Exclude cache directories, coverage reports, distribution outputs, and generated static assets from Docker contexts.
|
||||
|
||||
## 2026-06-03 - 13.43.0
|
||||
|
||||
### Features
|
||||
|
||||
- add derived HTTP-to-HTTPS redirects (http-redirects)
|
||||
- Generate 301 runtime redirect routes from eligible HTTPS routes while detecting existing HTTP route coverage or conflicts
|
||||
- Expose derived redirect metadata through the getHttpRedirects typed request API
|
||||
- Add an Ops Redirects network view with redirect status metrics and table details
|
||||
- Add tests for redirect derivation, conflict handling, and preserving request host/path
|
||||
|
||||
## 2026-06-02 - 13.42.4
|
||||
|
||||
### Fixes
|
||||
|
||||
- normalize source policy route priorities to stable integers (source-policy-compiler)
|
||||
- Assign integer priorities to compiled source policy route variants while preserving relative priority order.
|
||||
- Keep path-specific source policy variants ranked above fallback variants.
|
||||
- update Deno import dependencies (deps)
|
||||
- Bumped Deno import map versions for API, identity, push.rocks, serve.zone, and lru-cache dependencies.
|
||||
|
||||
## 2026-06-02 - 13.42.3
|
||||
|
||||
### Fixes
|
||||
|
||||
- update dependency versions (deps)
|
||||
- Bumped runtime dependencies including @serve.zone/interfaces to ^6.2.1, @serve.zone/catalog to ^2.12.7, and lru-cache to ^11.5.1.
|
||||
- Updated @git.zone/tsdocker dev dependency to ^2.4.2.
|
||||
|
||||
## 2026-06-02 - 13.42.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @git.zone/tsdocker to ^2.4.1 (dev-deps)
|
||||
- Updated @git.zone/tsdocker from ^2.4.0 to ^2.4.1.
|
||||
|
||||
## 2026-06-02 - 13.42.1
|
||||
|
||||
### Fixes
|
||||
|
||||
- bump @serve.zone/remoteingress to ^4.22.5 (deps)
|
||||
- Updates @serve.zone/remoteingress from ^4.22.4 to ^4.22.5.
|
||||
|
||||
## 2026-06-02 - 13.42.0
|
||||
|
||||
### Features
|
||||
|
||||
- add ordered route source policies with Gitea preset support (source-policy)
|
||||
- Compile metadata.sourcePolicy bindings into SmartProxy route variants with ordered source matching, path-class overrides, and terminal 429 rate/connection limit handling
|
||||
- Add shared source-policy interfaces, Gitea path-class patterns, validation limits, and resolver support for policy-backed profile usage and display names
|
||||
- Add Ops UI controls for manual and Gitea source-policy presets plus rate-limit editing for source profiles
|
||||
- Seed TRUSTED NETWORKS, AI CRAWLERS, and PUBLIC default profiles through defaults and the 13.42.0 migration
|
||||
- Bump smartproxy to ^27.12.4 and add coverage for source-policy compilation, rate-limit behavior, migrations, and port-safe server tests
|
||||
|
||||
## 2026-06-01 - 13.41.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- update SmartProxy and RemoteIngress dependencies (deps)
|
||||
- Bump SmartProxy to 27.12.3 for the published half-close regression coverage.
|
||||
- Bump RemoteIngress to 4.22.4 for the half-close/reset and UDP startup lifecycle fixes.
|
||||
- Align npm and Deno import metadata for both runtime dependencies.
|
||||
|
||||
## 2026-05-31 - 13.41.1
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.41.1",
|
||||
"version": "13.43.2",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
@@ -8,18 +8,18 @@
|
||||
]
|
||||
},
|
||||
"imports": {
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
|
||||
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.2",
|
||||
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
|
||||
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.7",
|
||||
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.4",
|
||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
|
||||
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.4.0/server",
|
||||
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
|
||||
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
|
||||
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
|
||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
|
||||
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
|
||||
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.2",
|
||||
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
|
||||
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
|
||||
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
|
||||
@@ -31,18 +31,18 @@
|
||||
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
|
||||
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
|
||||
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.2",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
|
||||
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.4",
|
||||
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.3.0",
|
||||
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
|
||||
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
|
||||
"@push.rocks/smartstate": "npm:@push.rocks/smartstate@^2.3.1",
|
||||
"@push.rocks/smartunique": "npm:@push.rocks/smartunique@^3.0.9",
|
||||
"@push.rocks/smartvpn": "npm:@push.rocks/smartvpn@1.20.0",
|
||||
"@push.rocks/taskbuffer": "npm:@push.rocks/taskbuffer@^8.0.2",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^5.8.0",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.3",
|
||||
"@serve.zone/interfaces": "npm:@serve.zone/interfaces@^6.2.1",
|
||||
"@serve.zone/remoteingress": "npm:@serve.zone/remoteingress@^4.22.5",
|
||||
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
|
||||
"lru-cache": "npm:lru-cache@^11.4.0",
|
||||
"lru-cache": "npm:lru-cache@^11.5.1",
|
||||
"qrcode": "npm:qrcode@^1.5.4",
|
||||
"uuid": "npm:uuid@^14.0.0"
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.41.1",
|
||||
"version": "13.43.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -28,28 +28,28 @@
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.2",
|
||||
"@git.zone/tsbundle": "^2.10.4",
|
||||
"@git.zone/tsdocker": "^2.4.0",
|
||||
"@git.zone/tsdeno": "^1.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.4",
|
||||
"@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.7",
|
||||
"@serve.zone/interfaces": "^6.2.1",
|
||||
"@serve.zone/remoteingress": "^4.22.5",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"lru-cache": "^11.4.0",
|
||||
"lru-cache": "^11.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
|
||||
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
|
||||
@@ -115,6 +127,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
@@ -129,7 +142,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
opsServerPort: 3104,
|
||||
opsServerPort,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,109 @@ 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');
|
||||
});
|
||||
|
||||
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,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,
|
||||
},
|
||||
};
|
||||
@@ -244,6 +264,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(createdRoute.createdBy).toEqual('token-user');
|
||||
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
||||
expect(createdRoute.metadata).toEqual({
|
||||
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
@@ -253,6 +274,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
workAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
});
|
||||
createdRoute.route.security = { ipAllowList: ['*'] };
|
||||
|
||||
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
@@ -275,6 +297,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
|
||||
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
|
||||
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
|
||||
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.41.1',
|
||||
version: '13.43.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js';
|
||||
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
|
||||
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
|
||||
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
|
||||
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
|
||||
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
|
||||
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
|
||||
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
|
||||
@@ -597,7 +597,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();
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -119,6 +126,10 @@ export class RouteConfigManager {
|
||||
return { routes: merged, warnings: [...this.warnings] };
|
||||
}
|
||||
|
||||
public getHttpRedirects(): IHttpRedirectInfo[] {
|
||||
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Route CRUD
|
||||
// =========================================================================
|
||||
@@ -131,6 +142,10 @@ export class RouteConfigManager {
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
|
||||
if (sourceBindingsPayloadError) {
|
||||
throw new Error(sourceBindingsPayloadError);
|
||||
}
|
||||
|
||||
// Ensure route has a name
|
||||
if (!route.name) {
|
||||
@@ -144,6 +159,10 @@ export class RouteConfigManager {
|
||||
route = resolved.route;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route);
|
||||
if (sourceBindingsValidationError) {
|
||||
throw new Error(sourceBindingsValidationError);
|
||||
}
|
||||
|
||||
const stored: IRoute = {
|
||||
id,
|
||||
@@ -174,8 +193,14 @@ export class RouteConfigManager {
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings);
|
||||
if (sourceBindingsPayloadError) {
|
||||
return { success: false, message: sourceBindingsPayloadError };
|
||||
}
|
||||
|
||||
const 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 +243,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 +252,14 @@ export class RouteConfigManager {
|
||||
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route);
|
||||
if (sourceBindingsValidationError) {
|
||||
stored.route = previousRoute;
|
||||
stored.metadata = previousMetadata;
|
||||
stored.enabled = previousEnabled;
|
||||
return { success: false, message: sourceBindingsValidationError };
|
||||
}
|
||||
|
||||
stored.updatedAt = Date.now();
|
||||
|
||||
await this.persistRoute(stored);
|
||||
@@ -453,9 +479,8 @@ export class RouteConfigManager {
|
||||
};
|
||||
|
||||
const normalized: IRouteMetadata = {
|
||||
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
||||
sourceBindings: this.normalizeSourceBindings(metadata.sourceBindings),
|
||||
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
||||
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
||||
networkTargetName: normalizeString(metadata.networkTargetName),
|
||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||
? metadata.lastResolvedAt
|
||||
@@ -476,13 +501,10 @@ export class RouteConfigManager {
|
||||
externalKey: normalizeString(metadata.externalKey),
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
normalized.sourceProfileName = undefined;
|
||||
}
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
|
||||
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
@@ -507,6 +529,127 @@ export class RouteConfigManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): IRouteSourceBinding[] | undefined {
|
||||
if (!Array.isArray(sourceBindings)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedBindings: IRouteSourceBinding[] = [];
|
||||
for (const binding of sourceBindings) {
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
if (!sourceProfileRef) {
|
||||
continue;
|
||||
}
|
||||
const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit);
|
||||
const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies);
|
||||
|
||||
normalizedBindings.push({
|
||||
...(typeof binding.id === 'string' && binding.id.trim() ? { id: binding.id.trim() } : {}),
|
||||
sourceProfileRef,
|
||||
...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim()
|
||||
? { sourceProfileName: binding.sourceProfileName.trim() }
|
||||
: {}),
|
||||
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
|
||||
...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0
|
||||
? { maxConnections: binding.maxConnections }
|
||||
: {}),
|
||||
...(binding.onExceeded?.type === '429'
|
||||
? {
|
||||
onExceeded: {
|
||||
type: '429' as const,
|
||||
...(typeof binding.onExceeded.errorMessage === 'string' && binding.onExceeded.errorMessage.trim()
|
||||
? { errorMessage: binding.onExceeded.errorMessage.trim() }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(normalizedPathPolicies ? { pathPolicies: normalizedPathPolicies } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedBindings.length > 0 ? normalizedBindings : undefined;
|
||||
}
|
||||
|
||||
private normalizePathPolicies(
|
||||
pathPolicies?: IRoutePathPolicyBinding[],
|
||||
): IRoutePathPolicyBinding[] | undefined {
|
||||
if (!Array.isArray(pathPolicies)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const validClasses = new Set<string>(routePathClasses);
|
||||
const normalizedPathPolicies: IRoutePathPolicyBinding[] = [];
|
||||
for (const pathPolicy of pathPolicies) {
|
||||
if (!validClasses.has(pathPolicy.pathClass)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
|
||||
const pathPatterns = Array.isArray(pathPolicy.pathPatterns)
|
||||
? [...new Set(pathPolicy.pathPatterns
|
||||
.map((pattern) => typeof pattern === 'string' ? pattern.trim() : '')
|
||||
.filter(Boolean))]
|
||||
: undefined;
|
||||
|
||||
normalizedPathPolicies.push({
|
||||
...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}),
|
||||
pathClass: pathPolicy.pathClass,
|
||||
...(pathPatterns?.length ? { pathPatterns } : {}),
|
||||
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
|
||||
...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0
|
||||
? { maxConnections: pathPolicy.maxConnections }
|
||||
: {}),
|
||||
...(pathPolicy.onExceeded?.type === '429'
|
||||
? {
|
||||
onExceeded: {
|
||||
type: '429' as const,
|
||||
...(typeof pathPolicy.onExceeded.errorMessage === 'string' && pathPolicy.onExceeded.errorMessage.trim()
|
||||
? { errorMessage: pathPolicy.onExceeded.errorMessage.trim() }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
|
||||
}
|
||||
|
||||
private validateSourceBindings(
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route);
|
||||
if (shapeError) {
|
||||
return shapeError;
|
||||
}
|
||||
return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver);
|
||||
}
|
||||
|
||||
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
|
||||
if (!rateLimit || typeof rateLimit !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const maxRequests = Number(rateLimit.maxRequests);
|
||||
const window = Number(rateLimit.window);
|
||||
if (!Number.isFinite(maxRequests) || maxRequests < 0 || !Number.isFinite(window) || window < 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: rateLimit.enabled !== false,
|
||||
maxRequests,
|
||||
window,
|
||||
keyBy: 'ip',
|
||||
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
|
||||
? { errorMessage: rateLimit.errorMessage.trim() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: warnings
|
||||
// =========================================================================
|
||||
@@ -567,16 +710,9 @@ export class RouteConfigManager {
|
||||
const smartProxy = this.getSmartProxy();
|
||||
if (!smartProxy) return;
|
||||
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const enabledRoutes = this.getPreparedEnabledRoutesForApply();
|
||||
|
||||
// Add all enabled routes with HTTP/3 and VPN augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(this.prepareStoredRouteForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
|
||||
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
|
||||
for (const route of runtimeRoutes) {
|
||||
enabledRoutes.push(this.prepareRouteForApply(route));
|
||||
}
|
||||
@@ -592,9 +728,43 @@ export class RouteConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
|
||||
private getPreparedEnabledRoutesForApply(): plugins.smartproxy.IRouteConfig[] {
|
||||
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
|
||||
// Add all enabled routes with HTTP/3, VPN, and source-policy augmentation
|
||||
for (const route of this.routes.values()) {
|
||||
if (route.enabled) {
|
||||
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
|
||||
}
|
||||
}
|
||||
|
||||
return enabledRoutes;
|
||||
}
|
||||
|
||||
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
|
||||
if (this.isManagedAccessRoute(storedRoute) && !storedRoute.metadata?.sourceBindings?.length) {
|
||||
return [];
|
||||
}
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
|
||||
const sourceBoundRoutes = SourcePolicyCompiler.compileRoute(
|
||||
hydratedRoute || storedRoute.route,
|
||||
storedRoute.metadata,
|
||||
this.referenceResolver,
|
||||
storedRoute.id,
|
||||
);
|
||||
return sourceBoundRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
|
||||
}
|
||||
|
||||
private isManagedAccessRoute(storedRoute: IRoute): boolean {
|
||||
const metadata = storedRoute.metadata;
|
||||
if (storedRoute.origin !== 'api' || !metadata) {
|
||||
return false;
|
||||
}
|
||||
return metadata.ownerType === 'gatewayClient'
|
||||
|| metadata.ownerType === 'workhoster'
|
||||
|| Boolean(metadata.gatewayClientId)
|
||||
|| Boolean(metadata.workHosterId)
|
||||
|| Boolean(metadata.externalKey);
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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...
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,47 @@ export interface IMigrationRunner {
|
||||
|
||||
type TMigrationSecurity = Record<string, any>;
|
||||
|
||||
const DEFAULT_SOURCE_PROFILES: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
security: TMigrationSecurity;
|
||||
}> = [
|
||||
{
|
||||
name: 'TRUSTED NETWORKS',
|
||||
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI CRAWLERS',
|
||||
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
|
||||
security: {
|
||||
ipAllowList: [],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 30,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PUBLIC',
|
||||
description: 'Public fallback source profile with per-IP request limiting',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 120,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function mergeMigrationSecurityFields(
|
||||
base: TMigrationSecurity | undefined,
|
||||
override: TMigrationSecurity | undefined,
|
||||
@@ -195,6 +236,149 @@ async function backfillSystemRouteKeys(ctx: {
|
||||
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
|
||||
}
|
||||
|
||||
async function seedMissingDefaultSourceProfiles(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const collection = ctx.mongo!.collection('SourceProfileDoc');
|
||||
const now = Date.now();
|
||||
let inserted = 0;
|
||||
let existing = 0;
|
||||
|
||||
for (const profile of DEFAULT_SOURCE_PROFILES) {
|
||||
const existingProfile = await collection.findOne({ name: profile.name });
|
||||
if (existingProfile) {
|
||||
existing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await collection.insertOne({
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
security: structuredClone(profile.security),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'system',
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`seed-missing-default-source-profiles: inserted ${inserted}, already present ${existing}`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMigrationSourceBinding(binding: any, profiles: Map<string, any>): any | undefined {
|
||||
if (!binding || typeof binding !== 'object') return undefined;
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
if (!sourceProfileRef) return undefined;
|
||||
|
||||
const profile = profiles.get(sourceProfileRef);
|
||||
const normalizedBinding = structuredClone(binding);
|
||||
normalizedBinding.sourceProfileRef = sourceProfileRef;
|
||||
const sourceProfileName = typeof normalizedBinding.sourceProfileName === 'string'
|
||||
? normalizedBinding.sourceProfileName.trim()
|
||||
: '';
|
||||
if (sourceProfileName) {
|
||||
normalizedBinding.sourceProfileName = sourceProfileName;
|
||||
} else if (typeof profile?.name === 'string' && profile.name.trim()) {
|
||||
normalizedBinding.sourceProfileName = profile.name.trim();
|
||||
} else {
|
||||
delete normalizedBinding.sourceProfileName;
|
||||
}
|
||||
|
||||
return normalizedBinding;
|
||||
}
|
||||
|
||||
async function convertRouteAccessMetadataToSourceBindings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
|
||||
const routeCollection = ctx.mongo!.collection('RouteDoc');
|
||||
const profiles = new Map<string, any>();
|
||||
const now = Date.now();
|
||||
|
||||
for await (const profile of profileCollection.find({})) {
|
||||
if (typeof (profile as any).id === 'string') {
|
||||
profiles.set((profile as any).id, profile);
|
||||
}
|
||||
}
|
||||
|
||||
let inspected = 0;
|
||||
let migrated = 0;
|
||||
for await (const routeDoc of routeCollection.find({})) {
|
||||
const metadata = (routeDoc as any).metadata || {};
|
||||
const existingSourceBindings = Array.isArray(metadata.sourceBindings)
|
||||
? metadata.sourceBindings
|
||||
: [];
|
||||
const legacyPolicyBindings = Array.isArray(metadata.sourcePolicy?.bindings)
|
||||
? metadata.sourcePolicy.bindings
|
||||
: [];
|
||||
const legacySourceProfileRef = typeof metadata.sourceProfileRef === 'string'
|
||||
? metadata.sourceProfileRef.trim()
|
||||
: '';
|
||||
const hasLegacyAccessFields = legacyPolicyBindings.length > 0
|
||||
|| legacySourceProfileRef.length > 0
|
||||
|| metadata.sourcePolicy !== undefined
|
||||
|| metadata.sourceProfileRef !== undefined
|
||||
|| metadata.sourceProfileName !== undefined;
|
||||
|
||||
if (!hasLegacyAccessFields && existingSourceBindings.length === 0) {
|
||||
continue;
|
||||
}
|
||||
inspected++;
|
||||
|
||||
const sourceBindings = existingSourceBindings.length > 0
|
||||
? existingSourceBindings
|
||||
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
|
||||
.filter(Boolean)
|
||||
: legacyPolicyBindings.length > 0
|
||||
? legacyPolicyBindings
|
||||
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
|
||||
.filter(Boolean)
|
||||
: legacySourceProfileRef
|
||||
? [normalizeMigrationSourceBinding({
|
||||
sourceProfileRef: legacySourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileName,
|
||||
}, profiles)].filter(Boolean)
|
||||
: [];
|
||||
|
||||
const $set: Record<string, any> = { updatedAt: now };
|
||||
const $unset: Record<string, ''> = {
|
||||
'metadata.sourcePolicy': '',
|
||||
'metadata.sourceProfileRef': '',
|
||||
'metadata.sourceProfileName': '',
|
||||
};
|
||||
|
||||
if (sourceBindings.length > 0) {
|
||||
$set['metadata.sourceBindings'] = sourceBindings;
|
||||
$set['metadata.lastResolvedAt'] = now;
|
||||
} else if (existingSourceBindings.length === 0) {
|
||||
$unset['metadata.sourceBindings'] = '';
|
||||
}
|
||||
|
||||
if (existingSourceBindings.length === 0 && legacyPolicyBindings.length === 0 && legacySourceProfileRef) {
|
||||
$unset['route.security'] = '';
|
||||
}
|
||||
|
||||
const query = (routeDoc as any)._id
|
||||
? { _id: (routeDoc as any)._id }
|
||||
: { id: (routeDoc as any).id };
|
||||
await routeCollection.updateOne(query, { $set, $unset });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`convert-route-access-metadata-to-source-bindings: migrated ${migrated}/${inspected} route(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -298,6 +482,18 @@ 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);
|
||||
});
|
||||
|
||||
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.43.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -2474,6 +2476,36 @@ export const fetchMergedRoutesAction = routeManagementStatePart.createAction(asy
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchHttpRedirectsAction = routeManagementStatePart.createAction(async (statePartArg): Promise<IRouteManagementState> => {
|
||||
const context = getActionContext();
|
||||
const currentState = statePartArg.getState()!;
|
||||
if (!context.identity) return currentState;
|
||||
|
||||
try {
|
||||
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetHttpRedirects
|
||||
>('/typedrequest', 'getHttpRedirects');
|
||||
|
||||
const response = await request.fire({
|
||||
identity: context.identity,
|
||||
});
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
httpRedirects: response.redirects,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
...currentState,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch HTTP redirects',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const createRouteAction = routeManagementStatePart.createAction<{
|
||||
route: any;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -19,6 +19,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
|
||||
@@ -17,6 +17,7 @@ export class OpsViewGatewayClients extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
httpRedirects: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,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';
|
||||
}
|
||||
}
|
||||
@@ -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