Compare commits

...

19 Commits

Author SHA1 Message Date
jkunz 496dba94b1 v13.43.5
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m46s
2026-06-03 16:10:17 +00:00
jkunz 69dbc29662 fix(deps): bump @serve.zone/catalog to ^2.12.8 2026-06-03 16:06:29 +00:00
jkunz 3bd6d2f2de v13.43.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 5m50s
2026-06-03 14:28:26 +00:00
jkunz 2c8cc93952 fix(remoteingress): track tunnel streams using summary events 2026-06-03 14:24:43 +00:00
jkunz 3f50518b80 v13.43.3
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m27s
2026-06-03 11:57:22 +00:00
jkunz 15ca5d137c chore(changelog): consolidate smartproxy dependency entry 2026-06-03 11:54:07 +00:00
jkunz 16a4b04dfb fix(deps): bump @push.rocks/smartproxy to ^27.12.6 2026-06-03 11:53:02 +00:00
jkunz 03b494018a fix(deps): bump @push.rocks/smartproxy to ^27.12.6 2026-06-03 11:43:16 +00:00
jkunz 9c08384df0 v13.43.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m22s
2026-06-03 09:32:11 +00:00
jkunz 9286f56316 fix(route-management): use canonical source bindings 2026-06-03 06:46:38 +00:00
jkunz 1c4caf2b85 v13.43.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m57s
2026-06-03 04:20:56 +00:00
jkunz 4a09b273df fix(dockerignore): ignore generated artifacts and caches in Docker build context 2026-06-03 04:17:02 +00:00
jkunz 4ceb46b509 v13.43.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m6s
2026-06-03 03:29:58 +00:00
jkunz 0aa1cde5eb feat(http-redirects): add derived HTTP-to-HTTPS redirects 2026-06-03 03:24:55 +00:00
jkunz 584782dcb7 v13.42.4
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m58s
2026-06-02 18:59:20 +00:00
jkunz 810ecf46f8 fix(deps): update Deno import dependencies 2026-06-02 17:38:51 +00:00
jkunz 6d5d23a691 fix(source-policy-compiler): normalize source policy route priorities to stable integers 2026-06-02 17:25:18 +00:00
jkunz c6617c79f5 v13.42.3
Release / build-and-release (push) Successful in 6m49s
Docker (tags) / release (push) Failing after 1s
2026-06-02 15:40:09 +00:00
jkunz 135432260d fix(deps): update dependency versions 2026-06-02 15:40:07 +00:00
35 changed files with 2191 additions and 1170 deletions
+8
View File
@@ -1,7 +1,15 @@
node_modules/
.nogit/
.git/
.cache/
.rpt2_cache
.yarn/
.playwright-mcp/
.vscode/
coverage/
dist/
dist_*/
pages/
public/
test/
test_watch/
+68
View File
@@ -6,6 +6,74 @@
## 2026-06-03 - 13.43.5
### Fixes
- bump @serve.zone/catalog to ^2.12.8 (deps)
- Updated @serve.zone/catalog from ^2.12.7 to ^2.12.8.
## 2026-06-03 - 13.43.4
### Fixes
- track tunnel streams using summary events (remoteingress)
- Enable summary stream event mode for the RemoteIngress hub.
- Synchronize active tunnel counts and stream totals from stream summary events.
- Bump @serve.zone/remoteingress to ^4.23.0.
- Remove obsolete Deno import map entries.
## 2026-06-03 - 13.43.3
### Fixes
- bump @push.rocks/smartproxy to ^27.12.6 (deps)
- Updates package and Deno import dependencies from @push.rocks/smartproxy ^27.12.4 to ^27.12.6.
## 2026-06-03 - 13.43.2
### Fixes
- enforce canonical source bindings for route access (route-management)
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
## 2026-06-03 - 13.43.1
### Fixes
- ignore generated artifacts and caches in Docker build context (dockerignore)
- Exclude cache directories, coverage reports, distribution outputs, and generated static assets from Docker contexts.
## 2026-06-03 - 13.43.0
### Features
- add derived HTTP-to-HTTPS redirects (http-redirects)
- Generate 301 runtime redirect routes from eligible HTTPS routes while detecting existing HTTP route coverage or conflicts
- Expose derived redirect metadata through the getHttpRedirects typed request API
- Add an Ops Redirects network view with redirect status metrics and table details
- Add tests for redirect derivation, conflict handling, and preserving request host/path
## 2026-06-02 - 13.42.4
### Fixes
- normalize source policy route priorities to stable integers (source-policy-compiler)
- Assign integer priorities to compiled source policy route variants while preserving relative priority order.
- Keep path-specific source policy variants ranked above fallback variants.
- update Deno import dependencies (deps)
- Bumped Deno import map versions for API, identity, push.rocks, serve.zone, and lru-cache dependencies.
## 2026-06-02 - 13.42.3
### Fixes
- update dependency versions (deps)
- Bumped runtime dependencies including @serve.zone/interfaces to ^6.2.1, @serve.zone/catalog to ^2.12.7, and lru-cache to ^11.5.1.
- Updated @git.zone/tsdocker dev dependency to ^2.4.2.
## 2026-06-02 - 13.42.2
### Fixes
+1 -40
View File
@@ -1,49 +1,10 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.42.2",
"version": "13.43.5",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
"dist_serve"
]
},
"imports": {
"@api.global/typedrequest": "npm:@api.global/typedrequest@^3.3.1",
"@api.global/typedrequest-interfaces": "npm:@api.global/typedrequest-interfaces@^3.0.19",
"@api.global/typedserver": "npm:@api.global/typedserver@^8.4.6",
"@api.global/typedsocket": "npm:@api.global/typedsocket@^4.1.3",
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@^7.1.0",
"@idp.global/sdk/server": "npm:@idp.global/sdk@^1.3.1/server",
"@push.rocks/lik": "npm:@push.rocks/lik@^6.4.1",
"@push.rocks/projectinfo": "npm:@push.rocks/projectinfo@^5.1.0",
"@push.rocks/qenv": "npm:@push.rocks/qenv@^6.1.4",
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^9.5.0",
"@push.rocks/smartdata": "npm:@push.rocks/smartdata@^7.1.7",
"@push.rocks/smartdb": "npm:@push.rocks/smartdb@^2.10.1",
"@push.rocks/smartdns": "npm:@push.rocks/smartdns@^7.9.3",
"@push.rocks/smartfs": "npm:@push.rocks/smartfs@^1.5.1",
"@push.rocks/smartguard": "npm:@push.rocks/smartguard@^3.1.0",
"@push.rocks/smartjwt": "npm:@push.rocks/smartjwt@^2.2.2",
"@push.rocks/smartlog": "npm:@push.rocks/smartlog@^3.2.2",
"@push.rocks/smartmetrics": "npm:@push.rocks/smartmetrics@^3.0.3",
"@push.rocks/smartmigration": "npm:@push.rocks/smartmigration@1.4.1",
"@push.rocks/smartmta": "npm:@push.rocks/smartmta@^5.3.3",
"@push.rocks/smartnetwork": "npm:@push.rocks/smartnetwork@^4.7.2",
"@push.rocks/smartpath": "npm:@push.rocks/smartpath@^6.0.0",
"@push.rocks/smartpromise": "npm:@push.rocks/smartpromise@^4.2.4",
"@push.rocks/smartproxy": "npm:@push.rocks/smartproxy@^27.12.3",
"@push.rocks/smartradius": "npm:@push.rocks/smartradius@^1.1.2",
"@push.rocks/smartrequest": "npm:@push.rocks/smartrequest@^5.0.3",
"@push.rocks/smartrx": "npm:@push.rocks/smartrx@^3.0.10",
"@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.4",
"@tsclass/tsclass": "npm:@tsclass/tsclass@^9.5.1",
"lru-cache": "npm:lru-cache@^11.4.0",
"qrcode": "npm:qrcode@^1.5.4",
"uuid": "npm:uuid@^14.0.0"
}
}
+12 -12
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.42.2",
"version": "13.43.5",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -29,27 +29,27 @@
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsdocker": "^2.4.1",
"@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.4",
"@push.rocks/smartproxy": "^27.12.6",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
@@ -69,12 +69,12 @@
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/smartvpn": "1.20.0",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.22.5",
"@serve.zone/catalog": "^2.12.8",
"@serve.zone/interfaces": "^6.2.1",
"@serve.zone/remoteingress": "^4.23.0",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.4.0",
"lru-cache": "^11.5.1",
"qrcode": "^1.5.4",
"uuid": "^14.0.0"
},
+143 -373
View File
File diff suppressed because it is too large Load Diff
+44 -45
View File
@@ -146,19 +146,20 @@ 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 Policies
## Route Source Bindings
API-created route records pass `metadata.sourcePolicy` alongside the SmartProxy route config to express ordered source and path policy variants without duplicating whole routes by hand. A source policy contains ordered `bindings`, each pointing at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
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-policy rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-policy binding and path-policy overrides.
- A public fallback binding must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
- Create/update paths reject source policies with missing source profiles, source profiles without source matches, missing final all-source fallback, or any all-source binding that shadows later bindings; persisted invalid policies 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, and 512 compiled SmartProxy route-port variants per stored route.
- 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.
@@ -177,45 +178,43 @@ const createRoutePayload = {
},
},
metadata: {
sourcePolicy: {
bindings: [
{
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' },
},
],
},
],
},
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' },
},
],
},
],
},
};
```
+232
View File
@@ -0,0 +1,232 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import * as http from 'node:http';
import * as net from 'node:net';
import {
deriveHttpRedirectConfiguration,
deriveHttpRedirects,
} from '../ts/config/helpers.http-redirects.js';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
tap.test('deriveHttpRedirectConfiguration creates active runtime redirects from HTTPS routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
id: 'route-1',
name: 'app-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
remoteIngress: {
enabled: true,
edgeFilter: ['edge-a'],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('active');
expect(result.redirects[0].domainPattern).toEqual('app.example.com');
expect(result.redirects[0].remoteIngress).toEqual(true);
expect(result.runtimeRoutes.length).toEqual(1);
expect(result.runtimeRoutes[0].match.ports).toEqual(80);
expect(result.runtimeRoutes[0].match.domains).toEqual('app.example.com');
expect(result.runtimeRoutes[0].priority).toEqual(0);
expect(result.runtimeRoutes[0].remoteIngress).toEqual({ enabled: true, edgeFilter: ['edge-a'] });
expect(typeof result.runtimeRoutes[0].action.socketHandler).toEqual('function');
});
tap.test('deriveHttpRedirectConfiguration deduplicates identical redirect scopes', async () => {
const redirects = deriveHttpRedirects([
{
id: 'route-1',
name: 'first-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
id: 'route-2',
name: 'second-route',
match: { ports: [443], domains: ['app.example.com'] },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8081 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]);
expect(redirects.length).toEqual(1);
expect(redirects[0].sourceRouteNames).toEqual(['first-route', 'second-route']);
});
tap.test('deriveHttpRedirectConfiguration treats broad explicit HTTP routes as covered', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects.length).toEqual(1);
expect(result.redirects[0].status).toEqual('covered');
expect(result.redirects[0].coveredByRouteNames).toEqual(['existing-http-route']);
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips broad redirects that overlap path-specific HTTP routes', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'existing-http-health-route',
match: { ports: 80, domains: 'app.example.com', path: '/health' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration skips wildcard redirects that overlap explicit HTTP domains', async () => {
const result = deriveHttpRedirectConfiguration([
{
name: 'wildcard-https-route',
match: { ports: 443, domains: '*.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'explicit-http-app-route',
match: { ports: 80, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
},
} as any,
]);
expect(result.redirects[0].status).toEqual('skipped');
expect(result.runtimeRoutes.length).toEqual(0);
});
tap.test('deriveHttpRedirectConfiguration ignores non-web or narrowed HTTPS routes', async () => {
const redirects = deriveHttpRedirects([
{
name: 'udp-route',
match: { ports: 443, domains: 'udp.example.com', transport: 'udp' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 443 }],
tls: { mode: 'passthrough' },
},
} as any,
{
name: 'header-route',
match: { ports: 443, domains: 'header.example.com', headers: { 'x-test': 'yes' } },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
{
name: 'socket-handler-route',
match: { ports: 443, domains: 'handler.example.com' },
action: {
type: 'socket-handler',
socketHandler: () => {},
},
} as any,
]);
expect(redirects.length).toEqual(0);
});
tap.test('generated runtime redirect preserves host and path', async () => {
const proxyPort = await getFreePort();
const redirectRoute = deriveHttpRedirectConfiguration([
{
name: 'https-route',
match: { ports: 443, domains: 'app.example.com' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: 8080 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
} as any,
]).runtimeRoutes[0] as any;
redirectRoute.match = { ...redirectRoute.match, ports: proxyPort };
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [redirectRoute],
});
try {
await proxy.start();
const response = await requestHeaders(proxyPort, '/some/path?x=1', { host: 'app.example.com' });
expect(response.statusCode).toEqual(301);
expect(response.headers.location).toEqual('https://app.example.com/some/path?x=1');
response.destroy();
} finally {
await proxy.stop();
}
});
export default tap.start();
+97
View File
@@ -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);
@@ -74,6 +92,7 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
for (const document of documents) {
if (!matchesQuery(document, query)) continue;
applySet(document, update.$set || {});
applyUnset(document, update.$unset || {});
modifiedCount++;
}
return { modifiedCount };
@@ -82,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 };
},
};
@@ -222,4 +242,81 @@ tap.test('migration runner seeds only missing default source profiles', async ()
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();
+46 -234
View File
@@ -3,10 +3,6 @@ import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
// ============================================================================
// Helpers: access private maps for direct unit testing without DB
// ============================================================================
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
@@ -54,10 +50,6 @@ function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
} as IRouteConfig;
}
// ============================================================================
// Resolution tests
// ============================================================================
let resolver: ReferenceResolver;
tap.test('should create ReferenceResolver instance', async () => {
@@ -67,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,62 +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' },
});
storedRoutes.set('route-d', {
id: 'route-d',
route: makeRoute({ name: 'route-d' }),
enabled: true,
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'profile-1' }],
},
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
networkTargetRef: 'target-1',
},
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
expect(profileRefs.length).toEqual(3);
expect(profileRefs).toHaveLength(2);
expect(profileRefs).toContain('route-a');
expect(profileRefs).toContain('route-c');
expect(profileRefs).toContain('route-d');
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 resolve source policy binding display names', async () => {
const route = makeRoute();
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'profile-1' }],
},
};
const result = resolver.resolveRoute(route, metadata);
expect(result.route.security).toBeUndefined();
expect(result.metadata.sourcePolicy!.bindings[0].sourceProfileName).toEqual('STANDARD');
expect(result.metadata.lastResolvedAt).toBeTruthy();
});
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' }),
@@ -376,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();
+246 -178
View File
@@ -55,13 +55,11 @@ tap.test('source policy compiler expands one route into ordered source variants'
}));
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'ai' },
{ sourceProfileRef: 'public' },
],
},
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'ai' },
{ sourceProfileRef: 'public' },
],
};
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
@@ -75,6 +73,8 @@ tap.test('source policy compiler expands one route into ordered source variants'
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 () => {
@@ -89,15 +89,13 @@ tap.test('source policy binding can override profile rate limit and 429 message'
}));
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [
{
sourceProfileRef: 'public',
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
onExceeded: { type: '429', errorMessage: 'Slow down' },
},
],
},
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');
@@ -126,27 +124,25 @@ tap.test('source policy compiler forces source-policy rate limits to source IP k
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{
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' },
},
],
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',
@@ -181,25 +177,23 @@ tap.test('source policy binding can split Gitea path classes before its fallback
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{
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' },
],
},
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',
@@ -232,14 +226,12 @@ tap.test('source policy compiler uses built-in Gitea path class patterns', async
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }],
},
],
},
sourceBindings: [
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }],
},
],
},
resolver,
'route-1',
@@ -258,6 +250,48 @@ tap.test('source policy compiler uses built-in Gitea path class patterns', async
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({
@@ -274,12 +308,10 @@ tap.test('source policy compiler fails closed when wildcard binding shadows late
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'public' },
{ sourceProfileRef: 'trusted' },
],
},
sourceBindings: [
{ sourceProfileRef: 'public' },
{ sourceProfileRef: 'trusted' },
],
},
resolver,
'route-1',
@@ -288,6 +320,32 @@ tap.test('source policy compiler fails closed when wildcard binding shadows late
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({
@@ -303,12 +361,10 @@ tap.test('source policy compiler fails closed when expansion would exceed route
),
}));
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public', pathPolicies }],
},
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
};
expect(SourcePolicyCompiler.validateSourcePolicyShape(metadata.sourcePolicy)).toContain('compiled route variants');
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
});
@@ -326,11 +382,9 @@ tap.test('source policy compiler fails closed when configured bindings cannot co
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'empty-ai' },
],
},
sourceBindings: [
{ sourceProfileRef: 'empty-ai' },
],
},
resolver,
'route-1',
@@ -339,9 +393,7 @@ tap.test('source policy compiler fails closed when configured bindings cannot co
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [{ sourceProfileRef: 'empty-ai' }],
},
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
undefined,
'route-1',
@@ -368,19 +420,17 @@ tap.test('source policy compiler keeps generated priorities inside SmartProxy bo
}));
const route = makeRoute();
route.priority = 10000;
route.priority = 9000;
const variants = SourcePolicyCompiler.compileRoute(
route,
{
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'trusted' },
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
},
],
},
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
},
],
},
resolver,
'route-1',
@@ -391,6 +441,24 @@ tap.test('source policy compiler keeps generated priorities inside SmartProxy bo
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({
@@ -427,12 +495,10 @@ tap.test('RouteConfigManager applies source policy as expanded runtime routes',
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'public' },
],
},
sourceBindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'public' },
],
},
});
@@ -476,9 +542,7 @@ tap.test('RouteConfigManager does not apply an uncompiled source-policy route',
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'empty-ai' }],
},
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
},
});
@@ -488,6 +552,39 @@ tap.test('RouteConfigManager does not apply an uncompiled source-policy route',
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({
@@ -516,23 +613,19 @@ tap.test('RouteConfigManager rejects wildcard source policy bindings before late
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
},
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?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('trusted');
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
});
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
@@ -558,23 +651,19 @@ tap.test('RouteConfigManager rejects missing source policy profiles', async () =
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain("Source profile 'missing' not found");
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
@@ -605,26 +694,22 @@ tap.test('RouteConfigManager rejects source profiles without source matches', as
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
},
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?.sourcePolicy?.bindings).toHaveLength(1);
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
});
tap.test('RouteConfigManager rejects source policies without a final all-source fallback', async () => {
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
@@ -643,6 +728,8 @@ tap.test('RouteConfigManager rejects source policies without a final all-source
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(),
@@ -652,23 +739,18 @@ tap.test('RouteConfigManager rejects source policies without a final all-source
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'trusted' }],
},
sourceBindings: [{ sourceProfileRef: 'trusted' }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('Source policy must end with an all-source fallback profile');
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('public');
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 () => {
@@ -699,9 +781,7 @@ tap.test('RouteConfigManager rejects source policies with broad port range expan
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
});
@@ -739,23 +819,19 @@ tap.test('RouteConfigManager rejects negative source-policy maxConnections overr
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
},
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('maxConnections');
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].maxConnections).toBeUndefined();
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
@@ -781,34 +857,30 @@ tap.test('RouteConfigManager rejects oversized nested source-policy rate limit m
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [
{
sourceProfileRef: 'public',
rateLimit: {
enabled: true,
maxRequests: 10,
window: 60,
keyBy: 'ip',
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
},
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?.sourcePolicy?.bindings[0].rateLimit).toBeUndefined();
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
@@ -834,36 +906,32 @@ tap.test('RouteConfigManager rejects oversized source policy path patterns', asy
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
sourceBindings: [{ sourceProfileRef: 'public' }],
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [
{
sourceProfileRef: 'public',
pathPolicies: [
{
pathClass: 'git-smart-http',
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
(_item, index) => `/too-many-${index}`,
),
},
],
},
],
},
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?.sourcePolicy?.bindings[0].pathPolicies).toBeUndefined();
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
});
export default tap.start();
+23
View File
@@ -108,6 +108,11 @@ const makeRouteConfigManager = () => {
if (!storedRoute) return { success: false, message: 'Route not found' };
if (patch.route) {
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
for (const [key, value] of Object.entries(patch.route)) {
if (value === null) {
delete (storedRoute.route as any)[key];
}
}
}
if (patch.enabled !== undefined) {
storedRoute.enabled = patch.enabled;
@@ -126,6 +131,20 @@ const makeRouteConfigManager = () => {
};
};
const standardSourceProfile: interfaces.data.ISourceProfile = {
id: 'standard',
name: 'STANDARD',
description: 'Standard test profile',
security: { ipAllowList: ['10.0.0.0/8'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
};
const makeReferenceResolver = () => ({
listProfiles: () => [standardSourceProfile],
});
const setupHandler = (options: {
scopes: TScope[];
policy?: interfaces.data.IApiTokenPolicy;
@@ -146,6 +165,7 @@ const setupHandler = (options: {
dcRouterRef: {
options: {},
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
referenceResolver: makeReferenceResolver(),
...options.dcRouterRef,
},
};
@@ -244,6 +264,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
expect(createdRoute.createdBy).toEqual('token-user');
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
expect(createdRoute.metadata).toEqual({
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
ownerType: 'gatewayClient',
gatewayClientType: 'onebox',
gatewayClientId: 'box-1',
@@ -253,6 +274,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
workAppId: 'app-1',
externalKey: 'onebox:box-1:app-1:app.example.com',
});
createdRoute.route.security = { ipAllowList: ['*'] };
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
@@ -275,6 +297,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
apiToken: 'valid-token',
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.42.2',
version: '13.43.5',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+2 -2
View File
@@ -25,7 +25,7 @@ import { MetricsManager } from './monitoring/index.js';
import { RadiusServer, type IRadiusServerConfig } from './radius/index.js';
import { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
import { VpnManager, type IVpnManagerConfig } from './vpn/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager } from './config/index.js';
import { RouteConfigManager, ApiTokenManager, GatewayClientManager, ReferenceResolver, DbSeeder, TargetProfileManager, buildHttpRedirectRuntimeRoutes } from './config/index.js';
import type { TVpnClientAllowEntry } from './config/classes.route-config-manager.js';
import { SecurityLogger, ContentScanner, IPReputationChecker, SecurityPolicyManager } from './security/index.js';
import { type IHttp3Config, augmentRoutesWithHttp3 } from './http3/index.js';
@@ -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();
+16 -40
View File
@@ -7,7 +7,7 @@ import type {
IRouteMetadata,
IRoute,
IRouteSecurity,
IRouteSourcePolicy,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
@@ -288,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(
@@ -298,27 +298,12 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.sourcePolicy?.bindings.length) {
const resolvedSourcePolicy = this.resolveRouteSourcePolicy(resolvedMetadata.sourcePolicy);
if (resolvedSourcePolicy) {
resolvedMetadata.sourcePolicy = resolvedSourcePolicy;
resolvedMetadata.sourceProfileRef = undefined;
resolvedMetadata.sourceProfileName = undefined;
if (resolvedMetadata.sourceBindings?.length) {
const resolvedSourceBindings = this.resolveRouteSourceBindings(resolvedMetadata.sourceBindings);
if (resolvedSourceBindings) {
resolvedMetadata.sourceBindings = resolvedSourceBindings;
resolvedMetadata.lastResolvedAt = Date.now();
}
} else 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;
resolvedMetadata.lastResolvedAt = Date.now();
} else {
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
}
}
if (resolvedMetadata.networkTargetRef) {
@@ -387,12 +372,12 @@ export class ReferenceResolver {
// Private: source profile resolution with inheritance
// =========================================================================
private resolveRouteSourcePolicy(sourcePolicy: IRouteSourcePolicy): IRouteSourcePolicy | undefined {
const bindings = sourcePolicy.bindings
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 policy resolution`);
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source binding resolution`);
return binding;
}
return {
@@ -402,7 +387,7 @@ export class ReferenceResolver {
})
.filter((binding) => binding.sourceProfileRef);
return bindings.length > 0 ? { bindings } : undefined;
return bindings.length > 0 ? bindings : undefined;
}
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
@@ -411,10 +396,7 @@ export class ReferenceResolver {
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
const refs = new Set<string>();
if (metadata?.sourceProfileRef) {
refs.add(metadata.sourceProfileRef);
}
for (const binding of metadata?.sourcePolicy?.bindings || []) {
for (const binding of metadata?.sourceBindings || []) {
if (binding.sourceProfileRef) {
refs.add(binding.sourceProfileRef);
}
@@ -623,22 +605,16 @@ export class ReferenceResolver {
}
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
const sourcePolicy = metadata.sourcePolicy?.bindings?.length
? {
bindings: metadata.sourcePolicy.bindings.filter(
(binding) => binding.sourceProfileRef !== profileId,
),
}
const sourceBindings = metadata.sourceBindings?.length
? metadata.sourceBindings.filter((binding) => binding.sourceProfileRef !== profileId)
: undefined;
const nextMetadata: IRouteMetadata = {
...metadata,
sourceProfileRef: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileRef,
sourceProfileName: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileName,
sourcePolicy: sourcePolicy?.bindings.length ? sourcePolicy : undefined,
sourceBindings: sourceBindings?.length ? sourceBindings : undefined,
};
if (!nextMetadata.sourceProfileRef && !nextMetadata.sourcePolicy && !nextMetadata.networkTargetRef) {
if (!nextMetadata.sourceBindings && !nextMetadata.networkTargetRef) {
nextMetadata.lastResolvedAt = undefined;
}
+63 -50
View File
@@ -3,18 +3,20 @@ 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,
IRouteSourcePolicy,
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[] };
@@ -64,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,
) {}
@@ -124,6 +126,10 @@ export class RouteConfigManager {
return { routes: merged, warnings: [...this.warnings] };
}
public getHttpRedirects(): IHttpRedirectInfo[] {
return deriveHttpRedirects(this.getPreparedEnabledRoutesForApply());
}
// =========================================================================
// Route CRUD
// =========================================================================
@@ -136,9 +142,9 @@ export class RouteConfigManager {
): Promise<string> {
const id = plugins.uuid.v4();
const now = Date.now();
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(metadata?.sourcePolicy);
if (sourcePolicyPayloadError) {
throw new Error(sourcePolicyPayloadError);
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
if (sourceBindingsPayloadError) {
throw new Error(sourceBindingsPayloadError);
}
// Ensure route has a name
@@ -153,9 +159,9 @@ export class RouteConfigManager {
route = resolved.route;
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourcePolicyValidationError = this.validateSourcePolicy(resolvedMetadata?.sourcePolicy, route);
if (sourcePolicyValidationError) {
throw new Error(sourcePolicyValidationError);
const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route);
if (sourceBindingsValidationError) {
throw new Error(sourceBindingsValidationError);
}
const stored: IRoute = {
@@ -187,12 +193,11 @@ export class RouteConfigManager {
if (!stored) {
return { success: false, message: 'Route not found' };
}
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(patch.metadata?.sourcePolicy);
if (sourcePolicyPayloadError) {
return { success: false, message: sourcePolicyPayloadError };
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;
@@ -238,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
@@ -254,12 +252,12 @@ export class RouteConfigManager {
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourcePolicyValidationError = this.validateSourcePolicy(stored.metadata?.sourcePolicy, stored.route);
if (sourcePolicyValidationError) {
const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route);
if (sourceBindingsValidationError) {
stored.route = previousRoute;
stored.metadata = previousMetadata;
stored.enabled = previousEnabled;
return { success: false, message: sourcePolicyValidationError };
return { success: false, message: sourceBindingsValidationError };
}
stored.updatedAt = Date.now();
@@ -481,10 +479,8 @@ export class RouteConfigManager {
};
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
sourcePolicy: this.normalizeSourcePolicy(metadata.sourcePolicy),
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
@@ -505,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.sourcePolicy && !normalized.networkTargetRef) {
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
@@ -536,14 +529,13 @@ export class RouteConfigManager {
return normalized;
}
private normalizeSourcePolicy(sourcePolicy?: Partial<IRouteSourcePolicy>): IRouteSourcePolicy | undefined {
const bindings = sourcePolicy?.bindings;
if (!Array.isArray(bindings)) {
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): IRouteSourceBinding[] | undefined {
if (!Array.isArray(sourceBindings)) {
return undefined;
}
const normalizedBindings: IRouteSourcePolicy['bindings'] = [];
for (const binding of bindings) {
const normalizedBindings: IRouteSourceBinding[] = [];
for (const binding of sourceBindings) {
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
? binding.sourceProfileRef.trim()
: '';
@@ -577,7 +569,7 @@ export class RouteConfigManager {
});
}
return normalizedBindings.length > 0 ? { bindings: normalizedBindings } : undefined;
return normalizedBindings.length > 0 ? normalizedBindings : undefined;
}
private normalizePathPolicies(
@@ -625,15 +617,15 @@ export class RouteConfigManager {
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
}
private validateSourcePolicy(
sourcePolicy: IRouteSourcePolicy | undefined,
private validateSourceBindings(
sourceBindings: IRouteSourceBinding[] | undefined,
route: IDcRouterRouteConfig,
): string | undefined {
const shapeError = SourcePolicyCompiler.validateSourcePolicyShape(sourcePolicy, route);
const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route);
if (shapeError) {
return shapeError;
}
return SourcePolicyCompiler.validateResolvedSourcePolicy(sourcePolicy, this.referenceResolver);
return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver);
}
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
@@ -718,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, VPN, and source-policy augmentation
for (const route of this.routes.values()) {
if (route.enabled) {
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
}
}
const runtimeRoutes = this.getRuntimeRoutes?.() || [];
const runtimeRoutes = this.getRuntimeRoutes?.(enabledRoutes) || [];
for (const route of runtimeRoutes) {
enabledRoutes.push(this.prepareRouteForApply(route));
}
@@ -743,15 +728,43 @@ export class RouteConfigManager {
});
}
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);
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
const sourceBoundRoutes = SourcePolicyCompiler.compileRoute(
hydratedRoute || storedRoute.route,
storedRoute.metadata,
this.referenceResolver,
storedRoute.id,
);
return sourcePolicyRoutes.map((route) => this.prepareRouteForApply(route, 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(
+146 -29
View File
@@ -8,8 +8,7 @@ import type {
IRoutePathPolicyBinding,
IRouteMetadata,
IRouteSecurity,
IRouteSourcePolicy,
IRouteSourcePolicyBinding,
IRouteSourceBinding,
} from '../../ts_interfaces/data/route-management.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
@@ -37,22 +36,23 @@ export class SourcePolicyCompiler {
referenceResolver: ReferenceResolver | undefined,
routeId?: string,
): plugins.smartproxy.IRouteConfig[] {
const bindings = metadata?.sourcePolicy?.bindings || [];
const bindings = metadata?.sourceBindings || [];
if (bindings.length === 0) {
return [route];
}
if (this.validateSourcePolicyShape(metadata?.sourcePolicy, route)) {
if (this.validateSourceBindingsShape(bindings, route)) {
return [];
}
if (!referenceResolver) {
return [];
}
if (this.validateResolvedSourcePolicy(metadata?.sourcePolicy, referenceResolver)) {
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);
@@ -65,6 +65,9 @@ export class SourcePolicyCompiler {
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 }
@@ -140,39 +143,43 @@ export class SourcePolicyCompiler {
}
});
return compiledRoutes;
if (compiledRoutes.length > 0 && !hasAllSourcesBinding) {
compiledRoutes.push(this.buildDenyFallbackRoute(route, basePriority, routeId));
}
return this.applyIntegerPriorities(compiledRoutes, basePriority);
}
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
if (!sourcePolicy) {
public static validateSourceBindingsPayload(sourceBindings?: Partial<IRouteSourceBinding>[]): string | undefined {
if (sourceBindings === undefined) {
return undefined;
}
if (!Array.isArray(sourcePolicy.bindings)) {
return 'Source policy bindings must be an array';
if (!Array.isArray(sourceBindings)) {
return 'Source bindings must be an array';
}
if (sourcePolicy.bindings.length === 0) {
if (sourceBindings.length === 0) {
return undefined;
}
if (sourcePolicy.bindings.length > sourcePolicyLimits.maxBindings) {
if (sourceBindings.length > sourcePolicyLimits.maxBindings) {
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
}
const validClasses = new Set<string>(routePathClasses);
for (const binding of sourcePolicy.bindings) {
for (const binding of sourceBindings) {
if (!binding || typeof binding !== 'object') {
return 'Source policy binding must be an object';
return 'Source binding must be an object';
}
if (typeof binding.sourceProfileRef !== 'string') {
return 'Source policy binding requires a source profile';
return 'Source binding requires a source profile';
}
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
return `Source policy source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
return `Source binding source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
}
if (binding.sourceProfileRef.trim().length === 0) {
return 'Source policy binding requires a source profile';
return 'Source binding requires a source profile';
}
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
return `Source policy binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
return `Source binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
return 'Source policy maxConnections must be non-negative';
@@ -268,14 +275,21 @@ export class SourcePolicyCompiler {
}
public static validateSourcePolicyShape(
sourcePolicy?: IRouteSourcePolicy,
sourceBindings?: IRouteSourceBinding[],
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
const payloadError = this.validateSourcePolicyPayload(sourcePolicy);
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 = sourcePolicy?.bindings || [];
const bindings = sourceBindings || [];
if (bindings.length === 0) {
return undefined;
}
@@ -310,19 +324,36 @@ export class SourcePolicyCompiler {
}
}
// 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(
sourcePolicy: IRouteSourcePolicy | undefined,
sourceBindings: IRouteSourceBinding[] | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
const bindings = sourcePolicy?.bindings || [];
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;
}
@@ -346,10 +377,7 @@ export class SourcePolicyCompiler {
}
const matchesAllSources = this.matchesAllSources(sourceMatches);
if (matchesAllSources && index < bindings.length - 1) {
return 'Wildcard source profile bindings must be last in a source policy';
}
if (index === bindings.length - 1 && !matchesAllSources) {
return 'Source policy must end with an all-source fallback profile';
return 'Wildcard source profile bindings must be last in source bindings';
}
}
@@ -361,7 +389,7 @@ export class SourcePolicyCompiler {
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
profileName: string;
profileSecurity: IRouteSecurity;
binding: IRouteSourcePolicyBinding;
binding: IRouteSourceBinding;
pathPolicy?: IRoutePathPolicyBinding;
pathPattern?: string;
sourcePriority: number;
@@ -414,6 +442,63 @@ export class SourcePolicyCompiler {
};
}
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
@@ -452,6 +537,38 @@ export class SourcePolicyCompiler {
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,
@@ -557,7 +674,7 @@ export class SourcePolicyCompiler {
private static buildBindingSecurity(
routeSecurity: IRouteSecurity | undefined,
profileSecurity: IRouteSecurity,
binding: IRouteSourcePolicyBinding,
binding: IRouteSourceBinding,
pathPolicy?: IRoutePathPolicyBinding,
): IRouteSecurity | undefined {
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
+462
View File
@@ -0,0 +1,462 @@
import * as plugins from '../plugins.js';
import type { IHttpRedirectInfo } from '../../ts_interfaces/data/route-management.js';
import type { IDcRouterRouteConfig, IRouteRemoteIngress } from '../../ts_interfaces/data/remoteingress.js';
const AUTO_REDIRECT_ROUTE_PREFIX = 'dcrouter-auto-http-redirect';
const REDIRECT_STATUS_CODE = 301;
const REDIRECT_PRIORITY = 0;
const REDIRECT_TARGET_TEMPLATE = 'https://{domain}{path}';
const REDIRECT_INITIAL_DATA_TIMEOUT_MS = 10_000;
interface IRedirectCandidate {
key: string;
id: string;
domainPattern: string;
pathPattern?: string;
sourceRouteNames: Set<string>;
sourceRouteIds: Set<string>;
remoteIngress?: IRouteRemoteIngress;
}
interface IRedirectConflict {
routeName: string;
covers: boolean;
}
export interface IHttpRedirectDerivationResult {
redirects: IHttpRedirectInfo[];
runtimeRoutes: IDcRouterRouteConfig[];
}
export function deriveHttpRedirectConfiguration(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectDerivationResult {
const candidates = collectRedirectCandidates(routes);
const httpRoutes = routes.filter((route) => isExplicitHttpRoute(route));
const redirects: IHttpRedirectInfo[] = [];
const runtimeRoutes: IDcRouterRouteConfig[] = [];
for (const candidate of candidates) {
const conflict = findHttpConflict(candidate, httpRoutes);
const redirectInfo: IHttpRedirectInfo = {
id: candidate.id,
status: conflict ? (conflict.covers ? 'covered' : 'skipped') : 'active',
domainPattern: candidate.domainPattern,
pathPattern: candidate.pathPattern,
fromTemplate: 'http://{domain}{path}',
toTemplate: REDIRECT_TARGET_TEMPLATE,
statusCode: REDIRECT_STATUS_CODE,
priority: REDIRECT_PRIORITY,
sourceRouteNames: [...candidate.sourceRouteNames].sort(),
sourceRouteIds: [...candidate.sourceRouteIds].sort(),
coveredByRouteNames: conflict ? [conflict.routeName] : [],
remoteIngress: Boolean(candidate.remoteIngress?.enabled),
notes: conflict
? conflict.covers
? 'An explicit HTTP route already covers this redirect scope.'
: 'Skipped because an explicit HTTP route overlaps this redirect scope.'
: undefined,
};
redirects.push(redirectInfo);
if (redirectInfo.status === 'active') {
runtimeRoutes.push(buildRuntimeRedirectRoute(candidate));
}
}
return { redirects, runtimeRoutes };
}
export function deriveHttpRedirects(
routes: plugins.smartproxy.IRouteConfig[],
): IHttpRedirectInfo[] {
return deriveHttpRedirectConfiguration(routes).redirects;
}
export function buildHttpRedirectRuntimeRoutes(
routes: plugins.smartproxy.IRouteConfig[],
): IDcRouterRouteConfig[] {
return deriveHttpRedirectConfiguration(routes).runtimeRoutes;
}
function collectRedirectCandidates(routes: plugins.smartproxy.IRouteConfig[]): IRedirectCandidate[] {
const candidates = new Map<string, IRedirectCandidate>();
for (const route of routes) {
if (!isHttpsRedirectSource(route)) {
continue;
}
for (const domainPattern of getDomainPatterns(route)) {
const key = createRedirectKey(domainPattern, route.match.path);
const existing = candidates.get(key);
if (existing) {
existing.sourceRouteNames.add(getRouteDisplayName(route));
if (route.id) existing.sourceRouteIds.add(route.id);
existing.remoteIngress = mergeRemoteIngress(existing.remoteIngress, (route as IDcRouterRouteConfig).remoteIngress);
continue;
}
const id = createRedirectRouteName(domainPattern, route.match.path);
candidates.set(key, {
key,
id,
domainPattern,
pathPattern: route.match.path,
sourceRouteNames: new Set([getRouteDisplayName(route)]),
sourceRouteIds: new Set(route.id ? [route.id] : []),
remoteIngress: mergeRemoteIngress(undefined, (route as IDcRouterRouteConfig).remoteIngress),
});
}
}
return [...candidates.values()].sort((a, b) => a.id.localeCompare(b.id));
}
function isHttpsRedirectSource(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (route.action.type !== 'forward') return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 443)) return false;
if (!route.action.tls) return false;
if (!route.match.domains) return false;
if (route.match.transport === 'udp') return false;
if (route.match.protocol && route.match.protocol !== 'http') return false;
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) return false;
return true;
}
function isExplicitHttpRoute(route: plugins.smartproxy.IRouteConfig): boolean {
if (isGeneratedRedirectRoute(route)) return false;
if (route.enabled === false) return false;
if (!route.match.ports) return false;
if (!plugins.smartproxy.portRangeIncludes(route.match.ports, 80)) return false;
if (route.match.transport === 'udp') return false;
return true;
}
function findHttpConflict(
candidate: IRedirectCandidate,
httpRoutes: plugins.smartproxy.IRouteConfig[],
): IRedirectConflict | undefined {
for (const route of httpRoutes) {
if (!httpRouteOverlapsCandidate(route, candidate)) {
continue;
}
return {
routeName: getRouteDisplayName(route),
covers: httpRouteCoversCandidate(route, candidate),
};
}
return undefined;
}
function httpRouteOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
return routeDomainOverlapsCandidate(route, candidate.domainPattern)
&& pathOverlaps(route.match.path, candidate.pathPattern);
}
function httpRouteCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidate: IRedirectCandidate,
): boolean {
if (route.match.clientIp || route.match.headers || route.match.tlsVersion) {
return false;
}
return routeDomainCoversCandidate(route, candidate.domainPattern)
&& pathCovers(route.match.path, candidate.pathPattern);
}
function routeDomainOverlapsCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternsOverlap(pattern, candidatePattern));
}
function routeDomainCoversCandidate(
route: plugins.smartproxy.IRouteConfig,
candidatePattern: string,
): boolean {
const routePatterns = getDomainPatterns(route);
if (routePatterns.length === 0) {
return true;
}
return routePatterns.some((pattern) => domainPatternCovers(pattern, candidatePattern));
}
function getDomainPatterns(route: plugins.smartproxy.IRouteConfig): string[] {
if (!route.match.domains) return [];
return Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains];
}
function normalizePattern(pattern: string): string {
return pattern.trim().toLowerCase().replace(/\.$/, '');
}
function domainPatternCovers(coverPattern: string, candidatePattern: string): boolean {
const cover = normalizePattern(coverPattern);
const candidate = normalizePattern(candidatePattern);
if (cover === candidate) return true;
if (!candidate.includes('*')) return domainPatternMatchesHostname(cover, candidate);
const coverSuffix = getLeadingWildcardSuffix(cover);
const candidateSuffix = getLeadingWildcardSuffix(candidate);
if (coverSuffix && candidateSuffix) {
return candidateSuffix.endsWith(coverSuffix);
}
return false;
}
function domainPatternsOverlap(firstPattern: string, secondPattern: string): boolean {
const first = normalizePattern(firstPattern);
const second = normalizePattern(secondPattern);
if (first === second) return true;
if (!first.includes('*')) return domainPatternMatchesHostname(second, first);
if (!second.includes('*')) return domainPatternMatchesHostname(first, second);
const firstSuffix = getLeadingWildcardSuffix(first);
const secondSuffix = getLeadingWildcardSuffix(second);
if (firstSuffix && secondSuffix) {
return firstSuffix.endsWith(secondSuffix) || secondSuffix.endsWith(firstSuffix);
}
return false;
}
function domainPatternMatchesHostname(pattern: string, hostname: string): boolean {
const regex = wildcardPatternToRegex(normalizePattern(pattern));
return regex.test(normalizePattern(hostname));
}
function wildcardPatternToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replace(/\*/g, '.*')}$`, 'i');
}
function getLeadingWildcardSuffix(pattern: string): string | undefined {
if (!pattern.startsWith('*')) return undefined;
if (pattern.slice(1).includes('*')) return undefined;
return pattern.slice(1);
}
function pathCovers(coverPath: string | undefined, candidatePath: string | undefined): boolean {
if (!coverPath) return true;
if (!candidatePath) return false;
if (coverPath === candidatePath) return true;
if (!coverPath.includes('*')) return false;
const coverPrefix = coverPath.split('*')[0];
if (!candidatePath.includes('*')) return candidatePath.startsWith(coverPrefix);
const candidatePrefix = candidatePath.split('*')[0];
return candidatePrefix.startsWith(coverPrefix);
}
function pathOverlaps(firstPath: string | undefined, secondPath: string | undefined): boolean {
if (!firstPath || !secondPath) return true;
if (firstPath === secondPath) return true;
const firstPrefix = firstPath.split('*')[0];
const secondPrefix = secondPath.split('*')[0];
return firstPrefix.startsWith(secondPrefix) || secondPrefix.startsWith(firstPrefix);
}
function buildRuntimeRedirectRoute(candidate: IRedirectCandidate): IDcRouterRouteConfig {
return {
id: candidate.id,
name: candidate.id,
description: 'Generated HTTP to HTTPS redirect',
priority: REDIRECT_PRIORITY,
tags: ['system', 'redirect', 'auto'],
match: {
ports: 80,
domains: candidate.domainPattern,
...(candidate.pathPattern ? { path: candidate.pathPattern } : {}),
},
action: {
type: 'socket-handler',
socketHandler: createHttpRedirectHandler(REDIRECT_TARGET_TEMPLATE, REDIRECT_STATUS_CODE),
},
...(candidate.remoteIngress ? { remoteIngress: candidate.remoteIngress } : {}),
};
}
function mergeRemoteIngress(
current: IRouteRemoteIngress | undefined,
next: IRouteRemoteIngress | undefined,
): IRouteRemoteIngress | undefined {
if (!next?.enabled) return current;
if (!current?.enabled) {
return {
enabled: true,
...(next.edgeFilter?.length ? { edgeFilter: [...next.edgeFilter] } : {}),
};
}
const currentFilter = current.edgeFilter || [];
const nextFilter = next.edgeFilter || [];
if (currentFilter.length === 0 || nextFilter.length === 0) {
return { enabled: true };
}
return {
enabled: true,
edgeFilter: [...new Set([...currentFilter, ...nextFilter])].sort(),
};
}
function createRedirectKey(domainPattern: string, pathPattern?: string): string {
return `${normalizePattern(domainPattern)}|${pathPattern || ''}`;
}
function createRedirectRouteName(domainPattern: string, pathPattern?: string): string {
const key = createRedirectKey(domainPattern, pathPattern);
const slug = key
.replace(/\*/g, 'wildcard')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'route';
const hash = plugins.crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
return `${AUTO_REDIRECT_ROUTE_PREFIX}-${slug}-${hash}`;
}
function getRouteDisplayName(route: plugins.smartproxy.IRouteConfig): string {
return route.name || route.id || 'unnamed-route';
}
function isGeneratedRedirectRoute(route: plugins.smartproxy.IRouteConfig): boolean {
return Boolean(route.name?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX) || route.id?.startsWith(AUTO_REDIRECT_ROUTE_PREFIX));
}
function createHttpRedirectHandler(
locationTemplate: string,
statusCode: number,
): NonNullable<plugins.smartproxy.IRouteConfig['action']['socketHandler']> {
return (socket, context) => {
const cleanup = () => {
clearTimeout(timeout);
socket.removeListener('data', handleData);
socket.removeListener('error', cleanup);
socket.removeListener('close', cleanup);
};
const handleData = (data: string | Uint8Array) => {
cleanup();
const request = parseHttpRequest(data);
if (!request) {
socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
return;
}
const domain = normalizeHostHeader(request.headers.host) || context.domain || 'localhost';
const finalLocation = locationTemplate
.replace('{domain}', domain)
.replace('{port}', String(context.port))
.replace('{path}', request.path || '/')
.replace('{clientIp}', context.clientIp);
const message = `Redirecting to ${finalLocation}`;
const response = [
`HTTP/1.1 ${statusCode} ${getHttpStatusText(statusCode)}`,
`Location: ${finalLocation}`,
'Content-Type: text/plain',
`Content-Length: ${message.length}`,
'Connection: close',
'',
message,
].join('\r\n');
socket.end(response);
};
const timeout = setTimeout(() => {
cleanup();
socket.end('HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n');
}, REDIRECT_INITIAL_DATA_TIMEOUT_MS) as ReturnType<typeof setTimeout> & { unref?: () => void };
timeout.unref?.();
socket.once('data', handleData);
socket.once('error', cleanup);
socket.once('close', cleanup);
};
}
function parseHttpRequest(data: string | Uint8Array): {
method: string;
path: string;
headers: Record<string, string>;
} | undefined {
const requestText = typeof data === 'string' ? data : new TextDecoder().decode(data);
const headerEnd = requestText.indexOf('\r\n\r\n');
const headerText = headerEnd >= 0 ? requestText.slice(0, headerEnd) : requestText;
const lines = headerText.split('\r\n');
const [method, rawPath] = (lines[0] || '').split(' ');
if (!method || !rawPath) return undefined;
const headers: Record<string, string> = {};
for (const line of lines.slice(1)) {
const colonIndex = line.indexOf(':');
if (colonIndex <= 0) continue;
const key = line.slice(0, colonIndex).trim().toLowerCase();
const value = line.slice(colonIndex + 1).trim();
headers[key] = value;
}
return {
method,
path: normalizeRequestPath(rawPath),
headers,
};
}
function normalizeRequestPath(rawPath: string): string {
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
try {
const url = new URL(rawPath);
return `${url.pathname}${url.search}` || '/';
} catch {
return '/';
}
}
return rawPath.startsWith('/') ? rawPath : '/';
}
function normalizeHostHeader(hostHeader: string | undefined): string | undefined {
if (!hostHeader) return undefined;
const host = hostHeader.split(',')[0].trim();
if (!host || /[\s\x00-\x1f\x7f]/.test(host)) return undefined;
if (host.startsWith('[')) {
const bracketIndex = host.indexOf(']');
return bracketIndex > 0 ? host.slice(0, bracketIndex + 1) : undefined;
}
return host.replace(/:(80|443)$/, '');
}
function getHttpStatusText(statusCode: number): string {
switch (statusCode) {
case 301:
return 'Moved Permanently';
case 302:
return 'Found';
case 307:
return 'Temporary Redirect';
case 308:
return 'Permanent Redirect';
default:
return 'Redirect';
}
}
+1
View File
@@ -5,5 +5,6 @@ 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>(
+26 -2
View File
@@ -587,7 +587,13 @@ export class WorkHosterHandler {
return { success: false, message: 'route is required unless delete=true' };
}
const sourceBindings = this.getManagedRouteSourceBindings();
if (!sourceBindings) {
return { success: false, message: 'STANDARD source profile not found' };
}
const metadata: interfaces.data.IRouteMetadata = {
sourceBindings,
ownerType: 'gatewayClient',
gatewayClientType: resolvedOwnership.gatewayClientType,
gatewayClientId: resolvedOwnership.gatewayClientId,
@@ -600,8 +606,10 @@ export class WorkHosterHandler {
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
if (existingRoute) {
const routePatch: Partial<interfaces.data.IDcRouterRouteConfig> = { ...normalizedRoute };
(routePatch as any).security = null;
const result = await manager.updateRoute(existingRoute.id, {
route: normalizedRoute,
route: routePatch,
enabled: enabled ?? true,
metadata,
});
@@ -640,10 +648,26 @@ export class WorkHosterHandler {
ownership: Required<interfaces.data.IGatewayClientOwnership>,
externalKey: string,
): interfaces.data.IDcRouterRouteConfig {
const normalizedRoute = { ...route };
const normalizedRoute = structuredClone(route);
delete normalizedRoute.security;
if (!normalizedRoute.name) {
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
}
return normalizedRoute;
}
private getManagedRouteSourceBindings(): interfaces.data.IRouteSourceBinding[] | undefined {
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
const standardProfile = resolver?.listProfiles().find((profile: interfaces.data.ISourceProfile) => {
return profile.id.trim().toLowerCase() === 'standard'
|| profile.name.trim().toLowerCase() === 'standard';
});
if (!standardProfile) {
return undefined;
}
return [{
sourceProfileRef: standardProfile.id,
sourceProfileName: standardProfile.name,
}];
}
}
+14 -11
View File
@@ -1,5 +1,5 @@
import * as plugins from '../plugins.js';
import type { IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
import type { IRemoteIngressPerformanceConfig, IRemoteIngressStatus } from '../../ts_interfaces/data/remoteingress.js';
import type { RemoteIngressManager } from './classes.remoteingress-manager.js';
export interface ITunnelManagerConfig {
@@ -9,7 +9,7 @@ export interface ITunnelManagerConfig {
certPem?: string;
keyPem?: string;
};
performance?: import('../../ts_interfaces/data/remoteingress.js').IRemoteIngressPerformanceConfig;
performance?: IRemoteIngressPerformanceConfig;
}
/**
@@ -46,18 +46,20 @@ export class TunnelManager {
this.edgeStatuses.delete(data.edgeId);
});
this.hub.on('streamOpened', (data: { edgeId: string; streamId: number }) => {
this.hub.on('streamSummary', (data: {
edgeId: string;
activeStreams: number;
streamsOpenedTotal: number;
streamsClosedTotal: number;
}) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing) {
existing.activeTunnels++;
existing.activeTunnels = data.activeStreams;
existing.lastHeartbeat = Date.now();
}
});
this.hub.on('streamClosed', (data: { edgeId: string; streamId: number }) => {
const existing = this.edgeStatuses.get(data.edgeId);
if (existing && existing.activeTunnels > 0) {
existing.activeTunnels--;
if (existing.traffic) {
existing.traffic.streamsOpenedTotal = data.streamsOpenedTotal;
existing.traffic.streamsClosedTotal = data.streamsClosedTotal;
}
}
});
}
@@ -73,6 +75,7 @@ export class TunnelManager {
targetHost: this.config.targetHost ?? '127.0.0.1',
tls: this.config.tls,
...(this.config.performance ? { performance: this.config.performance } : {}),
streamEventMode: 'summary',
} as any);
if (this.stopped) return;
+30 -8
View File
@@ -190,7 +190,7 @@ export interface IRoutePathPolicyBinding {
onExceeded?: IRouteSourcePolicyExceededAction;
}
export interface IRouteSourcePolicyBinding {
export interface IRouteSourceBinding {
id?: string;
sourceProfileRef: string;
/** Snapshot of the profile name at resolution time, for display. */
@@ -205,9 +205,13 @@ export interface IRouteSourcePolicyBinding {
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: IRouteSourcePolicyBinding[];
bindings: IRouteSourceBinding[];
}
// ============================================================================
@@ -236,14 +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 policy. When present, it supersedes sourceProfileRef. */
sourcePolicy?: IRouteSourcePolicy;
/** 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. */
@@ -285,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).
*/
+7 -6
View File
@@ -22,7 +22,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Export | Purpose |
| --- | --- |
| `data` | Shared runtime-shaped models such as identities, routes, route source policies, 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, ordered source/path policies |
| 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,14 +39,15 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Observability | stats, combined stats, logs, configuration |
| WorkHoster | external app/workhoster route ownership contracts |
## Route Source Policy Contracts
## Route Source Binding Contracts
`data/route-management.ts` exports the source-policy contracts used by the dashboard, API client, and route runtime compiler:
`data/route-management.ts` exports the source-binding contracts used by the dashboard, API client, and route runtime compiler:
- `IRouteSourcePolicy` stores ordered route-level source bindings.
- `IRouteSourcePolicyBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
- `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.
+18 -1
View File
@@ -1,6 +1,6 @@
import * as plugins from '../plugins.js';
import type * as authInterfaces from '../data/auth.js';
import type { IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
import type { IHttpRedirectInfo, IMergedRoute, IRouteWarning, IRouteMetadata } from '../data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
import type { IDcRouterRouteConfig } from '../data/remoteingress.js';
@@ -26,6 +26,23 @@ export interface IReq_GetMergedRoutes extends plugins.typedrequestInterfaces.imp
};
}
/**
* Get derived HTTP-to-HTTPS redirects.
*/
export interface IReq_GetHttpRedirects extends plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetHttpRedirects
> {
method: 'getHttpRedirects';
request: {
identity?: authInterfaces.IIdentity;
apiToken?: string;
};
response: {
redirects: IHttpRedirectInfo[];
};
}
/**
* Create a new route.
*/
+115
View File
@@ -270,6 +270,115 @@ async function seedMissingDefaultSourceProfiles(ctx: {
);
}
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.
*
@@ -379,6 +488,12 @@ export async function createMigrationRunner(
.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;
+1
View File
@@ -43,6 +43,7 @@ The current migration chain covers:
- `systemKey` backfill for persisted config, email, and DNS routes
- source-profile route-security rematerialization for routes with legacy `metadata.sourceProfileRef`
- `seed-missing-default-source-profiles` from `13.40.2` to `13.42.0`, which inserts missing `TRUSTED NETWORKS`, `AI CRAWLERS`, and `PUBLIC` source profiles by name without mutating existing profiles
- `convert-route-access-metadata-to-source-bindings` from `13.42.0` to `13.43.2`, which converts legacy `metadata.sourceProfileRef`, `metadata.sourceProfileName`, and `metadata.sourcePolicy.bindings` to canonical `metadata.sourceBindings[]` and removes legacy access metadata fields
## Migration Rules
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.42.2',
version: '13.43.5',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+32
View File
@@ -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
View File
@@ -1,5 +1,6 @@
export * from './ops-view-network-activity.js';
export * from './ops-view-routes.js';
export * from './ops-view-redirects.js';
export * from './ops-view-sourceprofiles.js';
export * from './ops-view-networktargets.js';
export * from './ops-view-targetprofiles.js';
@@ -0,0 +1,202 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import { type IStatsTile } from '@design.estate/dees-catalog';
import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
declare global {
interface HTMLElementTagNameMap {
'ops-view-redirects': OpsViewRedirects;
}
}
@customElement('ops-view-redirects')
export class OpsViewRedirects extends DeesElement {
@state()
accessor routeState: appstate.IRouteManagementState = appstate.routeManagementStatePart.getState()!;
constructor() {
super();
const routeSub = appstate.routeManagementStatePart.select().subscribe((routeState) => {
this.routeState = routeState;
});
this.rxSubscriptions.push(routeSub);
const loginSub = appstate.loginStatePart
.select((state) => state.isLoggedIn)
.subscribe((isLoggedIn) => {
if (isLoggedIn) {
void this.refreshData();
}
});
this.rxSubscriptions.push(loginSub);
}
async connectedCallback() {
await super.connectedCallback();
await this.refreshData();
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.redirectsContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state p {
margin: 8px 0;
}
`,
];
public render(): TemplateResult {
const redirects = this.routeState.httpRedirects || [];
const activeCount = redirects.filter((redirect) => redirect.status === 'active').length;
const coveredCount = redirects.filter((redirect) => redirect.status === 'covered').length;
const skippedCount = redirects.filter((redirect) => redirect.status === 'skipped').length;
const remoteIngressCount = redirects.filter((redirect) => redirect.remoteIngress).length;
const statsTiles: IStatsTile[] = [
{
id: 'totalRedirects',
title: 'Total Redirects',
type: 'number',
value: redirects.length,
icon: 'lucide:CornerDownRight',
description: 'Derived HTTP to HTTPS scopes',
color: '#3b82f6',
},
{
id: 'activeRedirects',
title: 'Active',
type: 'number',
value: activeCount,
icon: 'lucide:CircleCheck',
description: 'Generated at runtime',
color: '#22c55e',
},
{
id: 'coveredRedirects',
title: 'Covered',
type: 'number',
value: coveredCount,
icon: 'lucide:ShieldCheck',
description: 'Handled by explicit HTTP routes',
color: '#8b5cf6',
},
{
id: 'skippedRedirects',
title: 'Skipped',
type: 'number',
value: skippedCount,
icon: 'lucide:AlertTriangle',
description: 'Overlaps explicit HTTP routes',
color: skippedCount > 0 ? '#f59e0b' : '#6b7280',
},
{
id: 'remoteIngressRedirects',
title: 'Remote Ingress',
type: 'number',
value: remoteIngressCount,
icon: 'lucide:Globe',
description: 'Also exposed to edge nodes',
color: '#0ea5e9',
},
];
return html`
<dees-heading level="3">Redirects</dees-heading>
<div class="redirectsContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
${redirects.length > 0
? html`
<dees-table
.heading1=${'HTTP to HTTPS Redirects'}
.heading2=${'Runtime redirects derived from enabled HTTPS routes'}
.data=${redirects}
.showColumnFilters=${true}
.displayFunction=${(redirect: interfaces.data.IHttpRedirectInfo) => ({
Status: this.formatStatus(redirect.status),
'Domain Pattern': redirect.domainPattern,
Path: redirect.pathPattern || '*',
From: this.formatHttpTemplate(redirect, 'http'),
To: this.formatHttpTemplate(redirect, 'https'),
Code: redirect.statusCode,
Priority: redirect.priority,
'Source HTTPS Route': redirect.sourceRouteNames.join(', ') || '-',
'Covered By': redirect.coveredByRouteNames.join(', ') || '-',
Notes: this.formatNotes(redirect),
})}
.dataActions=${[
{
name: 'Refresh',
iconName: 'lucide:RefreshCw',
type: ['header' as const],
actionFunc: async () => this.refreshData(),
},
]}
></dees-table>
`
: html`
<dees-table
.heading1=${'HTTP to HTTPS Redirects'}
.heading2=${'Runtime redirects derived from enabled HTTPS routes'}
.data=${[]}
.displayFunction=${() => ({})}
.dataActions=${[
{
name: 'Refresh',
iconName: 'lucide:RefreshCw',
type: ['header' as const],
actionFunc: async () => this.refreshData(),
},
]}
></dees-table>
<div class="empty-state">
<p>No derived redirects</p>
<p>Enable HTTPS routes with explicit domains to generate HTTP to HTTPS redirects.</p>
</div>
`}
</div>
`;
}
private async refreshData(): Promise<void> {
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchHttpRedirectsAction, null);
}
private formatStatus(status: interfaces.data.THttpRedirectStatus): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
private formatHttpTemplate(redirect: interfaces.data.IHttpRedirectInfo, protocol: 'http' | 'https'): string {
return `${protocol}://${redirect.domainPattern}${redirect.pathPattern || '{path}'}`;
}
private formatNotes(redirect: interfaces.data.IHttpRedirectInfo): string {
const notes = redirect.notes ? [redirect.notes] : [];
if (redirect.remoteIngress) {
notes.push('Remote Ingress enabled');
}
return notes.join(' ') || 'Generated from HTTPS route';
}
}
+113 -136
View File
@@ -24,10 +24,7 @@ const tlsCertOptions = [
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
{ key: 'custom', option: 'Custom certificate' },
];
const sourcePolicyPresetOptions = [
{ key: 'manual', option: 'Manual source policy' },
{ key: 'gitea', option: 'Gitea bot protection' },
];
const maxSourceBindingRows = 16;
const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
@@ -38,10 +35,10 @@ function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[] {
function getSourceBindingRefsFromFormData(formData: Record<string, any>): string[] {
const refs: string[] = [];
for (let index = 0; index < 4; index++) {
const ref = getDropdownKey(formData[`sourcePolicyProfileRef${index}`]);
for (let index = 0; index < maxSourceBindingRows; index++) {
const ref = getDropdownKey(formData[`sourceBindingProfileRef${index}`]);
if (ref && !refs.includes(ref)) {
refs.push(ref);
}
@@ -49,25 +46,23 @@ function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[
return refs;
}
function buildSourcePolicyMetadata(
function buildSourceBindingsMetadata(
profileRefs: string[],
existingSourcePolicy?: interfaces.data.IRouteSourcePolicy,
): interfaces.data.IRouteSourcePolicy {
return {
bindings: profileRefs.map((sourceProfileRef) => {
const existingBinding = existingSourcePolicy?.bindings.find((binding) => binding.sourceProfileRef === sourceProfileRef);
return existingBinding
? {
...existingBinding,
sourceProfileRef,
onExceeded: existingBinding.onExceeded || { type: '429' as const },
}
: {
sourceProfileRef,
onExceeded: { type: '429' as const },
};
}),
};
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[]): {
@@ -87,56 +82,54 @@ function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]):
return { refs, missingNames };
}
function buildGiteaSourcePolicyMetadata(profileRefs: string[]): interfaces.data.IRouteSourcePolicy {
function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.data.IRouteSourceBinding[] {
const [trustedRef, aiRef, publicRef] = profileRefs;
return {
bindings: [
{
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) },
],
},
],
};
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 getGiteaPresetSourcePolicy(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourcePolicy | null {
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 (!validateSourcePolicySelection(refs, profiles)) {
if (!validateSourceBindingSelection(refs, profiles)) {
return null;
}
return buildGiteaSourcePolicyMetadata(refs);
return buildGiteaSourceBindingsMetadata(refs);
}
function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
return Boolean(metadata?.sourcePolicy?.bindings.some((binding) => binding.pathPolicies?.length));
return Boolean(metadata?.sourceBindings?.some((binding) => binding.pathPolicies?.length));
}
function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
@@ -153,7 +146,7 @@ function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile):
});
}
function validateSourcePolicySelection(
function validateSourceBindingSelection(
profileRefs: string[],
profiles: interfaces.data.ISourceProfile[],
): boolean {
@@ -176,19 +169,14 @@ function validateSourcePolicySelection(
return false;
}
const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
if (!sourceProfileMatchesAll(fallbackProfile)) {
alert('Source policy needs an explicit public/wildcard fallback profile as the last binding. Add a profile with IP Allow List "*".');
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;
}
if (fallbackProfile.security?.rateLimit?.enabled !== true) {
return confirm(`The fallback profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
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;
@@ -293,6 +281,7 @@ export class OpsViewRoutes extends DeesElement {
@state() accessor routeState: appstate.IRouteManagementState = {
mergedRoutes: [],
warnings: [],
httpRedirects: [],
apiTokens: [],
gatewayClients: [],
isLoading: false,
@@ -520,7 +509,7 @@ export class OpsViewRoutes extends DeesElement {
const meta = merged.metadata;
const isSystemManaged = this.isSystemManagedRoute(merged);
const sourcePolicySummary = this.describeSourcePolicy(meta);
const sourceBindingSummary = this.describeSourcePolicy(meta);
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
@@ -530,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>` : ''}
${sourcePolicySummary ? html`<p>Source Policy: <strong style="color: #a78bfa;">${sourcePolicySummary}</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>
`,
@@ -662,8 +651,7 @@ export class OpsViewRoutes extends DeesElement {
const currentVpnOnly = route.vpnOnly === true;
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
const currentSourcePolicyRefs = this.getSourcePolicyRefs(merged.metadata);
const currentSourcePolicyPreset = metadataUsesPathPolicies(merged.metadata) ? 'gitea' : 'manual';
const currentSourceBindingRefs = this.getSourceBindingRefs(merged.metadata);
// Compute current TLS state for pre-population
const currentTls = (route.action as any).tls;
@@ -685,21 +673,20 @@ export class OpsViewRoutes extends DeesElement {
<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>
<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 Policy</strong>
<small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
<dees-input-dropdown
.key=${'sourcePolicyPreset'}
.label=${'Source Policy Preset'}
.options=${sourcePolicyPresetOptions}
.selectedOption=${sourcePolicyPresetOptions.find((o) => o.key === currentSourcePolicyPreset) || sourcePolicyPresetOptions[0]}
></dees-input-dropdown>
<small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
${[0, 1, 2, 3].map((index) => html`
<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=${`sourcePolicyProfileRef${index}`}
.label=${`Source Profile ${index + 1}`}
.key=${`sourceBindingProfileRef${index}`}
.label=${`Binding ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions.find((o) => o.key === (currentSourcePolicyRefs[index] || '')) || profileOptions[0]}
.selectedOption=${profileOptions.find((o) => o.key === (currentSourceBindingRefs[index] || '')) || profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
@@ -743,11 +730,11 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
const sourceBindingRefs = useGiteaTemplate
? []
: getSourcePolicyRefsFromFormData(formData);
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
: getSourceBindingRefsFromFormData(formData);
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -811,20 +798,14 @@ export class OpsViewRoutes extends DeesElement {
}
const metadata: any = {};
if (sourcePolicyPreset === 'gitea') {
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
if (!sourcePolicy) return;
metadata.sourcePolicy = sourcePolicy;
metadata.sourceProfileRef = '';
metadata.sourceProfileName = '';
} else if (sourcePolicyRefs.length > 0) {
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs, merged.metadata?.sourcePolicy);
metadata.sourceProfileRef = '';
metadata.sourceProfileName = '';
} else if (merged.metadata?.sourcePolicy || merged.metadata?.sourceProfileRef) {
metadata.sourcePolicy = { bindings: [] };
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;
@@ -885,19 +866,18 @@ export class OpsViewRoutes extends DeesElement {
<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>
<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 Policy</strong>
<small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
<dees-input-dropdown
.key=${'sourcePolicyPreset'}
.label=${'Source Policy Preset'}
.options=${sourcePolicyPresetOptions}
.selectedOption=${sourcePolicyPresetOptions[0]}
></dees-input-dropdown>
<small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
${[0, 1, 2, 3].map((index) => html`
<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=${`sourcePolicyProfileRef${index}`}
.label=${`Source Profile ${index + 1}`}
.key=${`sourceBindingProfileRef${index}`}
.label=${`Binding ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions[0]}
></dees-input-dropdown>
@@ -943,11 +923,11 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
const sourceBindingRefs = useGiteaTemplate
? []
: getSourcePolicyRefsFromFormData(formData);
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
: getSourceBindingRefsFromFormData(formData);
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -1012,12 +992,12 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected
const metadata: any = {};
if (sourcePolicyPreset === 'gitea') {
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
if (!sourcePolicy) return;
metadata.sourcePolicy = sourcePolicy;
} else if (sourcePolicyRefs.length > 0) {
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs);
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;
@@ -1048,23 +1028,20 @@ export class OpsViewRoutes extends DeesElement {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
private getSourcePolicyRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
const policyRefs = metadata?.sourcePolicy?.bindings
private getSourceBindingRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
const bindingRefs = metadata?.sourceBindings
?.map((binding) => binding.sourceProfileRef)
.filter(Boolean) || [];
if (policyRefs.length > 0) {
return policyRefs;
}
return metadata?.sourceProfileRef ? [metadata.sourceProfileRef] : [];
return bindingRefs;
}
private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string {
const refs = this.getSourcePolicyRefs(metadata);
const refs = this.getSourceBindingRefs(metadata);
if (refs.length === 0) {
return '';
}
return refs.map((ref) => {
const binding = metadata?.sourcePolicy?.bindings?.find((item) => item.sourceProfileRef === 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(' → ');
+2
View File
@@ -23,6 +23,7 @@ import { OpsViewConfig } from './overview/ops-view-config.js';
// Network group
import { OpsViewNetworkActivity } from './network/ops-view-network-activity.js';
import { OpsViewRoutes } from './network/ops-view-routes.js';
import { OpsViewRedirects } from './network/ops-view-redirects.js';
import { OpsViewSourceProfiles } from './network/ops-view-sourceprofiles.js';
import { OpsViewNetworkTargets } from './network/ops-view-networktargets.js';
import { OpsViewTargetProfiles } from './network/ops-view-targetprofiles.js';
@@ -100,6 +101,7 @@ export class OpsDashboard extends DeesElement {
subViews: [
{ slug: 'activity', name: 'Network Activity', iconName: 'lucide:activity', element: OpsViewNetworkActivity },
{ slug: 'routes', name: 'Routes', iconName: 'lucide:route', element: OpsViewRoutes },
{ slug: 'redirects', name: 'Redirects', iconName: 'lucide:CornerDownRight', element: OpsViewRedirects },
{ slug: 'sourceprofiles', name: 'Source Profiles', iconName: 'lucide:shieldCheck', element: OpsViewSourceProfiles },
{ slug: 'networktargets', name: 'Network Targets', iconName: 'lucide:server', element: OpsViewNetworkTargets },
{ slug: 'targetprofiles', name: 'Target Profiles', iconName: 'lucide:target', element: OpsViewTargetProfiles },
+1 -1
View File
@@ -9,7 +9,7 @@ const flatViews = ['logs'] as const;
// Tabbed views and their valid subviews
const subviewMap: Record<string, readonly string[]> = {
overview: ['stats', 'configuration'] as const,
network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
network: ['activity', 'routes', 'redirects', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
email: ['log', 'security', 'domains'] as const,
access: ['gatewayclients', 'apitokens', 'users'] as const,
security: ['overview', 'blocked', 'authentication'] as const,