Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee716e4ef | |||
| 1d4ed9af2c | |||
| d2331fdcbe | |||
| 0e7765c740 |
@@ -3,6 +3,26 @@
|
||||
## Pending
|
||||
|
||||
|
||||
|
||||
|
||||
## 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.41.2",
|
||||
"version": "13.42.1",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.41.2",
|
||||
"version": "13.42.1",
|
||||
"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.0",
|
||||
"@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",
|
||||
|
||||
Generated
+10
-10
@@ -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
|
||||
@@ -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':
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,9 +2,21 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as net from 'node:net';
|
||||
import { DcRouter, type IDcRouterOptions } from '../ts/classes.dcrouter.js';
|
||||
import type { IUnifiedEmailServerOptions } from '@push.rocks/smartmta';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
// Define custom port mapping
|
||||
@@ -115,6 +127,7 @@ tap.test('DcRouter class - Custom email port configuration', async () => {
|
||||
});
|
||||
|
||||
tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
// Create a basic email configuration
|
||||
const emailConfig: IUnifiedEmailServerOptions = {
|
||||
ports: [2525],
|
||||
@@ -129,7 +142,7 @@ tap.test('DcRouter class - Email config with domains and routes', async () => {
|
||||
tls: {
|
||||
contactEmail: 'test@example.com'
|
||||
},
|
||||
opsServerPort: 3104,
|
||||
opsServerPort,
|
||||
dbConfig: {
|
||||
enabled: false,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/classes.dcrouter.js';
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let dcRouter: DcRouter;
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('should NOT instantiate DNS server when dnsNsDomains is not set', async () => {
|
||||
const opsServerPort = await getFreePort();
|
||||
dcRouter = new DcRouter({
|
||||
smartProxyConfig: {
|
||||
routes: []
|
||||
},
|
||||
opsServerPort: 3100,
|
||||
opsServerPort,
|
||||
dbConfig: { enabled: false }
|
||||
});
|
||||
|
||||
@@ -146,4 +160,4 @@ tap.test('stop', async () => {
|
||||
await tap.stopForcefully();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
+26
-7
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let identity: interfaces.data.IIdentity;
|
||||
let opsServerPort: number;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTypedRequestUrl(): string {
|
||||
return `http://localhost:${opsServerPort}/typedrequest`;
|
||||
}
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
opsServerPort = await getFreePort();
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3102,
|
||||
opsServerPort,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
|
||||
tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
@@ -48,7 +67,7 @@ tap.test('should login with admin credentials and receive JWT', async () => {
|
||||
|
||||
tap.test('should verify valid JWT identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -68,7 +87,7 @@ tap.test('should verify valid JWT identity', async () => {
|
||||
|
||||
tap.test('should reject invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -85,7 +104,7 @@ tap.test('should reject invalid JWT', async () => {
|
||||
|
||||
tap.test('should verify JWT matches identity data', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -106,7 +125,7 @@ tap.test('should verify JWT matches identity data', async () => {
|
||||
|
||||
tap.test('should handle logout', async () => {
|
||||
const logoutRequest = new TypedRequest<interfaces.requests.IReq_AdminLogout>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'adminLogout'
|
||||
);
|
||||
|
||||
@@ -120,7 +139,7 @@ tap.test('should handle logout', async () => {
|
||||
|
||||
tap.test('should reject wrong credentials', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3102/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
let opsServerPort: number;
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function typedRequestUrl(): string {
|
||||
return `http://127.0.0.1:${opsServerPort}/typedrequest`;
|
||||
}
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
opsServerPort = await getFreePort();
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3101,
|
||||
opsServerPort,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
typedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
@@ -40,7 +59,7 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
tap.test('should respond to health status request', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
typedRequestUrl(),
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
@@ -56,7 +75,7 @@ tap.test('should respond to health status request', async () => {
|
||||
|
||||
tap.test('should respond to server statistics request', async () => {
|
||||
const statsRequest = new TypedRequest<interfaces.requests.IReq_GetServerStatistics>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
typedRequestUrl(),
|
||||
'getServerStatistics'
|
||||
);
|
||||
|
||||
@@ -73,7 +92,7 @@ tap.test('should respond to server statistics request', async () => {
|
||||
|
||||
tap.test('should respond to configuration request', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
typedRequestUrl(),
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
@@ -94,7 +113,7 @@ tap.test('should respond to configuration request', async () => {
|
||||
|
||||
tap.test('should handle log retrieval request', async () => {
|
||||
const logsRequest = new TypedRequest<interfaces.requests.IReq_GetRecentLogs>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
typedRequestUrl(),
|
||||
'getRecentLogs'
|
||||
);
|
||||
|
||||
@@ -111,7 +130,7 @@ tap.test('should handle log retrieval request', async () => {
|
||||
|
||||
tap.test('should reject unauthenticated requests', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3101/typedrequest',
|
||||
typedRequestUrl(),
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
|
||||
@@ -2,16 +2,35 @@ import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { DcRouter } from '../ts/index.js';
|
||||
import { TypedRequest } from '@api.global/typedrequest';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
import * as net from 'node:net';
|
||||
|
||||
let testDcRouter: DcRouter;
|
||||
let adminIdentity: interfaces.data.IIdentity;
|
||||
let opsServerPort: number;
|
||||
const testAdminPassword = 'test-admin-password';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTypedRequestUrl(): string {
|
||||
return `http://localhost:${opsServerPort}/typedrequest`;
|
||||
}
|
||||
|
||||
tap.test('should start DCRouter with OpsServer', async () => {
|
||||
process.env.DCROUTER_ADMIN_PASSWORD = testAdminPassword;
|
||||
opsServerPort = await getFreePort();
|
||||
testDcRouter = new DcRouter({
|
||||
// Minimal config for testing
|
||||
opsServerPort: 3103,
|
||||
opsServerPort,
|
||||
dbConfig: { enabled: false },
|
||||
});
|
||||
|
||||
@@ -21,7 +40,7 @@ tap.test('should start DCRouter with OpsServer', async () => {
|
||||
|
||||
tap.test('should login as admin', async () => {
|
||||
const loginRequest = new TypedRequest<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'adminLoginWithUsernameAndPassword'
|
||||
);
|
||||
|
||||
@@ -41,7 +60,7 @@ tap.test('should login as admin', async () => {
|
||||
|
||||
tap.test('should allow admin to verify identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -56,7 +75,7 @@ tap.test('should allow admin to verify identity', async () => {
|
||||
|
||||
tap.test('should reject verify identity without identity', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -71,7 +90,7 @@ tap.test('should reject verify identity without identity', async () => {
|
||||
|
||||
tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
const verifyRequest = new TypedRequest<interfaces.requests.IReq_VerifyIdentity>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'verifyIdentity'
|
||||
);
|
||||
|
||||
@@ -91,7 +110,7 @@ tap.test('should reject verify identity with invalid JWT', async () => {
|
||||
|
||||
tap.test('should reject protected endpoints without auth', async () => {
|
||||
const healthRequest = new TypedRequest<interfaces.requests.IReq_GetHealthStatus>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'getHealthStatus'
|
||||
);
|
||||
|
||||
@@ -107,7 +126,7 @@ tap.test('should reject protected endpoints without auth', async () => {
|
||||
|
||||
tap.test('should allow authenticated access to protected endpoints', async () => {
|
||||
const configRequest = new TypedRequest<interfaces.requests.IReq_GetConfiguration>(
|
||||
'http://localhost:3103/typedrequest',
|
||||
getTypedRequestUrl(),
|
||||
'getConfiguration'
|
||||
);
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SmartProxy } from '@push.rocks/smartproxy';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import * as http from 'node:http';
|
||||
import * as net from 'node:net';
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startBackend(
|
||||
handler: http.RequestListener = (_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('ok');
|
||||
},
|
||||
): Promise<{ server: http.Server; port: number }> {
|
||||
const server = http.createServer(handler);
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
resolve(typeof address === 'object' && address ? address.port : 0);
|
||||
});
|
||||
});
|
||||
return { server, port };
|
||||
}
|
||||
|
||||
async function closeServer(server: http.Server): Promise<void> {
|
||||
if (!server.listening) return;
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
async function requestHeaders(
|
||||
port: number,
|
||||
path: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<http.IncomingMessage> {
|
||||
return await new Promise<http.IncomingMessage>((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path, headers, agent: false }, resolve);
|
||||
request.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function readResponseBody(response: http.IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of response) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
tap.test('SmartProxy route rateLimit returns 429 after threshold', async () => {
|
||||
const backend = await startBackend();
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
name: 'rate-limit-smoke',
|
||||
match: {
|
||||
ports: proxyPort,
|
||||
},
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: backend.port }],
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 1,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'too many requests',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
const firstResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
||||
const secondResponse = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
||||
const firstBody = await firstResponse.text();
|
||||
const secondBody = await secondResponse.text();
|
||||
|
||||
expect(firstResponse.status).toEqual(200);
|
||||
expect(firstBody).toEqual('ok');
|
||||
expect(secondResponse.status).toEqual(429);
|
||||
expect(secondBody).toContain('too many requests');
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(backend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SmartProxy rateLimit is terminal and does not fall through to a lower priority route', async () => {
|
||||
const limitedBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('limited');
|
||||
});
|
||||
const fallbackBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('fallback');
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
id: 'terminal-rate-limit',
|
||||
name: 'terminal-rate-limit',
|
||||
priority: 10,
|
||||
match: { ports: proxyPort, domains: 'limited.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
|
||||
},
|
||||
security: {
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 1,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'limited route exceeded',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lower-priority-fallback',
|
||||
name: 'lower-priority-fallback',
|
||||
priority: 0,
|
||||
match: { ports: proxyPort },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
const firstResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
|
||||
const secondResponse = await requestHeaders(proxyPort, '/', { host: 'limited.local' });
|
||||
const firstBody = await readResponseBody(firstResponse);
|
||||
const secondBody = await readResponseBody(secondResponse);
|
||||
|
||||
expect(firstResponse.statusCode).toEqual(200);
|
||||
expect(firstBody).toEqual('limited');
|
||||
expect(secondResponse.statusCode).toEqual(429);
|
||||
expect(secondBody).toContain('limited route exceeded');
|
||||
expect(secondBody.includes('fallback')).toBeFalse();
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(limitedBackend.server),
|
||||
closeServer(fallbackBackend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SmartProxy route maxConnections returns 429 when concurrent limit is exceeded', async () => {
|
||||
let firstResponse: http.IncomingMessage | undefined;
|
||||
let secondResponse: http.IncomingMessage | undefined;
|
||||
let releaseResponse: (() => void) | undefined;
|
||||
const releasePromise = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
const backend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.flushHeaders();
|
||||
void releasePromise.then(() => response.end('released'));
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
id: 'max-connections-smoke',
|
||||
name: 'max-connections-smoke',
|
||||
match: { ports: proxyPort },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: backend.port }],
|
||||
},
|
||||
security: {
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
firstResponse = await requestHeaders(proxyPort, '/hold');
|
||||
secondResponse = await requestHeaders(proxyPort, '/blocked');
|
||||
|
||||
expect(firstResponse.statusCode).toEqual(200);
|
||||
expect(secondResponse.statusCode).toEqual(429);
|
||||
const secondBody = await readResponseBody(secondResponse);
|
||||
releaseResponse?.();
|
||||
expect(await readResponseBody(firstResponse)).toEqual('released');
|
||||
expect(secondBody.length > 0).toBeTrue();
|
||||
} finally {
|
||||
releaseResponse?.();
|
||||
firstResponse?.destroy();
|
||||
secondResponse?.destroy();
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(backend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('SmartProxy maxConnections is terminal and does not fall through to a lower priority route', async () => {
|
||||
let firstResponse: http.IncomingMessage | undefined;
|
||||
let secondResponse: http.IncomingMessage | undefined;
|
||||
let releaseResponse: (() => void) | undefined;
|
||||
const releasePromise = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
const limitedBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.flushHeaders();
|
||||
void releasePromise.then(() => response.end('limited released'));
|
||||
});
|
||||
const fallbackBackend = await startBackend((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('fallback');
|
||||
});
|
||||
const proxyPort = await getFreePort();
|
||||
const proxy = new SmartProxy({
|
||||
connectionRateLimitPerMinute: 1000,
|
||||
routes: [
|
||||
{
|
||||
id: 'terminal-max-connections',
|
||||
name: 'terminal-max-connections',
|
||||
priority: 10,
|
||||
match: { ports: proxyPort, domains: 'limited.local' },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: limitedBackend.port }],
|
||||
},
|
||||
security: {
|
||||
maxConnections: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'max-connections-lower-priority-fallback',
|
||||
name: 'max-connections-lower-priority-fallback',
|
||||
priority: 0,
|
||||
match: { ports: proxyPort },
|
||||
action: {
|
||||
type: 'forward',
|
||||
targets: [{ host: '127.0.0.1', port: fallbackBackend.port }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await proxy.start();
|
||||
firstResponse = await requestHeaders(proxyPort, '/hold', { host: 'limited.local' });
|
||||
secondResponse = await requestHeaders(proxyPort, '/blocked', { host: 'limited.local' });
|
||||
const secondBody = await readResponseBody(secondResponse);
|
||||
releaseResponse?.();
|
||||
const firstBody = await readResponseBody(firstResponse);
|
||||
|
||||
expect(firstResponse.statusCode).toEqual(200);
|
||||
expect(firstBody).toEqual('limited released');
|
||||
expect(secondResponse.statusCode).toEqual(429);
|
||||
expect(secondBody.includes('fallback')).toBeFalse();
|
||||
} finally {
|
||||
releaseResponse?.();
|
||||
firstResponse?.destroy();
|
||||
secondResponse?.destroy();
|
||||
await Promise.allSettled([
|
||||
proxy.stop(),
|
||||
closeServer(limitedBackend.server),
|
||||
closeServer(fallbackBackend.server),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,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();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.41.2',
|
||||
version: '13.42.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -68,11 +68,38 @@ export class DbSeeder {
|
||||
}
|
||||
|
||||
const DEFAULT_PROFILES: Array<NonNullable<ISeedData['profiles']>[number]> = [
|
||||
{
|
||||
name: 'TRUSTED NETWORKS',
|
||||
description: 'Trusted office, VPN, localhost, and private-network sources with high connection allowance',
|
||||
security: {
|
||||
ipAllowList: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '127.0.0.1', '::1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AI CRAWLERS',
|
||||
description: 'Add verified crawler CIDRs before assigning this profile in a source policy',
|
||||
security: {
|
||||
ipAllowList: [],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 30,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PUBLIC',
|
||||
description: 'Allow all traffic — no IP restrictions',
|
||||
description: 'Public fallback source profile with per-IP request limiting',
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 120,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
IRouteMetadata,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -66,7 +66,7 @@ await router.start();
|
||||
- System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
|
||||
- API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
|
||||
- Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
|
||||
- `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
|
||||
- The published package exposes the `dcrouter` npm bin through `./cli.js`; `runCli()` is the supported code-level bootstrap entrypoint.
|
||||
|
||||
## Use Another Module When...
|
||||
|
||||
|
||||
@@ -104,6 +104,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
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.41.2',
|
||||
version: '13.42.1',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user