Compare commits

..

6 Commits

Author SHA1 Message Date
jkunz b55d2ac61d v13.42.2
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m1s
2026-06-02 14:11:18 +00:00
jkunz c88e8e1758 fix(dev-deps): bump @git.zone/tsdocker to ^2.4.1 2026-06-02 14:10:49 +00:00
jkunz 6ee716e4ef v13.42.1
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 6m6s
2026-06-02 12:48:16 +00:00
jkunz 1d4ed9af2c fix(deps): bump @serve.zone/remoteingress to ^4.22.5 2026-06-02 12:47:53 +00:00
jkunz d2331fdcbe v13.42.0
Docker (tags) / release (push) Failing after 1s
Release / build-and-release (push) Successful in 7m27s
2026-06-02 00:29:38 +00:00
jkunz 0e7765c740 feat(source-policy): add ordered route source policies with Gitea preset support 2026-06-02 00:29:13 +00:00
28 changed files with 2874 additions and 89 deletions
+28
View File
@@ -3,6 +3,34 @@
## Pending
## 2026-06-02 - 13.42.2
### Fixes
- bump @git.zone/tsdocker to ^2.4.1 (dev-deps)
- Updated @git.zone/tsdocker from ^2.4.0 to ^2.4.1.
## 2026-06-02 - 13.42.1
### Fixes
- bump @serve.zone/remoteingress to ^4.22.5 (deps)
- Updates @serve.zone/remoteingress from ^4.22.4 to ^4.22.5.
## 2026-06-02 - 13.42.0
### Features
- add ordered route source policies with Gitea preset support (source-policy)
- Compile metadata.sourcePolicy bindings into SmartProxy route variants with ordered source matching, path-class overrides, and terminal 429 rate/connection limit handling
- Add shared source-policy interfaces, Gitea path-class patterns, validation limits, and resolver support for policy-backed profile usage and display names
- Add Ops UI controls for manual and Gitea source-policy presets plus rate-limit editing for source profiles
- Seed TRUSTED NETWORKS, AI CRAWLERS, and PUBLIC default profiles through defaults and the 13.42.0 migration
- Bump smartproxy to ^27.12.4 and add coverage for source-policy compilation, rate-limit behavior, migrations, and port-safe server tests
## 2026-06-01 - 13.41.2
### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/dcrouter",
"version": "13.41.2",
"version": "13.42.2",
"exports": "./binary/dcrouter.ts",
"compile": {
"include": [
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@serve.zone/dcrouter",
"private": false,
"version": "13.41.2",
"version": "13.42.2",
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
"type": "module",
"bin": {
@@ -28,8 +28,8 @@
"devDependencies": {
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdocker": "^2.4.0",
"@git.zone/tsdeno": "^1.5.0",
"@git.zone/tsdocker": "^2.4.1",
"@git.zone/tsrun": "^2.0.4",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
@@ -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.3",
"@push.rocks/smartproxy": "^27.12.4",
"@push.rocks/smartradius": "^1.3.0",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
@@ -71,7 +71,7 @@
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/catalog": "^2.12.4",
"@serve.zone/interfaces": "^5.8.0",
"@serve.zone/remoteingress": "^4.22.4",
"@serve.zone/remoteingress": "^4.22.5",
"@tsclass/tsclass": "^9.5.1",
"@types/qrcode": "^1.5.6",
"lru-cache": "^11.4.0",
+15 -15
View File
@@ -84,8 +84,8 @@ importers:
specifier: ^4.2.4
version: 4.2.4
'@push.rocks/smartproxy':
specifier: ^27.12.3
version: 27.12.3
specifier: ^27.12.4
version: 27.12.4
'@push.rocks/smartradius':
specifier: ^1.3.0
version: 1.3.0
@@ -114,8 +114,8 @@ importers:
specifier: ^5.8.0
version: 5.8.0
'@serve.zone/remoteingress':
specifier: ^4.22.4
version: 4.22.4
specifier: ^4.22.5
version: 4.22.5
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
@@ -142,8 +142,8 @@ importers:
specifier: ^1.5.0
version: 1.5.0
'@git.zone/tsdocker':
specifier: ^2.4.0
version: 2.4.0
specifier: ^2.4.1
version: 2.4.1
'@git.zone/tsrun':
specifier: ^2.0.4
version: 2.0.4
@@ -733,8 +733,8 @@ packages:
resolution: {integrity: sha512-OdGPhnBz6v92OkKKWyswpyGman3m3FOXin+9WRzEBvvwyLAAkc2mKUGViPAIxYkrak4GiglzqjTkSyReDU0QOw==}
hasBin: true
'@git.zone/tsdocker@2.4.0':
resolution: {integrity: sha512-GFE93RxFm8HDrSm5Ulggy4se7heb4GaNQgaWV6Mds6lhkm6GouO91xZYlmXVH9glzBoFJNG63pFXYHW6nrqf5A==}
'@git.zone/tsdocker@2.4.1':
resolution: {integrity: sha512-T7c0yf8eUNsT+ZYACv43FZqTY5OvhR+quSrc6+zgvAyzRIN4rG4t5lO0tyXkiakKp9QnIkjnLSPqWqeoP68jzQ==}
hasBin: true
'@git.zone/tspublish@1.11.6':
@@ -1429,8 +1429,8 @@ packages:
'@push.rocks/smartpromise@4.2.4':
resolution: {integrity: sha512-8FUyYt94hOIY9mqHjitn4h69u0jbEtTF2RKKw2DpiTVFjpDTk9gXbVHZ/V+xEcBrN4mrzdQES0OiDmkNPoddEQ==}
'@push.rocks/smartproxy@27.12.3':
resolution: {integrity: sha512-nw5+iYhngwrdmSOg87R1opHVZXdLK4GHm/PAtVSWHD7zlnOPhEvdrlJndAq4ehGktf7z6B0SvwwmdrAOCPhWWw==}
'@push.rocks/smartproxy@27.12.4':
resolution: {integrity: sha512-oqr4IE4hqNOL38RRzCux/dF0PjKjeIf4Z/vbe2JXZUz9ZF2ANNPsLSvs0Z/LFlfsrvV35k0rqeEmBvKIq+1lVQ==}
'@push.rocks/smartpuppeteer@2.0.6':
resolution: {integrity: sha512-G+8cyDERvbXQcb9Sd8lnYdWYz8b3Mv2LfFf1ULmucDqQhcRHvxrWX/dKsvBZrwKPR4Wg+795Dyd+E1iOOh3tHw==}
@@ -1719,8 +1719,8 @@ packages:
'@serve.zone/interfaces@5.8.0':
resolution: {integrity: sha512-0ekSKUL/b44wmmzuCRANzrjaJRAHtkqiL8cPiMASEs7UJBDqbJCrgtrlJK84pz5dxBz3jTcdznNd5qjB8c6H0A==}
'@serve.zone/remoteingress@4.22.4':
resolution: {integrity: sha512-3SPTlFQQlB7ptdUr0TzZJQ1UOppPWcjcffv25qpO64gzw5f5VhmkywN7YQGAeXqCe4UeuRZrxOwZY0m9SpfJzw==}
'@serve.zone/remoteingress@4.22.5':
resolution: {integrity: sha512-P2aQ/0VLPATbIBMYm4DT2XqLnBa15WXB1HcyE7RMVNC5RntGbmfj5FES3R5OgxY1V00E7wTMTCNrkfbKhj5xqQ==}
hasBin: true
'@smithy/chunked-blob-reader-native@4.2.3':
@@ -5264,7 +5264,7 @@ snapshots:
- supports-color
- vue
'@git.zone/tsdocker@2.4.0':
'@git.zone/tsdocker@2.4.1':
dependencies:
'@push.rocks/lik': 6.4.1
'@push.rocks/projectinfo': 5.1.0
@@ -6696,7 +6696,7 @@ snapshots:
'@push.rocks/smartpromise@4.2.4': {}
'@push.rocks/smartproxy@27.12.3':
'@push.rocks/smartproxy@27.12.4':
dependencies:
'@push.rocks/smartcrypto': 2.0.4
'@push.rocks/smartlog': 3.2.2
@@ -7085,7 +7085,7 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/remoteingress@4.22.4':
'@serve.zone/remoteingress@4.22.5':
dependencies:
'@push.rocks/qenv': 6.1.4
'@push.rocks/smartnftables': 1.2.0
+74
View File
@@ -146,6 +146,80 @@ 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
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.
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.
Path policies let a source binding override rate limits or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps `git-smart-http` high-limit and separate from HTML crawling paths so normal `git clone`, `git fetch`, `git push`, and Git LFS traffic are not subject to the lower HTML crawler limits.
```typescript
const trustedProfileId = 'source-profile-id-trusted';
const publicProfileId = 'source-profile-id-public';
const createRoutePayload = {
route: {
name: 'public-gitea',
match: { domains: ['code.example.com'], ports: [443] },
action: {
type: 'forward',
targets: [{ host: '10.10.0.20', port: 3000 }],
tls: { mode: 'terminate', certificate: 'auto' },
},
},
metadata: {
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' },
},
],
},
],
},
},
};
```
## Production-Flavored Example
```typescript
+14 -1
View File
@@ -2,9 +2,21 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as plugins from '../ts/plugins.js';
import * as path from 'path';
import * as fs from 'fs';
import * as net from 'node:net';
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
tap.test('DcRouter class - Custom email port configuration', async () => {
// Define custom port mapping
@@ -115,6 +127,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
});
tap.test('DcRouter class - Email config with domains and routes', async () => {
const opsServerPort = await getFreePort();
// Create a basic email configuration
const emailConfig: IUnifiedEmailServerOptions = {
ports: [2525],
@@ -129,7 +142,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
tls: {
contactEmail: 'test@example.com'
},
opsServerPort: 3104,
opsServerPort,
dbConfig: {
enabled: false,
}
+16 -2
View File
@@ -1,15 +1,29 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/classes.dcrouter.js';
import * as plugins from '../ts/plugins.js';
import * as net from 'node:net';
let dcRouter: DcRouter;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
const opsServerPort = await getFreePort();
dcRouter = new DcRouter({
smartProxyConfig: {
routes: []
},
opsServerPort: 3100,
opsServerPort,
dbConfig: { enabled: false }
});
@@ -146,4 +160,4 @@ tap.test('stop', async () => {
await tap.stopForcefully();
});
export default tap.start();
export default tap.start();
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let identity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3102,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login with admin credentials and receive JWT', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -48,7 +67,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
tap.test('should verify valid JWT identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -68,7 +87,7 @@ tap.test('should verify valid JWT identity', async () => {
tap.test('should reject invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -85,7 +104,7 @@ tap.test('should reject invalid JWT', async () => {
tap.test('should verify JWT matches identity data', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -106,7 +125,7 @@ tap.test('should verify JWT matches identity data', async () => {
tap.test('should handle logout', async () => {
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLogout'
);
@@ -120,7 +139,7 @@ tap.test('should handle logout', async () => {
tap.test('should reject wrong credentials', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3102/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
+46 -3
View File
@@ -52,6 +52,10 @@ function matchesQuery(document: Record<string, any>, query: Record<string, any>)
function createFakeCollection(documents: Array<Record<string, any>> = []) {
return {
findOne: async (query: Record<string, any> = {}) => {
const document = documents.find((candidate) => matchesQuery(candidate, query));
return document ? structuredClone(document) : null;
},
find: (query: Record<string, any> = {}) => ({
async *[Symbol.asyncIterator]() {
for (const document of documents) {
@@ -61,6 +65,10 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
}
},
}),
insertOne: async (document: Record<string, any>) => {
documents.push(structuredClone(document));
return { insertedId: document._id || document.id };
},
updateMany: async (query: Record<string, any>, update: any) => {
let modifiedCount = 0;
for (const document of documents) {
@@ -122,12 +130,19 @@ function createFakeDb(
}
tap.test('migration runner applies schema steps through the current target', async () => {
const runner = await createMigrationRunner(createFakeDb('13.16.0'), '13.40.2');
const sourceProfiles: Array<Record<string, any>> = [];
const runner = await createMigrationRunner(
createFakeDb('13.16.0', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
expect(result.currentVersionBefore).toEqual('13.16.0');
expect(result.currentVersionAfter).toEqual('13.40.2');
expect(result.stepsApplied).toHaveLength(3);
expect(result.currentVersionAfter).toEqual('13.42.0');
expect(result.stepsApplied).toHaveLength(4);
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('PUBLIC');
});
tap.test('migration runner rematerializes source-profile-backed route security', async () => {
@@ -179,4 +194,32 @@ tap.test('migration runner rematerializes source-profile-backed route security',
expect(routes[0].metadata.lastResolvedAt).toBeTruthy();
});
tap.test('migration runner seeds only missing default source profiles', async () => {
const sourceProfiles: Array<Record<string, any>> = [
{
id: 'public-profile',
name: 'PUBLIC',
description: 'Existing public profile',
security: { ipAllowList: ['*'] },
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
},
];
const runner = await createMigrationRunner(
createFakeDb('13.40.2', { SourceProfileDoc: sourceProfiles }),
'13.42.0',
);
const result = await runner.run();
const publicProfiles = sourceProfiles.filter((profile) => profile.name === 'PUBLIC');
expect(result.stepsApplied).toHaveLength(1);
expect(sourceProfiles).toHaveLength(3);
expect(publicProfiles).toHaveLength(1);
expect(publicProfiles[0].security.rateLimit).toBeUndefined();
expect(sourceProfiles.map((profile) => profile.name)).toContain('TRUSTED NETWORKS');
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
});
export default tap.start();
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
const testAdminPassword = 'test-admin-password';
let opsServerPort: number;
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function typedRequestUrl(): string {
return `http://127.0.0.1:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3101,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -40,7 +59,7 @@ tap.test('should login as admin', async () => {
tap.test('should respond to health status request', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
@@ -56,7 +75,7 @@ tap.test('should respond to health status request', async () => {
tap.test('should respond to server statistics request', async () => {
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getServerStatistics'
);
@@ -73,7 +92,7 @@ tap.test('should respond to server statistics request', async () => {
tap.test('should respond to configuration request', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getConfiguration'
);
@@ -94,7 +113,7 @@ tap.test('should respond to configuration request', async () => {
tap.test('should handle log retrieval request', async () => {
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getRecentLogs'
);
@@ -111,7 +130,7 @@ tap.test('should handle log retrieval request', async () => {
tap.test('should reject unauthenticated requests', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3101/typedrequest',
typedRequestUrl(),
'getHealthStatus'
);
+26 -7
View File
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DcRouter } from '../ts/index.js';
import { TypedRequest } from '@api.global/typedrequest';
import * as interfaces from '../ts_interfaces/index.js';
import * as net from 'node:net';
let testDcRouter: DcRouter;
let adminIdentity: interfaces.data.IIdentity;
let opsServerPort: number;
const testAdminPassword = 'test-admin-password';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
function getTypedRequestUrl(): string {
return `http://localhost:${opsServerPort}/typedrequest`;
}
tap.test('should start DCRouter with OpsServer', async () => {
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
opsServerPort = await getFreePort();
testDcRouter = new DcRouter({
// Minimal config for testing
opsServerPort: 3103,
opsServerPort,
dbConfig: { enabled: false },
});
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
tap.test('should login as admin', async () => {
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'adminLoginWithUsernameAndPassword'
);
@@ -41,7 +60,7 @@ tap.test('should login as admin', async () => {
tap.test('should allow admin to verify identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -56,7 +75,7 @@ tap.test('should allow admin to verify identity', async () => {
tap.test('should reject verify identity without identity', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -71,7 +90,7 @@ tap.test('should reject verify identity without identity', async () => {
tap.test('should reject verify identity with invalid JWT', async () => {
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'verifyIdentity'
);
@@ -91,7 +110,7 @@ tap.test('should reject verify identity with invalid JWT', async () => {
tap.test('should reject protected endpoints without auth', async () => {
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getHealthStatus'
);
@@ -107,7 +126,7 @@ tap.test('should reject protected endpoints without auth', async () => {
tap.test('should allow authenticated access to protected endpoints', async () => {
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
'http://localhost:3103/typedrequest',
getTypedRequestUrl(),
'getConfiguration'
);
+26 -1
View File
@@ -315,11 +315,22 @@ tap.test('should find routes by profile ref (sync)', async () => {
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' }],
},
},
});
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
expect(profileRefs.length).toEqual(2);
expect(profileRefs.length).toEqual(3);
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);
@@ -327,6 +338,20 @@ tap.test('should find routes by profile ref (sync)', async () => {
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 () => {
const storedRoutes = new Map<string, any>();
storedRoutes.set('route-x', {
+296
View File
@@ -0,0 +1,296 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { SmartProxy } from '@push.rocks/smartproxy';
import { Buffer } from 'node:buffer';
import * as http from 'node:http';
import * as net from 'node:net';
async function getFreePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startBackend(
handler: http.RequestListener = (_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
},
): Promise<{ server: http.Server; port: number }> {
const server = http.createServer(handler);
const port = await new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : 0);
});
});
return { server, port };
}
async function closeServer(server: http.Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
async function requestHeaders(
port: number,
path: string,
headers?: Record<string, string>,
): Promise<http.IncomingMessage> {
return await new Promise<http.IncomingMessage>((resolve, reject) => {
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
request.once('error', reject);
});
}
async function readResponseBody(response: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of response) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf8');
}
tap.test('SmartProxy route rateLimit returns 429 after threshold', async () => {
const backend = await startBackend();
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
name: 'rate-limit-smoke',
match: {
ports: proxyPort,
},
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'too many requests',
},
},
},
],
});
try {
await proxy.start();
const firstResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const secondResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
const firstBody = await firstResponse.text();
const secondBody = await secondResponse.text();
expect(firstResponse.status).toEqual(200);
expect(firstBody).toEqual('ok');
expect(secondResponse.status).toEqual(429);
expect(secondBody).toContain('too many requests');
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy rateLimit is terminal and does not fall through to a lower priority route', async () => {
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('limited');
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-rate-limit',
name: 'terminal-rate-limit',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
rateLimit: {
enabled: true,
maxRequests: 1,
window: 60,
keyBy: 'ip',
errorMessage: 'limited route exceeded',
},
},
},
{
id: 'lower-priority-fallback',
name: 'lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
const firstResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const secondResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
const firstBody = await readResponseBody(firstResponse);
const secondBody = await readResponseBody(secondResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody).toContain('limited route exceeded');
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
tap.test('SmartProxy route maxConnections returns 429 when concurrent limit is exceeded', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const backend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('released'));
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'max-connections-smoke',
name: 'max-connections-smoke',
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: backend.port }],
},
security: {
maxConnections: 1,
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold');
secondResponse = await requestHeaders(proxyPort, '/blocked');
expect(firstResponse.statusCode).toEqual(200);
expect(secondResponse.statusCode).toEqual(429);
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
expect(await readResponseBody(firstResponse)).toEqual('released');
expect(secondBody.length > 0).toBeTrue();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(backend.server),
]);
}
});
tap.test('SmartProxy maxConnections is terminal and does not fall through to a lower priority route', async () => {
let firstResponse: http.IncomingMessage | undefined;
let secondResponse: http.IncomingMessage | undefined;
let releaseResponse: (() => void) | undefined;
const releasePromise = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
const limitedBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.flushHeaders();
void releasePromise.then(() => response.end('limited released'));
});
const fallbackBackend = await startBackend((_request, response) => {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('fallback');
});
const proxyPort = await getFreePort();
const proxy = new SmartProxy({
connectionRateLimitPerMinute: 1000,
routes: [
{
id: 'terminal-max-connections',
name: 'terminal-max-connections',
priority: 10,
match: { ports: proxyPort, domains: 'limited.local' },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
},
security: {
maxConnections: 1,
},
},
{
id: 'max-connections-lower-priority-fallback',
name: 'max-connections-lower-priority-fallback',
priority: 0,
match: { ports: proxyPort },
action: {
type: 'forward',
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
},
},
],
});
try {
await proxy.start();
firstResponse = await requestHeaders(proxyPort, '/hold', { host: 'limited.local' });
secondResponse = await requestHeaders(proxyPort, '/blocked', { host: 'limited.local' });
const secondBody = await readResponseBody(secondResponse);
releaseResponse?.();
const firstBody = await readResponseBody(firstResponse);
expect(firstResponse.statusCode).toEqual(200);
expect(firstBody).toEqual('limited released');
expect(secondResponse.statusCode).toEqual(429);
expect(secondBody.includes('fallback')).toBeFalse();
} finally {
releaseResponse?.();
firstResponse?.destroy();
secondResponse?.destroy();
await Promise.allSettled([
proxy.stop(),
closeServer(limitedBackend.server),
closeServer(fallbackBackend.server),
]);
}
});
export default tap.start();
+869
View File
@@ -0,0 +1,869 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
import { RouteConfigManager } from '../ts/config/classes.route-config-manager.js';
import { SourcePolicyCompiler, sourcePolicyLimits } from '../ts/config/classes.source-policy-compiler.js';
import type { ISourceProfile, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
import type { IRouteConfig } from '@push.rocks/smartproxy';
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
(resolver as any).profiles.set(profile.id, profile);
}
function makeRoute(): IRouteConfig {
return {
id: 'route-1',
name: 'gitea',
priority: 10,
match: { ports: 443, domains: 'code.example.com' },
action: { type: 'forward', targets: [{ host: '127.0.0.1', port: 3000 }] },
};
}
function makeProfile(profile: Partial<ISourceProfile> & Pick<ISourceProfile, 'id' | 'name'>): ISourceProfile {
return {
description: '',
security: {},
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
...profile,
};
}
tap.test('source policy compiler expands one route into ordered source variants', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'ai' },
{ sourceProfileRef: 'public' },
],
},
};
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:Trusted');
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(variants[0].security?.ipAllowList).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(30);
expect(variants[2].match.clientIp).toBeUndefined();
expect(variants[2].security?.rateLimit?.maxRequests).toEqual(120);
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
});
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [
{
sourceProfileRef: 'public',
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
onExceeded: { type: '429', errorMessage: 'Slow down' },
},
],
},
};
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
expect(variant.security?.rateLimit?.maxRequests).toEqual(10);
expect(variant.security?.rateLimit?.errorMessage).toEqual('Slow down');
});
tap.test('source policy compiler forces source-policy rate limits to source IP keys', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'header',
headerName: 'x-forwarded-for',
},
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
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' },
},
],
},
],
},
},
resolver,
'route-1',
);
expect(variants).toHaveLength(2);
expect(variants[0].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[0].security?.rateLimit?.headerName).toBeUndefined();
expect(variants[1].security?.rateLimit?.keyBy).toEqual('ip');
expect(variants[1].security?.rateLimit?.headerName).toBeUndefined();
});
tap.test('source policy binding can split Gitea path classes before its fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'ai',
name: 'AI Crawlers',
security: {
ipAllowList: ['203.0.113.0/24'],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
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' },
],
},
},
resolver,
'route-1',
);
expect(variants.length).toEqual(3);
expect(variants[0].name).toEqual('gitea:source:AI Crawlers:path:Git Smart HTTP');
expect(variants[0].match.clientIp).toEqual(['203.0.113.0/24']);
expect(variants[0].match.path).toEqual('/*/*.git/info/refs');
expect(variants[0].security?.rateLimit?.maxRequests).toEqual(600);
expect(variants[1].name).toEqual('gitea:source:AI Crawlers:path:Normal HTML');
expect(variants[1].match.path).toBeUndefined();
expect(variants[1].security?.rateLimit?.maxRequests).toEqual(20);
expect(variants[2].name).toEqual('gitea:source:Public');
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
});
tap.test('source policy compiler uses built-in Gitea path class patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }],
},
],
},
},
resolver,
'route-1',
);
expect(variants.map((variant) => variant.match.path)).toEqual([
'/*/*.git/info/refs',
'/*/*.git/git-upload-pack',
'/*/*.git/git-receive-pack',
'/*/*.git/info/lfs',
'/*/*.git/info/lfs/*',
undefined,
]);
expect(variants[0].id).toEqual('route-1:source:public:path:git-smart-http:1');
expect(variants[5].id).toEqual('route-1:source:public');
expect(variants[0].priority! > variants[5].priority!).toBeTrue();
});
tap.test('source policy compiler fails closed when wildcard binding shadows later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const variants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'public' },
{ sourceProfileRef: 'trusted' },
],
},
},
resolver,
'route-1',
);
expect(variants).toEqual([]);
});
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const pathPolicies = Array.from({ length: sourcePolicyLimits.maxPathPoliciesPerBinding }, (_policy, policyIndex) => ({
pathClass: 'git-smart-http' as const,
pathPatterns: Array.from(
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy },
(_pattern, patternIndex) => `/heavy-${policyIndex}-${patternIndex}`,
),
}));
const metadata: IRouteMetadata = {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public', pathPolicies }],
},
};
expect(SourcePolicyCompiler.validateSourcePolicyShape(metadata.sourcePolicy)).toContain('compiled route variants');
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
});
tap.test('source policy compiler fails closed when configured bindings cannot compile', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'empty-ai' },
],
},
},
resolver,
'route-1',
);
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
makeRoute(),
{
sourcePolicy: {
bindings: [{ sourceProfileRef: 'empty-ai' }],
},
},
undefined,
'route-1',
);
expect(emptyProfileVariants.length).toEqual(0);
expect(missingResolverVariants.length).toEqual(0);
});
tap.test('source policy compiler keeps generated priorities inside SmartProxy bounds', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const route = makeRoute();
route.priority = 10000;
const variants = SourcePolicyCompiler.compileRoute(
route,
{
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'trusted' },
{
sourceProfileRef: 'public',
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
},
],
},
},
resolver,
'route-1',
);
expect(variants.length > 0).toBeTrue();
expect(variants.every((variant) => variant.priority! <= 10000 && variant.priority! >= 0)).toBeTrue();
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
});
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: {
ipAllowList: ['*'],
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [
{ sourceProfileRef: 'trusted' },
{ sourceProfileRef: 'public' },
],
},
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(2);
expect(appliedRoutes[0][0].match.clientIp).toEqual(['10.0.0.0/8']);
expect(appliedRoutes[0][1].match.clientIp).toBeUndefined();
expect(appliedRoutes[0][1].security?.rateLimit?.maxRequests).toEqual(120);
});
tap.test('RouteConfigManager does not apply an uncompiled source-policy route', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: {
ipAllowList: [],
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
},
}));
const appliedRoutes: IRouteConfig[][] = [];
const manager = new RouteConfigManager(
() => ({
updateRoutes: async (routes: IRouteConfig[]) => {
appliedRoutes.push(routes);
},
} as any),
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'empty-ai' }],
},
},
});
await manager.applyRoutes();
expect(appliedRoutes.length).toEqual(1);
expect(appliedRoutes[0].length).toEqual(0);
});
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ 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');
});
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ 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);
});
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'empty-ai',
name: 'Empty AI',
security: { ipAllowList: [] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ 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);
});
tap.test('RouteConfigManager rejects source policies without a final all-source fallback', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ 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');
});
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'trusted',
name: 'Trusted',
security: { ipAllowList: ['10.0.0.0/8'] },
}));
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
},
},
});
const result = await manager.updateRoute('route-1', {
route: {
match: { ports: [{ from: 1, to: 1_000_000_000 }], domains: 'code.example.com' },
} as any,
});
expect(result.success).toBeFalse();
expect(result.message).toContain('compiled route-port variants');
expect(manager.getRoute('route-1')?.route.match.ports).toEqual(443);
});
tap.test('RouteConfigManager rejects negative source-policy maxConnections overrides', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public' }],
},
},
});
const result = await manager.updateRoute('route-1', {
metadata: {
sourcePolicy: {
bindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
},
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('maxConnections');
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].maxConnections).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ 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),
},
},
],
},
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('rate limit error message');
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].rateLimit).toBeUndefined();
});
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
const resolver = new ReferenceResolver();
injectProfile(resolver, makeProfile({
id: 'public',
name: 'Public',
security: { ipAllowList: ['*'] },
}));
const manager = new RouteConfigManager(
() => undefined,
() => ({ enabled: false }),
undefined,
resolver,
);
(manager as any).routes.set('route-1', {
id: 'route-1',
route: makeRoute(),
enabled: true,
createdAt: 1,
updatedAt: 1,
createdBy: 'test',
origin: 'api',
metadata: {
sourcePolicy: {
bindings: [{ 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}`,
),
},
],
},
],
},
},
});
expect(result.success).toBeFalse();
expect(result.message).toContain('path patterns');
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].pathPolicies).toBeUndefined();
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.41.2',
version: '13.42.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+28 -1
View File
@@ -68,11 +68,38 @@ export class DbSeeder {
}
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
{
name: 'TRUSTED NETWORKS',
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
security: {
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
maxConnections: 5000,
},
},
{
name: 'AI CRAWLERS',
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
security: {
ipAllowList: [],
rateLimit: {
enabled: true,
maxRequests: 30,
window: 60,
keyBy: 'ip',
},
},
},
{
name: 'PUBLIC',
description: 'Allow all traffic — no IP restrictions',
description: 'Public fallback source profile with per-IP request limiting',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
},
},
},
{
+94 -14
View File
@@ -7,6 +7,7 @@ import type {
IRouteMetadata,
IRoute,
IRouteSecurity,
IRouteSourcePolicy,
} from '../../ts_interfaces/data/route-management.js';
const MAX_INHERITANCE_DEPTH = 5;
@@ -107,7 +108,7 @@ export class ReferenceResolver {
// If force-deleting with referencing routes, clear refs but keep resolved values
if (affectedIds.length > 0) {
await this.clearProfileRefsOnRoutes(affectedIds);
await this.clearProfileRefsOnRoutes(id, affectedIds, storedRoutes);
logger.log('warn', `Force-deleted profile '${profile.name}'; cleared refs on ${affectedIds.length} route(s)`);
} else {
logger.log('info', `Deleted source profile '${profile.name}' (${id})`);
@@ -131,15 +132,22 @@ export class ReferenceResolver {
return [...this.profiles.values()];
}
public resolveSourceProfileSecurity(profileId: string): IRouteSecurity | null {
const resolvedSecurity = this.resolveSourceProfile(profileId);
return resolvedSecurity ? this.cloneSecurityFields(resolvedSecurity) : null;
}
public getProfileUsage(storedRoutes: Map<string, IRoute>): Map<string, Array<{ id: string; routeName: string }>> {
const usage = new Map<string, Array<{ id: string; routeName: string }>>();
for (const profile of this.profiles.values()) {
usage.set(profile.id, []);
}
for (const [routeId, stored] of storedRoutes) {
const ref = stored.metadata?.sourceProfileRef;
if (ref && usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
const refs = this.getSourceProfileRefsFromMetadata(stored.metadata);
for (const ref of refs) {
if (usage.has(ref)) {
usage.get(ref)!.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
}
return usage;
@@ -151,7 +159,7 @@ export class ReferenceResolver {
): Array<{ id: string; routeName: string }> {
const routes: Array<{ id: string; routeName: string }> = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
routes.push({ id: routeId, routeName: stored.route.name || routeId });
}
}
@@ -290,7 +298,15 @@ export class ReferenceResolver {
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
const resolvedMetadata: IRouteMetadata = { ...metadata };
if (resolvedMetadata.sourceProfileRef) {
if (resolvedMetadata.sourcePolicy?.bindings.length) {
const resolvedSourcePolicy = this.resolveRouteSourcePolicy(resolvedMetadata.sourcePolicy);
if (resolvedSourcePolicy) {
resolvedMetadata.sourcePolicy = resolvedSourcePolicy;
resolvedMetadata.sourceProfileRef = undefined;
resolvedMetadata.sourceProfileName = undefined;
resolvedMetadata.lastResolvedAt = Date.now();
}
} else if (resolvedMetadata.sourceProfileRef) {
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
if (resolvedSecurity) {
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
@@ -336,7 +352,7 @@ export class ReferenceResolver {
public async findRoutesByProfileRef(profileId: string): Promise<string[]> {
const docs = await RouteDoc.findAll();
return docs
.filter((doc) => doc.metadata?.sourceProfileRef === profileId)
.filter((doc) => this.metadataUsesSourceProfile(doc.metadata, profileId))
.map((doc) => doc.id);
}
@@ -350,7 +366,7 @@ export class ReferenceResolver {
public findRoutesByProfileRefSync(profileId: string, storedRoutes: Map<string, IRoute>): string[] {
const ids: string[] = [];
for (const [routeId, stored] of storedRoutes) {
if (stored.metadata?.sourceProfileRef === profileId) {
if (this.metadataUsesSourceProfile(stored.metadata, profileId)) {
ids.push(routeId);
}
}
@@ -371,6 +387,41 @@ export class ReferenceResolver {
// Private: source profile resolution with inheritance
// =========================================================================
private resolveRouteSourcePolicy(sourcePolicy: IRouteSourcePolicy): IRouteSourcePolicy | undefined {
const bindings = sourcePolicy.bindings
.map((binding) => {
const profile = this.profiles.get(binding.sourceProfileRef);
if (!profile) {
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source policy resolution`);
return binding;
}
return {
...binding,
sourceProfileName: profile.name,
};
})
.filter((binding) => binding.sourceProfileRef);
return bindings.length > 0 ? { bindings } : undefined;
}
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
return this.getSourceProfileRefsFromMetadata(metadata).includes(profileId);
}
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
const refs = new Set<string>();
if (metadata?.sourceProfileRef) {
refs.add(metadata.sourceProfileRef);
}
for (const binding of metadata?.sourcePolicy?.bindings || []) {
if (binding.sourceProfileRef) {
refs.add(binding.sourceProfileRef);
}
}
return [...refs];
}
private resolveSourceProfile(
profileId: string,
visited: Set<string> = new Set(),
@@ -550,21 +601,50 @@ export class ReferenceResolver {
// Private: ref cleanup on force-delete
// =========================================================================
private async clearProfileRefsOnRoutes(routeIds: string[]): Promise<void> {
private async clearProfileRefsOnRoutes(
profileId: string,
routeIds: string[],
storedRoutes?: Map<string, IRoute>,
): Promise<void> {
for (const routeId of routeIds) {
const doc = await RouteDoc.findById(routeId);
if (doc?.metadata) {
doc.metadata = {
...doc.metadata,
sourceProfileRef: undefined,
sourceProfileName: undefined,
};
doc.metadata = this.clearSourceProfileFromMetadata(doc.metadata, profileId);
doc.updatedAt = Date.now();
await doc.save();
}
const storedRoute = storedRoutes?.get(routeId);
if (storedRoute?.metadata) {
storedRoute.metadata = this.clearSourceProfileFromMetadata(storedRoute.metadata, profileId);
storedRoute.updatedAt = Date.now();
}
}
}
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
const sourcePolicy = metadata.sourcePolicy?.bindings?.length
? {
bindings: metadata.sourcePolicy.bindings.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,
};
if (!nextMetadata.sourceProfileRef && !nextMetadata.sourcePolicy && !nextMetadata.networkTargetRef) {
nextMetadata.lastResolvedAt = undefined;
}
return nextMetadata;
}
private async clearTargetRefsOnRoutes(routeIds: string[]): Promise<void> {
for (const routeId of routeIds) {
const doc = await RouteDoc.findById(routeId);
+162 -5
View File
@@ -1,15 +1,20 @@
import * as plugins from '../plugins.js';
import { logger } from '../logger.js';
import { RouteDoc } from '../db/index.js';
import { routePathClasses } from '../../ts_interfaces/data/route-management.js';
import type {
IRoute,
IMergedRoute,
IRouteWarning,
IRouteMetadata,
IRoutePathPolicyBinding,
IRouteSourcePolicy,
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';
export type TVpnClientAllowEntry = string | { clientId: string; domains: string[] };
@@ -131,6 +136,10 @@ 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);
}
// Ensure route has a name
if (!route.name) {
@@ -144,6 +153,10 @@ 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 stored: IRoute = {
id,
@@ -174,8 +187,15 @@ 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 previousSourceProfileRef = stored.metadata?.sourceProfileRef;
const previousRoute = structuredClone(stored.route);
const previousMetadata = structuredClone(stored.metadata);
const previousEnabled = stored.enabled;
const isToggleOnlyPatch = patch.enabled !== undefined
&& patch.route === undefined
@@ -234,6 +254,14 @@ export class RouteConfigManager {
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
}
const sourcePolicyValidationError = this.validateSourcePolicy(stored.metadata?.sourcePolicy, stored.route);
if (sourcePolicyValidationError) {
stored.route = previousRoute;
stored.metadata = previousMetadata;
stored.enabled = previousEnabled;
return { success: false, message: sourcePolicyValidationError };
}
stored.updatedAt = Date.now();
await this.persistRoute(stored);
@@ -454,6 +482,7 @@ export class RouteConfigManager {
const normalized: IRouteMetadata = {
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
sourcePolicy: this.normalizeSourcePolicy(metadata.sourcePolicy),
networkTargetRef: normalizeString(metadata.networkTargetRef),
sourceProfileName: normalizeString(metadata.sourceProfileName),
networkTargetName: normalizeString(metadata.networkTargetName),
@@ -482,7 +511,7 @@ export class RouteConfigManager {
if (!normalized.networkTargetRef) {
normalized.networkTargetName = undefined;
}
if (!normalized.sourceProfileRef && !normalized.networkTargetRef) {
if (!normalized.sourceProfileRef && !normalized.sourcePolicy && !normalized.networkTargetRef) {
normalized.lastResolvedAt = undefined;
}
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
@@ -507,6 +536,128 @@ export class RouteConfigManager {
return normalized;
}
private normalizeSourcePolicy(sourcePolicy?: Partial<IRouteSourcePolicy>): IRouteSourcePolicy | undefined {
const bindings = sourcePolicy?.bindings;
if (!Array.isArray(bindings)) {
return undefined;
}
const normalizedBindings: IRouteSourcePolicy['bindings'] = [];
for (const binding of bindings) {
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
? binding.sourceProfileRef.trim()
: '';
if (!sourceProfileRef) {
continue;
}
const normalizedRateLimit = this.normalizeRateLimit(binding.rateLimit);
const normalizedPathPolicies = this.normalizePathPolicies(binding.pathPolicies);
normalizedBindings.push({
...(typeof binding.id === 'string' && binding.id.trim() ? { id: binding.id.trim() } : {}),
sourceProfileRef,
...(typeof binding.sourceProfileName === 'string' && binding.sourceProfileName.trim()
? { sourceProfileName: binding.sourceProfileName.trim() }
: {}),
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
...(typeof binding.maxConnections === 'number' && Number.isFinite(binding.maxConnections) && binding.maxConnections >= 0
? { maxConnections: binding.maxConnections }
: {}),
...(binding.onExceeded?.type === '429'
? {
onExceeded: {
type: '429' as const,
...(typeof binding.onExceeded.errorMessage === 'string' && binding.onExceeded.errorMessage.trim()
? { errorMessage: binding.onExceeded.errorMessage.trim() }
: {}),
},
}
: {}),
...(normalizedPathPolicies ? { pathPolicies: normalizedPathPolicies } : {}),
});
}
return normalizedBindings.length > 0 ? { bindings: normalizedBindings } : undefined;
}
private normalizePathPolicies(
pathPolicies?: IRoutePathPolicyBinding[],
): IRoutePathPolicyBinding[] | undefined {
if (!Array.isArray(pathPolicies)) {
return undefined;
}
const validClasses = new Set<string>(routePathClasses);
const normalizedPathPolicies: IRoutePathPolicyBinding[] = [];
for (const pathPolicy of pathPolicies) {
if (!validClasses.has(pathPolicy.pathClass)) {
continue;
}
const normalizedRateLimit = this.normalizeRateLimit(pathPolicy.rateLimit);
const pathPatterns = Array.isArray(pathPolicy.pathPatterns)
? [...new Set(pathPolicy.pathPatterns
.map((pattern) => typeof pattern === 'string' ? pattern.trim() : '')
.filter(Boolean))]
: undefined;
normalizedPathPolicies.push({
...(typeof pathPolicy.id === 'string' && pathPolicy.id.trim() ? { id: pathPolicy.id.trim() } : {}),
pathClass: pathPolicy.pathClass,
...(pathPatterns?.length ? { pathPatterns } : {}),
...(normalizedRateLimit ? { rateLimit: normalizedRateLimit } : {}),
...(typeof pathPolicy.maxConnections === 'number' && Number.isFinite(pathPolicy.maxConnections) && pathPolicy.maxConnections >= 0
? { maxConnections: pathPolicy.maxConnections }
: {}),
...(pathPolicy.onExceeded?.type === '429'
? {
onExceeded: {
type: '429' as const,
...(typeof pathPolicy.onExceeded.errorMessage === 'string' && pathPolicy.onExceeded.errorMessage.trim()
? { errorMessage: pathPolicy.onExceeded.errorMessage.trim() }
: {}),
},
}
: {}),
});
}
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
}
private validateSourcePolicy(
sourcePolicy: IRouteSourcePolicy | undefined,
route: IDcRouterRouteConfig,
): string | undefined {
const shapeError = SourcePolicyCompiler.validateSourcePolicyShape(sourcePolicy, route);
if (shapeError) {
return shapeError;
}
return SourcePolicyCompiler.validateResolvedSourcePolicy(sourcePolicy, this.referenceResolver);
}
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const maxRequests = Number(rateLimit.maxRequests);
const window = Number(rateLimit.window);
if (!Number.isFinite(maxRequests) || maxRequests < 0 || !Number.isFinite(window) || window < 0) {
return undefined;
}
return {
enabled: rateLimit.enabled !== false,
maxRequests,
window,
keyBy: 'ip',
...(typeof rateLimit.errorMessage === 'string' && rateLimit.errorMessage.trim()
? { errorMessage: rateLimit.errorMessage.trim() }
: {}),
};
}
// =========================================================================
// Private: warnings
// =========================================================================
@@ -569,10 +720,10 @@ export class RouteConfigManager {
const enabledRoutes: plugins.smartproxy.IRouteConfig[] = [];
// Add all enabled routes with HTTP/3 and VPN augmentation
// 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.prepareStoredRouteForApply(route));
enabledRoutes.push(...this.prepareStoredRoutesForApply(route));
}
}
@@ -592,9 +743,15 @@ export class RouteConfigManager {
});
}
private prepareStoredRouteForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig {
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
return this.prepareRouteForApply(hydratedRoute || storedRoute.route, storedRoute.id);
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
hydratedRoute || storedRoute.route,
storedRoute.metadata,
this.referenceResolver,
storedRoute.id,
);
return sourcePolicyRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
}
private prepareRouteForApply(
+614
View File
@@ -0,0 +1,614 @@
import * as plugins from '../plugins.js';
import {
giteaRoutePathClassLabels,
giteaRoutePathClassPatterns,
routePathClasses,
} from '../../ts_interfaces/data/route-management.js';
import type {
IRoutePathPolicyBinding,
IRouteMetadata,
IRouteSecurity,
IRouteSourcePolicy,
IRouteSourcePolicyBinding,
} from '../../ts_interfaces/data/route-management.js';
import type { ReferenceResolver } from './classes.reference-resolver.js';
const MIN_ROUTE_PRIORITY = 0;
const MAX_ROUTE_PRIORITY = 10000;
const SOURCE_PRIORITY_BAND = 0.0008;
const PATH_PRIORITY_BAND = 0.0001;
export const sourcePolicyLimits = {
maxBindings: 16,
maxPathPoliciesPerBinding: 12,
maxPathPatternsPerPolicy: 64,
maxPathPatternLength: 256,
maxPathPatternWildcards: 8,
maxSourceProfileRefLength: 256,
maxIdLength: 128,
maxExceededMessageLength: 512,
maxCompiledVariantsPerRoute: 512,
} as const;
export class SourcePolicyCompiler {
public static compileRoute(
route: plugins.smartproxy.IRouteConfig,
metadata: IRouteMetadata | undefined,
referenceResolver: ReferenceResolver | undefined,
routeId?: string,
): plugins.smartproxy.IRouteConfig[] {
const bindings = metadata?.sourcePolicy?.bindings || [];
if (bindings.length === 0) {
return [route];
}
if (this.validateSourcePolicyShape(metadata?.sourcePolicy, route)) {
return [];
}
if (!referenceResolver) {
return [];
}
if (this.validateResolvedSourcePolicy(metadata?.sourcePolicy, referenceResolver)) {
return [];
}
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
const basePriority = route.priority ?? 0;
bindings.forEach((binding, index) => {
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profile || !profileSecurity) {
return;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return;
}
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
const sourceMatch = this.matchesAllSources(sourceMatches)
? { ...route.match }
: { ...route.match, clientIp: sourceMatches };
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
return;
}
let hasSourceFallback = false;
pathPolicies.forEach((pathPolicy, pathIndex) => {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length === 0) {
hasSourceFallback = true;
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
}));
return;
}
pathPatterns.forEach((pathPattern, pathPatternIndex) => {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
pathPolicy,
pathPattern,
sourcePriority,
routeId,
sourceIndex: index,
pathIndex,
pathPolicyCount: pathPolicies.length,
pathPatternIndex,
pathPatternCount: pathPatterns.length,
}));
});
});
if (!hasSourceFallback) {
compiledRoutes.push(this.buildCompiledRoute({
route,
sourceMatch,
profileName: profile.name,
profileSecurity,
binding,
sourcePriority,
routeId,
sourceIndex: index,
}));
}
});
return compiledRoutes;
}
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
if (!sourcePolicy) {
return undefined;
}
if (!Array.isArray(sourcePolicy.bindings)) {
return 'Source policy bindings must be an array';
}
if (sourcePolicy.bindings.length === 0) {
return undefined;
}
if (sourcePolicy.bindings.length > sourcePolicyLimits.maxBindings) {
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
}
const validClasses = new Set<string>(routePathClasses);
for (const binding of sourcePolicy.bindings) {
if (!binding || typeof binding !== 'object') {
return 'Source policy binding must be an object';
}
if (typeof binding.sourceProfileRef !== 'string') {
return 'Source policy binding requires a source profile';
}
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
return `Source policy source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
}
if (binding.sourceProfileRef.trim().length === 0) {
return 'Source policy binding requires a source profile';
}
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
return `Source policy binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
return 'Source policy maxConnections must be non-negative';
}
const bindingRateLimitError = this.validateRateLimitPayload(binding.rateLimit);
if (bindingRateLimitError) {
return bindingRateLimitError;
}
const bindingMessage = binding.onExceeded?.errorMessage;
if (typeof bindingMessage === 'string' && bindingMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPolicies = binding.pathPolicies;
if (pathPolicies === undefined) {
continue;
}
if (!Array.isArray(pathPolicies)) {
return 'Source policy path policies must be an array';
}
if (pathPolicies.length > sourcePolicyLimits.maxPathPoliciesPerBinding) {
return `Source policy binding exceeds ${sourcePolicyLimits.maxPathPoliciesPerBinding} path policies`;
}
for (const pathPolicy of pathPolicies) {
if (!pathPolicy || typeof pathPolicy !== 'object') {
return 'Source policy path policy must be an object';
}
if (!validClasses.has(pathPolicy.pathClass)) {
return 'Source policy path policy uses an unsupported path class';
}
if (typeof pathPolicy.id === 'string' && pathPolicy.id.length > sourcePolicyLimits.maxIdLength) {
return `Source policy path policy id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
}
if (typeof pathPolicy.maxConnections === 'number' && pathPolicy.maxConnections < 0) {
return 'Source policy path policy maxConnections must be non-negative';
}
const pathRateLimitError = this.validateRateLimitPayload(pathPolicy.rateLimit);
if (pathRateLimitError) {
return pathRateLimitError;
}
const pathMessage = pathPolicy.onExceeded?.errorMessage;
if (typeof pathMessage === 'string' && pathMessage.length > sourcePolicyLimits.maxExceededMessageLength) {
return `Source policy exceeded message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
const pathPatterns = pathPolicy.pathPatterns;
if (pathPatterns === undefined) {
continue;
}
if (!Array.isArray(pathPatterns)) {
return 'Source policy path patterns must be an array';
}
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class exceeds ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
for (const pattern of pathPatterns) {
if (typeof pattern !== 'string') {
return 'Source policy path pattern must be a string';
}
if (pattern.length > sourcePolicyLimits.maxPathPatternLength) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternLength} characters`;
}
const wildcardCount = pattern.split('*').length - 1;
if (wildcardCount > sourcePolicyLimits.maxPathPatternWildcards) {
return `Source policy path pattern exceeds ${sourcePolicyLimits.maxPathPatternWildcards} wildcards`;
}
}
}
}
return undefined;
}
private static validateRateLimitPayload(rateLimit: IRouteSecurity['rateLimit'] | undefined): string | undefined {
if (!rateLimit || typeof rateLimit !== 'object') {
return undefined;
}
const rawRateLimit = rateLimit as unknown as Record<string, unknown>;
for (const key of ['maxRequests', 'window'] as const) {
const value = rawRateLimit[key];
if (typeof value === 'string' && value.length > 32) {
return `Source policy rate limit ${key} exceeds 32 characters`;
}
}
if (
typeof rateLimit.errorMessage === 'string'
&& rateLimit.errorMessage.length > sourcePolicyLimits.maxExceededMessageLength
) {
return `Source policy rate limit error message exceeds ${sourcePolicyLimits.maxExceededMessageLength} characters`;
}
return undefined;
}
public static validateSourcePolicyShape(
sourcePolicy?: IRouteSourcePolicy,
route?: plugins.smartproxy.IRouteConfig,
): string | undefined {
const payloadError = this.validateSourcePolicyPayload(sourcePolicy);
if (payloadError) {
return payloadError;
}
const bindings = sourcePolicy?.bindings || [];
if (bindings.length === 0) {
return undefined;
}
let estimatedCompiledRoutes = 0;
for (const binding of bindings) {
const pathPolicies = binding.pathPolicies || [];
if (pathPolicies.length === 0) {
estimatedCompiledRoutes++;
} else {
let hasSourceFallback = false;
for (const pathPolicy of pathPolicies) {
const pathPatterns = this.getPathPatterns(pathPolicy);
if (pathPatterns.length > sourcePolicyLimits.maxPathPatternsPerPolicy) {
return `Source policy path class expands beyond ${sourcePolicyLimits.maxPathPatternsPerPolicy} path patterns`;
}
if (pathPatterns.length === 0) {
hasSourceFallback = true;
estimatedCompiledRoutes++;
} else {
estimatedCompiledRoutes += pathPatterns.length;
}
}
if (!hasSourceFallback) {
estimatedCompiledRoutes++;
}
}
if (estimatedCompiledRoutes > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route variants`;
}
}
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
}
return undefined;
}
public static validateResolvedSourcePolicy(
sourcePolicy: IRouteSourcePolicy | undefined,
referenceResolver: ReferenceResolver | undefined,
): string | undefined {
const bindings = sourcePolicy?.bindings || [];
if (bindings.length === 0) {
return undefined;
}
if (!referenceResolver) {
return 'Source policy requires source profile resolution';
}
for (let index = 0; index < bindings.length; index++) {
const binding = bindings[index];
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
if (!profile) {
return `Source profile '${binding.sourceProfileRef}' not found`;
}
const profileSecurity = referenceResolver.resolveSourceProfileSecurity(binding.sourceProfileRef);
if (!profileSecurity) {
return `Source profile '${profile.name}' could not be resolved`;
}
const sourceMatches = this.getSourceMatchEntries(profileSecurity);
if (sourceMatches.length === 0) {
return `Source profile '${profile.name}' has no source matches`;
}
const matchesAllSources = this.matchesAllSources(sourceMatches);
if (matchesAllSources && index < bindings.length - 1) {
return 'Wildcard source profile bindings must be last in a source policy';
}
if (index === bindings.length - 1 && !matchesAllSources) {
return 'Source policy must end with an all-source fallback profile';
}
}
return undefined;
}
private static buildCompiledRoute(options: {
route: plugins.smartproxy.IRouteConfig;
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
profileName: string;
profileSecurity: IRouteSecurity;
binding: IRouteSourcePolicyBinding;
pathPolicy?: IRoutePathPolicyBinding;
pathPattern?: string;
sourcePriority: number;
routeId?: string;
sourceIndex: number;
pathIndex?: number;
pathPolicyCount?: number;
pathPatternIndex?: number;
pathPatternCount?: number;
}): plugins.smartproxy.IRouteConfig {
const routeKey = options.route.id || options.routeId || options.route.name || 'route';
const bindingKey = options.binding.id || options.binding.sourceProfileRef || String(options.sourceIndex + 1);
const pathPolicyKey = options.pathPolicy
? options.pathPolicy.id || options.pathPolicy.pathClass
: undefined;
const pathLabel = options.pathPolicy
? giteaRoutePathClassLabels[options.pathPolicy.pathClass]
: undefined;
const pathPatternSuffix = options.pathPatternCount && options.pathPatternCount > 1
? `:${(options.pathPatternIndex || 0) + 1}`
: '';
const pathPriority = options.pathPolicy
? this.calculatePathPriorityOffset(
options.pathPattern,
options.pathIndex || 0,
options.pathPolicyCount || 1,
options.pathPatternIndex || 0,
options.pathPatternCount || 1,
)
: 0;
return {
...options.route,
id: pathPolicyKey
? `${routeKey}:source:${bindingKey}:path:${pathPolicyKey}${pathPatternSuffix}`
: `${routeKey}:source:${bindingKey}`,
name: pathLabel
? `${options.route.name || routeKey}:source:${options.profileName}:path:${pathLabel}${pathPatternSuffix}`
: `${options.route.name || routeKey}:source:${options.profileName}`,
match: options.pathPattern
? { ...options.sourceMatch, path: options.pathPattern }
: { ...options.sourceMatch },
priority: this.clampPriority(options.sourcePriority + pathPriority),
security: this.buildBindingSecurity(
options.route.security,
options.profileSecurity,
options.binding,
options.pathPolicy,
),
};
}
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
const patterns: string[] = pathPolicy.pathPatterns?.length
? pathPolicy.pathPatterns
: giteaRoutePathClassPatterns[pathPolicy.pathClass];
return [...new Set(patterns.map((pattern) => pattern.trim()).filter(Boolean))];
}
private static calculatePathPriorityOffset(
pathPattern: string | undefined,
pathIndex: number,
pathPolicyCount: number,
pathPatternIndex: number,
pathPatternCount: number,
): number {
if (!pathPattern) {
return 0;
}
const pathPolicyOffset = ((pathPolicyCount - pathIndex) / (pathPolicyCount + 1))
* (PATH_PRIORITY_BAND * 0.9);
const pathPatternOffset = ((pathPatternCount - pathPatternIndex) / (pathPatternCount + 1))
* (PATH_PRIORITY_BAND * 0.1 / (pathPolicyCount + 1));
return pathPolicyOffset + pathPatternOffset;
}
private static calculateSourcePriority(
basePriority: number,
sourceIndex: number,
sourceCount: number,
): number {
const safeBasePriority = this.clampPriority(
basePriority,
MIN_ROUTE_PRIORITY,
MAX_ROUTE_PRIORITY - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND,
);
const sourceStep = SOURCE_PRIORITY_BAND / (sourceCount + 1);
return safeBasePriority + ((sourceCount - sourceIndex) * sourceStep);
}
private static clampPriority(
priority: number,
min = MIN_ROUTE_PRIORITY,
max = MAX_ROUTE_PRIORITY,
): number {
if (!Number.isFinite(priority)) {
return min;
}
return Math.min(max, Math.max(min, priority));
}
private static getExpandedPortCount(portRange: plugins.smartproxy.IRouteConfig['match']['ports'] | undefined): number {
if (portRange === undefined) {
return 1;
}
if (typeof portRange === 'number') {
return Number.isFinite(portRange) ? 1 : sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (!Array.isArray(portRange)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
let count = 0;
for (const portEntry of portRange) {
if (typeof portEntry === 'number') {
if (!Number.isFinite(portEntry)) {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
count++;
} else if (
portEntry
&& typeof portEntry === 'object'
&& Number.isFinite(portEntry.from)
&& Number.isFinite(portEntry.to)
&& portEntry.from <= portEntry.to
) {
count += Math.floor(portEntry.to) - Math.floor(portEntry.from) + 1;
} else {
return sourcePolicyLimits.maxCompiledVariantsPerRoute + 1;
}
if (count > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
return count;
}
}
return Math.max(1, count);
}
private static normalizeMaxConnections(value: IRouteSecurity['maxConnections']): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
}
private static forceIpRateLimit(
rateLimit: IRouteSecurity['rateLimit'] | undefined,
): IRouteSecurity['rateLimit'] | undefined {
if (!rateLimit) {
return undefined;
}
const { headerName: _headerName, ...rest } = structuredClone(rateLimit as Record<string, any>);
return {
...rest,
keyBy: 'ip',
} as IRouteSecurity['rateLimit'];
}
private static sanitizeSourcePolicySecurity(security: IRouteSecurity): IRouteSecurity {
const sanitized = structuredClone(security);
const maxConnections = this.normalizeMaxConnections(sanitized.maxConnections);
if (maxConnections === undefined) {
delete sanitized.maxConnections;
} else {
sanitized.maxConnections = maxConnections;
}
if (sanitized.rateLimit) {
sanitized.rateLimit = this.forceIpRateLimit(sanitized.rateLimit);
}
return sanitized;
}
private static isEmptySecurity(security: IRouteSecurity): boolean {
return Object.keys(security).length === 0;
}
private static getSourceMatchEntries(security: IRouteSecurity): string[] {
const entries = security.ipAllowList || [];
const normalizedEntries: string[] = [];
for (const entry of entries) {
const rawEntry = typeof entry === 'string' ? entry : entry.ip;
if (typeof rawEntry !== 'string') continue;
const normalizedEntry = rawEntry.trim();
if (normalizedEntry) {
normalizedEntries.push(normalizedEntry);
}
}
return [...new Set(normalizedEntries)];
}
private static matchesAllSources(sourceMatches: string[]): boolean {
return sourceMatches.includes('*')
|| (sourceMatches.includes('0.0.0.0/0') && sourceMatches.includes('::/0'));
}
private static buildBindingSecurity(
routeSecurity: IRouteSecurity | undefined,
profileSecurity: IRouteSecurity,
binding: IRouteSourcePolicyBinding,
pathPolicy?: IRoutePathPolicyBinding,
): IRouteSecurity | undefined {
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
const sourceSecurity = this.omitSourceMatchFields(profileSecurity);
if (binding.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(binding.rateLimit);
}
if (binding.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(binding.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (binding.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: binding.onExceeded.errorMessage,
};
}
if (pathPolicy?.rateLimit !== undefined) {
sourceSecurity.rateLimit = this.forceIpRateLimit(pathPolicy.rateLimit);
}
if (pathPolicy?.maxConnections !== undefined) {
const maxConnections = this.normalizeMaxConnections(pathPolicy.maxConnections);
if (maxConnections === undefined) {
delete sourceSecurity.maxConnections;
} else {
sourceSecurity.maxConnections = maxConnections;
}
}
if (pathPolicy?.onExceeded?.errorMessage && sourceSecurity.rateLimit) {
sourceSecurity.rateLimit = {
...sourceSecurity.rateLimit,
errorMessage: pathPolicy.onExceeded.errorMessage,
};
}
const mergedSecurity = this.sanitizeSourcePolicySecurity({
...baseSecurity,
...sourceSecurity,
});
return this.isEmptySecurity(mergedSecurity) ? undefined : mergedSecurity;
}
private static omitSourceMatchFields(security: IRouteSecurity): IRouteSecurity {
const { ipAllowList: _ipAllowList, ...controls } = security;
return this.sanitizeSourcePolicySecurity(controls);
}
}
+1
View File
@@ -4,5 +4,6 @@ export { RouteConfigManager } from './classes.route-config-manager.js';
export { ApiTokenManager } from './classes.api-token-manager.js';
export { GatewayClientManager } from './classes.gateway-client-manager.js';
export { ReferenceResolver } from './classes.reference-resolver.js';
export { SourcePolicyCompiler } from './classes.source-policy-compiler.js';
export { DbSeeder } from './classes.db-seeder.js';
export { TargetProfileManager } from './classes.target-profile-manager.js';
+1 -1
View File
@@ -66,7 +66,7 @@ await router.start();
- System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
- API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
- The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
## Use Another Module When...
+108
View File
@@ -104,6 +104,112 @@ export interface ISourceProfile {
createdBy: string;
}
export interface IRouteSourcePolicyExceededAction {
type: '429';
errorMessage?: string;
}
export const routePathClasses = [
'git-smart-http',
'static',
'normal-html',
'expensive-html',
'raw',
'archive',
] as const;
export type TRoutePathClass = typeof routePathClasses[number];
export const giteaRoutePathClassLabels: Record<TRoutePathClass, string> = {
'git-smart-http': 'Git Smart HTTP',
static: 'Static Assets',
'normal-html': 'Normal HTML',
'expensive-html': 'Expensive HTML',
raw: 'Raw Files',
archive: 'Archives',
};
export const giteaRoutePathClassPatterns: Record<TRoutePathClass, string[]> = {
'git-smart-http': [
'/*/*.git/info/refs',
'/*/*.git/git-upload-pack',
'/*/*.git/git-receive-pack',
'/*/*.git/info/lfs',
'/*/*.git/info/lfs/*',
],
static: [
'/assets/*',
'/avatars/*',
'/repo-avatars/*',
'/user/avatar/*',
'/img/*',
'/css/*',
'/js/*',
'/fonts/*',
'/favicon.ico',
],
'normal-html': [],
'expensive-html': [
'/explore/*',
'/issues',
'/issues/*',
'/pulls',
'/pulls/*',
'/search',
'/*/*/commits/*',
'/*/*/graph',
'/*/*/activity',
'/*/*/stars',
'/*/*/forks',
'/*/*/watchers',
'/*/*/issues',
'/*/*/pulls',
'/*/*/projects',
'/*/*/actions',
'/*/*/packages',
],
raw: [
'/*/*/raw/*',
'/*/*/src/*',
],
archive: [
'/*/*/archive/*',
'/*/*/releases/download/*',
],
};
export interface IRoutePathPolicyBinding {
id?: string;
pathClass: TRoutePathClass;
/** Optional custom patterns. When omitted, the Gitea defaults for the class are used. */
pathPatterns?: string[];
/** Optional path-class override for the source binding's rate limit. */
rateLimit?: IRouteSecurity['rateLimit'];
/** Optional path-class override for the source binding's connection limit. */
maxConnections?: IRouteSecurity['maxConnections'];
onExceeded?: IRouteSourcePolicyExceededAction;
}
export interface IRouteSourcePolicyBinding {
id?: string;
sourceProfileRef: string;
/** Snapshot of the profile name at resolution time, for display. */
sourceProfileName?: string;
/** Optional route-level override for the referenced profile's rate limit. */
rateLimit?: IRouteSecurity['rateLimit'];
/** Optional route-level override for the referenced profile's connection limit. */
maxConnections?: IRouteSecurity['maxConnections'];
/** Initial source-policy slice only supports explicit 429 behavior. */
onExceeded?: IRouteSourcePolicyExceededAction;
/** Optional path-class variants inside this source binding. Path-specific variants win over fallback variants. */
pathPolicies?: IRoutePathPolicyBinding[];
}
export interface IRouteSourcePolicy {
/** Ordered source profile bindings. The first matching binding wins. */
bindings: IRouteSourcePolicyBinding[];
}
// ============================================================================
// Network Target Types
// ============================================================================
@@ -132,6 +238,8 @@ export interface INetworkTarget {
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;
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
networkTargetRef?: string;
/** Snapshot of the profile name at resolution time, for display. */
+15 -3
View File
@@ -22,7 +22,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Export | Purpose |
| --- | --- |
| `data` | Shared runtime-shaped models such as identities, routes, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
| `data` | Shared runtime-shaped models such as identities, routes, route source policies, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
@@ -31,7 +31,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
| Area | Examples |
| --- | --- |
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata |
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path policies |
| Access | API tokens, source profiles, target profiles, network targets |
| DNS and domains | DNS providers, domains, DNS records, ACME config |
| Email | email-domain management and email operations |
@@ -39,6 +39,18 @@ 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
`data/route-management.ts` exports the source-policy 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.
- `IRoutePathPolicyBinding` applies path-class-specific overrides within a source binding.
- `IRouteSourcePolicyExceededAction` describes terminal exceeded-limit behavior, currently explicit `429` handling.
- `TRoutePathClass` is the string-union type derived from `routePathClasses`.
- `routePathClasses` lists the supported classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`.
- `giteaRoutePathClassLabels` and `giteaRoutePathClassPatterns` provide the built-in Gitea labels and path patterns, including Git Smart HTTP and Git LFS patterns.
## Raw TypedRequest Example
```typescript
@@ -86,7 +98,7 @@ Useful source entry points:
- `index.ts` exports `data` and `requests` namespaces.
- `data/index.ts` groups shared data models.
- `requests/index.ts` groups TypedRequest contracts.
- `data/route-management.ts` defines route ownership, API token scopes, profiles, and network target shapes.
- `data/route-management.ts` defines route ownership, source/path policies, API token scopes, profiles, and network target shapes.
## License and Legal Information
+81
View File
@@ -21,6 +21,47 @@ export interface IMigrationRunner {
type TMigrationSecurity = Record<string, any>;
const DEFAULT_SOURCE_PROFILES: Array<{
name: string;
description: string;
security: TMigrationSecurity;
}> = [
{
name: 'TRUSTED NETWORKS',
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
security: {
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
maxConnections: 5000,
},
},
{
name: 'AI CRAWLERS',
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
security: {
ipAllowList: [],
rateLimit: {
enabled: true,
maxRequests: 30,
window: 60,
keyBy: 'ip',
},
},
},
{
name: 'PUBLIC',
description: 'Public fallback source profile with per-IP request limiting',
security: {
ipAllowList: ['*'],
rateLimit: {
enabled: true,
maxRequests: 120,
window: 60,
keyBy: 'ip',
},
},
},
];
function mergeMigrationSecurityFields(
base: TMigrationSecurity | undefined,
override: TMigrationSecurity | undefined,
@@ -195,6 +236,40 @@ async function backfillSystemRouteKeys(ctx: {
ctx.log.log('info', `backfill-system-route-keys: migrated ${migrated} route(s)`);
}
async function seedMissingDefaultSourceProfiles(ctx: {
mongo?: { collection: (name: string) => any };
log: { log: (level: 'info', message: string) => void };
}): Promise<void> {
const collection = ctx.mongo!.collection('SourceProfileDoc');
const now = Date.now();
let inserted = 0;
let existing = 0;
for (const profile of DEFAULT_SOURCE_PROFILES) {
const existingProfile = await collection.findOne({ name: profile.name });
if (existingProfile) {
existing++;
continue;
}
await collection.insertOne({
id: globalThis.crypto.randomUUID(),
name: profile.name,
description: profile.description,
security: structuredClone(profile.security),
createdAt: now,
updatedAt: now,
createdBy: 'system',
});
inserted++;
}
ctx.log.log(
'info',
`seed-missing-default-source-profiles: inserted ${inserted}, already present ${existing}`,
);
}
/**
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
*
@@ -298,6 +373,12 @@ export async function createMigrationRunner(
.description('Replace stale route security with resolved source profile security')
.up(async (ctx) => {
await rematerializeSourceProfileRouteSecurity(ctx);
})
.step('seed-missing-default-source-profiles')
.from('13.40.2').to('13.42.0')
.description('Seed missing default source profiles for source-policy presets')
.up(async (ctx) => {
await seedMissingDefaultSourceProfiles(ctx);
});
return migration;
+3 -1
View File
@@ -25,7 +25,7 @@ If you boot `DcRouter`, you usually do not install or call this package directly
```typescript
import { createMigrationRunner } from '@serve.zone/dcrouter-migrations';
const migration = await createMigrationRunner(db, '13.25.0');
const migration = await createMigrationRunner(db, '<current-version>');
const result = await migration.run();
console.log(result.currentVersionBefore, result.currentVersionAfter);
@@ -41,6 +41,8 @@ The current migration chain covers:
- route collection unification from `StoredRouteDoc` to `RouteDoc`
- route `origin` backfill for migrated API routes
- `systemKey` backfill for persisted config, email, and DNS routes
- source-profile route-security rematerialization for routes with legacy `metadata.sourceProfileRef`
- `seed-missing-default-source-profiles` from `13.40.2` to `13.42.0`, which inserts missing `TRUSTED NETWORKS`, `AI CRAWLERS`, and `PUBLIC` source profiles by name without mutating existing profiles
## Migration Rules
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '13.41.2',
version: '13.42.2',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}
+257 -10
View File
@@ -24,11 +24,176 @@ 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 giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
return { enabled: true, maxRequests, window: 60, keyBy: 'ip' };
}
function getDropdownKey(value: any): string {
return typeof value === 'string' ? value : value?.key || '';
}
function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[] {
const refs: string[] = [];
for (let index = 0; index < 4; index++) {
const ref = getDropdownKey(formData[`sourcePolicyProfileRef${index}`]);
if (ref && !refs.includes(ref)) {
refs.push(ref);
}
}
return refs;
}
function buildSourcePolicyMetadata(
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 },
};
}),
};
}
function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
refs: string[];
missingNames: string[];
} {
const refs: string[] = [];
const missingNames: string[] = [];
for (const profileName of giteaSourcePolicyProfileNames) {
const profile = profiles.find((item) => item.name.trim().toUpperCase() === profileName);
if (profile) {
refs.push(profile.id);
} else {
missingNames.push(profileName);
}
}
return { refs, missingNames };
}
function buildGiteaSourcePolicyMetadata(profileRefs: string[]): interfaces.data.IRouteSourcePolicy {
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) },
],
},
],
};
}
function getGiteaPresetSourcePolicy(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourcePolicy | 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)) {
return null;
}
return buildGiteaSourcePolicyMetadata(refs);
}
function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
return Boolean(metadata?.sourcePolicy?.bindings.some((binding) => binding.pathPolicies?.length));
}
function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
return (profile.security?.ipAllowList || []).some((entry) => {
const source = typeof entry === 'string' ? entry : entry.ip;
return ['*', '0.0.0.0/0', '::/0'].includes(source.trim());
});
}
function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile): boolean {
return (profile.security?.ipAllowList || []).some((entry) => {
const source = typeof entry === 'string' ? entry : entry.ip;
return source.trim().length > 0;
});
}
function validateSourcePolicySelection(
profileRefs: string[],
profiles: interfaces.data.ISourceProfile[],
): boolean {
if (profileRefs.length === 0) {
return true;
}
const selectedProfiles = profileRefs
.map((profileRef) => profiles.find((profile) => profile.id === profileRef))
.filter(Boolean) as interfaces.data.ISourceProfile[];
if (selectedProfiles.length !== profileRefs.length) {
alert('One or more selected source profiles could not be found. Refresh profiles and try again.');
return false;
}
const profilesWithoutMatches = selectedProfiles.filter((profile) => !sourceProfileHasSourceMatches(profile));
if (profilesWithoutMatches.length > 0) {
alert(`Source profiles need IP/CIDR match entries before use: ${profilesWithoutMatches.map((profile) => profile.name).join(', ')}`);
return false;
}
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?`);
}
return true;
}
function parseTargetPort(value: any): number | undefined {
const parsed = typeof value === 'number'
? value
@@ -355,6 +520,7 @@ export class OpsViewRoutes extends DeesElement {
const meta = merged.metadata;
const isSystemManaged = this.isSystemManagedRoute(merged);
const sourcePolicySummary = this.describeSourcePolicy(meta);
await DeesModal.createAndShow({
heading: `Route: ${merged.route.name}`,
content: html`
@@ -364,7 +530,7 @@ export class OpsViewRoutes extends DeesElement {
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
<p>ID: <code style="color: #888;">${merged.id}</code></p>
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
${sourcePolicySummary ? html`<p>Source Policy: <strong style="color: #a78bfa;">${sourcePolicySummary}</strong></p>` : ''}
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div>
`,
@@ -496,6 +662,8 @@ 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';
// Compute current TLS state for pre-population
const currentTls = (route.action as any).tls;
@@ -516,7 +684,25 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
<strong>Source 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`
<dees-input-dropdown
.key=${`sourcePolicyProfileRef${index}`}
.label=${`Source Profile ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions.find((o) => o.key === (currentSourcePolicyRefs[index] || '')) || profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
@@ -557,7 +743,11 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
? []
: getSourcePolicyRefsFromFormData(formData);
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -621,9 +811,18 @@ export class OpsViewRoutes extends DeesElement {
}
const metadata: any = {};
if (profileKey) {
metadata.sourceProfileRef = profileKey;
} else if (merged.metadata?.sourceProfileRef) {
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 = '';
}
@@ -685,7 +884,25 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports'} .description=${'Comma-separated, e.g. 80, 443'} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
<strong>Source 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`
<dees-input-dropdown
.key=${`sourcePolicyProfileRef${index}`}
.label=${`Source Profile ${index + 1}`}
.options=${profileOptions}
.selectedOption=${profileOptions[0]}
></dees-input-dropdown>
`)}
</div>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
@@ -726,7 +943,11 @@ export class OpsViewRoutes extends DeesElement {
: [];
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
const profileKey = getDropdownKey(formData.sourceProfileRef);
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
? []
: getSourcePolicyRefsFromFormData(formData);
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
const targetKey = getDropdownKey(formData.networkTargetRef);
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
const targetPort = preserveMatchPort
@@ -791,8 +1012,12 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected
const metadata: any = {};
if (profileKey) {
metadata.sourceProfileRef = profileKey;
if (sourcePolicyPreset === 'gitea') {
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
if (!sourcePolicy) return;
metadata.sourcePolicy = sourcePolicy;
} else if (sourcePolicyRefs.length > 0) {
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs);
}
if (targetKey) {
metadata.networkTargetRef = targetKey;
@@ -823,6 +1048,28 @@ export class OpsViewRoutes extends DeesElement {
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
}
private getSourcePolicyRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
const policyRefs = metadata?.sourcePolicy?.bindings
?.map((binding) => binding.sourceProfileRef)
.filter(Boolean) || [];
if (policyRefs.length > 0) {
return policyRefs;
}
return metadata?.sourceProfileRef ? [metadata.sourceProfileRef] : [];
}
private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string {
const refs = this.getSourcePolicyRefs(metadata);
if (refs.length === 0) {
return '';
}
return refs.map((ref) => {
const binding = metadata?.sourcePolicy?.bindings?.find((item) => item.sourceProfileRef === ref);
const profile = this.profilesTargetsState.profiles.find((item) => item.id === ref);
return binding?.sourceProfileName || profile?.name || ref.slice(0, 8);
}).join(' → ');
}
private findMergedRoute(clickedRoute: { id?: string; name?: string }): interfaces.data.IMergedRoute | undefined {
if (clickedRoute.id) {
const routeById = this.routeState.mergedRoutes.find((mr) => mr.id === clickedRoute.id);
@@ -12,6 +12,33 @@ import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js';
import { type IStatsTile } from '@design.estate/dees-catalog';
function parseOptionalPositiveInteger(value: unknown): number | undefined {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? parseInt(value.trim(), 10)
: Number.NaN;
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function buildRateLimitFromFormData(data: Record<string, any>) {
if (!Boolean(data.rateLimitEnabled)) {
return undefined;
}
const maxRequests = parseOptionalPositiveInteger(data.rateLimitMaxRequests);
const window = parseOptionalPositiveInteger(data.rateLimitWindow);
if (!maxRequests || !window) {
alert('Rate limit requires positive Max Requests and Window values.');
return null;
}
return {
enabled: true,
maxRequests,
window,
keyBy: 'ip' as const,
};
}
declare global {
interface HTMLElementTagNameMap {
'ops-view-sourceprofiles': OpsViewSourceProfiles;
@@ -138,6 +165,9 @@ export class OpsViewSourceProfiles extends DeesElement {
<dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
<dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'}></dees-input-list>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'}></dees-input-text>
<dees-input-checkbox .key=${'rateLimitEnabled'} .label=${'Enable Rate Limit'} .description=${'Per source IP. Exceeded requests receive 429.'} .value=${false}></dees-input-checkbox>
<dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'}></dees-input-text>
<dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'}></dees-input-text>
</dees-form>
`,
menuOptions: [
@@ -150,8 +180,9 @@ export class OpsViewSourceProfiles extends DeesElement {
const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
const rateLimit = buildRateLimitFromFormData(data);
if (rateLimit === null) return;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.createProfileAction, {
name: String(data.name),
@@ -160,6 +191,7 @@ export class OpsViewSourceProfiles extends DeesElement {
...(ipAllowList.length > 0 ? { ipAllowList } : {}),
...(ipBlockList.length > 0 ? { ipBlockList } : {}),
...(maxConnections ? { maxConnections } : {}),
...(rateLimit ? { rateLimit } : {}),
},
});
modalArg.destroy();
@@ -180,6 +212,9 @@ export class OpsViewSourceProfiles extends DeesElement {
<dees-input-list .key=${'ipAllowList'} .label=${'IP Allow List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipAllowList || []}></dees-input-list>
<dees-input-list .key=${'ipBlockList'} .label=${'IP Block List'} .placeholder=${'Add IP or CIDR...'} .value=${profile.security?.ipBlockList || []}></dees-input-list>
<dees-input-text .key=${'maxConnections'} .label=${'Max Connections'} .value=${String(profile.security?.maxConnections || '')}></dees-input-text>
<dees-input-checkbox .key=${'rateLimitEnabled'} .label=${'Enable Rate Limit'} .description=${'Per source IP. Exceeded requests receive 429.'} .value=${profile.security?.rateLimit?.enabled === true}></dees-input-checkbox>
<dees-input-text .key=${'rateLimitMaxRequests'} .label=${'Max Requests'} .description=${'Requests per source IP'} .value=${String(profile.security?.rateLimit?.maxRequests || '')}></dees-input-text>
<dees-input-text .key=${'rateLimitWindow'} .label=${'Window Seconds'} .value=${String(profile.security?.rateLimit?.window || '')}></dees-input-text>
</dees-form>
`,
menuOptions: [
@@ -192,8 +227,9 @@ export class OpsViewSourceProfiles extends DeesElement {
const data = await form.collectFormData();
const ipAllowList: string[] = Array.isArray(data.ipAllowList) ? data.ipAllowList : [];
const ipBlockList: string[] = Array.isArray(data.ipBlockList) ? data.ipBlockList : [];
const parsed = data.maxConnections ? parseInt(String(data.maxConnections), 10) : NaN;
const maxConnections = Number.isNaN(parsed) ? undefined : parsed;
const maxConnections = parseOptionalPositiveInteger(data.maxConnections);
const rateLimit = buildRateLimitFromFormData(data);
if (rateLimit === null) return;
await appstate.profilesTargetsStatePart.dispatchAction(appstate.updateProfileAction, {
id: profile.id,
@@ -203,6 +239,7 @@ export class OpsViewSourceProfiles extends DeesElement {
ipAllowList,
ipBlockList,
...(maxConnections ? { maxConnections } : {}),
...(rateLimit ? { rateLimit } : {}),
},
});
modalArg.destroy();