Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c08384df0 | |||
| 9286f56316 |
+6
-4
@@ -3,12 +3,14 @@
|
||||
## Pending
|
||||
|
||||
|
||||
## 2026-06-03 - 13.43.2
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- enforce canonical source bindings for route access (route-management)
|
||||
- Convert route access metadata to ordered `metadata.sourceBindings[]` and remove active runtime use of legacy source policy/source profile fields.
|
||||
- Fail closed for managed gateway/workhoster routes without source bindings and add terminal deny fallbacks for private-only bindings.
|
||||
- Add migration coverage, Ops route UI updates, and documentation for the canonical source binding model.
|
||||
|
||||
## 2026-06-03 - 13.43.1
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"version": "13.43.1",
|
||||
"version": "13.43.2",
|
||||
"exports": "./binary/dcrouter.ts",
|
||||
"compile": {
|
||||
"include": [
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@serve.zone/dcrouter",
|
||||
"private": false,
|
||||
"version": "13.43.1",
|
||||
"version": "13.43.2",
|
||||
"description": "A multifaceted routing service handling mail and SMS delivery functions.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -146,19 +146,20 @@ dcrouter keeps generated and operator-created routes separate so automation can
|
||||
|
||||
System routes are persisted with stable `systemKey` values. API-created routes are the editable route layer intended for operators and automation.
|
||||
|
||||
## Route Source Policies
|
||||
## Route Source Bindings
|
||||
|
||||
API-created route records pass `metadata.sourcePolicy` alongside the SmartProxy route config to express ordered source and path policy variants without duplicating whole routes by hand. A source policy contains ordered `bindings`, each pointing at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
|
||||
API-created route records pass ordered `metadata.sourceBindings[]` alongside the SmartProxy route config to express source and path policy variants without duplicating whole routes by hand. Each binding points at a source profile id through `sourceProfileRef`. Dashboard presets resolve seeded profile names to ids before saving.
|
||||
|
||||
Runtime behavior:
|
||||
|
||||
- Source matching uses the referenced `SourceProfile.security.ipAllowList`.
|
||||
- Bindings are evaluated in order and the first matching source profile wins.
|
||||
- A matched binding that exceeds its configured rate or connection limit is terminal and returns `429`; dcrouter does not fall through to later bindings.
|
||||
- Source-policy rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-policy binding and path-policy overrides.
|
||||
- A public fallback binding must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
|
||||
- Create/update paths reject source policies with missing source profiles, source profiles without source matches, missing final all-source fallback, or any all-source binding that shadows later bindings; persisted invalid policies fail closed at compile time.
|
||||
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, and 512 compiled SmartProxy route-port variants per stored route.
|
||||
- Source-binding rate limits are always keyed by source IP; dcrouter ignores `path` and `header` keying on source-binding and path-policy overrides.
|
||||
- Private-only binding lists are valid. dcrouter adds a same-match terminal deny fallback so unmatched sources fail closed.
|
||||
- A public or wildcard binding is optional. When present, it must be last and must use `*`, or both `0.0.0.0/0` and `::/0`, in `security.ipAllowList`.
|
||||
- Create/update paths reject source bindings with missing source profiles, source profiles without source matches, or any all-source binding that shadows later bindings; persisted invalid bindings fail closed at compile time.
|
||||
- Server-side caps bound policy expansion to 16 source bindings, 12 path policies per binding, 64 path patterns per path policy, 256 characters and 8 wildcards per custom path pattern, 512 compiled SmartProxy route-port variants per stored route, and enough priority headroom above the stored route priority for generated source-binding variants.
|
||||
|
||||
Path policies let a source binding override rate limits or connection limits for specific path classes. dcrouter currently ships Gitea-oriented classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`. Path-specific variants win over the same binding's fallback; if every path policy is path-specific, dcrouter adds a source-level fallback route for unmatched paths so normal browsing cannot fall through to a later source binding. The Gitea preset keeps `git-smart-http` high-limit and separate from HTML crawling paths so normal `git clone`, `git fetch`, `git push`, and Git LFS traffic are not subject to the lower HTML crawler limits.
|
||||
|
||||
@@ -177,45 +178,43 @@ const createRoutePayload = {
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: trustedProfileId,
|
||||
maxConnections: 5000,
|
||||
onExceeded: { type: '429' },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicProfileId,
|
||||
onExceeded: { type: '429' },
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'static',
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'raw',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'archive',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'expensive-html',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: trustedProfileId,
|
||||
maxConnections: 5000,
|
||||
onExceeded: { type: '429' },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicProfileId,
|
||||
onExceeded: { type: '429' },
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
rateLimit: { enabled: true, maxRequests: 1200, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'static',
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'raw',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'archive',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'expensive-html',
|
||||
rateLimit: { enabled: true, maxRequests: 30, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 120, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -27,6 +27,24 @@ function applySet(document: Record<string, any>, set: Record<string, unknown>):
|
||||
}
|
||||
}
|
||||
|
||||
function unsetPath(target: Record<string, any>, path: string): void {
|
||||
const parts = path.split('.');
|
||||
let cursor: any = target;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (cursor?.[part] === undefined) return;
|
||||
cursor = cursor[part];
|
||||
}
|
||||
if (cursor && typeof cursor === 'object') {
|
||||
delete cursor[parts[parts.length - 1]];
|
||||
}
|
||||
}
|
||||
|
||||
function applyUnset(document: Record<string, any>, unset: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(unset)) {
|
||||
unsetPath(document, key);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(document: Record<string, any>, query: Record<string, any>): boolean {
|
||||
for (const [key, expected] of Object.entries(query)) {
|
||||
const actual = getPath(document, key);
|
||||
@@ -74,6 +92,7 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
|
||||
for (const document of documents) {
|
||||
if (!matchesQuery(document, query)) continue;
|
||||
applySet(document, update.$set || {});
|
||||
applyUnset(document, update.$unset || {});
|
||||
modifiedCount++;
|
||||
}
|
||||
return { modifiedCount };
|
||||
@@ -82,6 +101,7 @@ function createFakeCollection(documents: Array<Record<string, any>> = []) {
|
||||
const document = documents.find((candidate) => matchesQuery(candidate, query));
|
||||
if (!document) return { matchedCount: 0, modifiedCount: 0, upsertedCount: 0 };
|
||||
applySet(document, update.$set || {});
|
||||
applyUnset(document, update.$unset || {});
|
||||
return { matchedCount: 1, modifiedCount: 1, upsertedCount: 0 };
|
||||
},
|
||||
};
|
||||
@@ -222,4 +242,81 @@ tap.test('migration runner seeds only missing default source profiles', async ()
|
||||
expect(sourceProfiles.map((profile) => profile.name)).toContain('AI CRAWLERS');
|
||||
});
|
||||
|
||||
tap.test('migration runner converts legacy route access metadata to source bindings', async () => {
|
||||
const profiles: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'profile-doc-1',
|
||||
id: 'standard-profile',
|
||||
name: 'Standard',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
},
|
||||
{
|
||||
_id: 'profile-doc-2',
|
||||
id: 'public-profile',
|
||||
name: 'PUBLIC',
|
||||
security: { ipAllowList: ['*'] },
|
||||
},
|
||||
];
|
||||
const routes: Array<Record<string, any>> = [
|
||||
{
|
||||
_id: 'route-doc-1',
|
||||
id: 'route-1',
|
||||
route: {
|
||||
name: 'standard service',
|
||||
match: { ports: 443, domains: ['onebox.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.2', port: 443 }] },
|
||||
security: { ipAllowList: ['10.0.0.0/8'], maxConnections: 1000 },
|
||||
},
|
||||
metadata: {
|
||||
sourceProfileRef: 'standard-profile',
|
||||
sourceProfileName: 'Old Standard Name',
|
||||
},
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
_id: 'route-doc-2',
|
||||
id: 'route-2',
|
||||
route: {
|
||||
name: 'gitea',
|
||||
match: { ports: 443, domains: ['code.example.com'] },
|
||||
action: { type: 'forward', targets: [{ host: '10.0.0.3', port: 3000 }] },
|
||||
security: { basicAuth: { username: 'user', password: 'pass' } },
|
||||
},
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'standard-profile' },
|
||||
{ sourceProfileRef: 'public-profile' },
|
||||
],
|
||||
},
|
||||
},
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const runner = await createMigrationRunner(
|
||||
createFakeDb('13.43.1', {
|
||||
SourceProfileDoc: profiles,
|
||||
RouteDoc: routes,
|
||||
}),
|
||||
'13.43.2',
|
||||
);
|
||||
const result = await runner.run();
|
||||
|
||||
expect(result.stepsApplied).toHaveLength(1);
|
||||
expect(routes[0].metadata.sourceBindings).toEqual([
|
||||
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Old Standard Name' },
|
||||
]);
|
||||
expect(routes[0].metadata.sourceProfileRef).toBeUndefined();
|
||||
expect(routes[0].metadata.sourceProfileName).toBeUndefined();
|
||||
expect(routes[0].metadata.sourcePolicy).toBeUndefined();
|
||||
expect(routes[0].route.security).toBeUndefined();
|
||||
expect(routes[1].metadata.sourceBindings).toEqual([
|
||||
{ sourceProfileRef: 'standard-profile', sourceProfileName: 'Standard' },
|
||||
{ sourceProfileRef: 'public-profile', sourceProfileName: 'PUBLIC' },
|
||||
]);
|
||||
expect(routes[1].metadata.sourcePolicy).toBeUndefined();
|
||||
expect(routes[1].route.security.basicAuth.username).toEqual('user');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
+46
-234
@@ -3,10 +3,6 @@ import { ReferenceResolver } from '../ts/config/classes.reference-resolver.js';
|
||||
import type { ISourceProfile, INetworkTarget, IRouteMetadata } from '../ts_interfaces/data/route-management.js';
|
||||
import type { IRouteConfig } from '@push.rocks/smartproxy';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: access private maps for direct unit testing without DB
|
||||
// ============================================================================
|
||||
|
||||
function injectProfile(resolver: ReferenceResolver, profile: ISourceProfile): void {
|
||||
(resolver as any).profiles.set(profile.id, profile);
|
||||
}
|
||||
@@ -54,10 +50,6 @@ function makeRoute(overrides: Partial<IRouteConfig> = {}): IRouteConfig {
|
||||
} as IRouteConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Resolution tests
|
||||
// ============================================================================
|
||||
|
||||
let resolver: ReferenceResolver;
|
||||
|
||||
tap.test('should create ReferenceResolver instance', async () => {
|
||||
@@ -67,92 +59,43 @@ tap.test('should create ReferenceResolver instance', async () => {
|
||||
|
||||
tap.test('should list empty profiles and targets initially', async () => {
|
||||
expect(resolver.listProfiles()).toBeArray();
|
||||
expect(resolver.listProfiles().length).toEqual(0);
|
||||
expect(resolver.listProfiles()).toHaveLength(0);
|
||||
expect(resolver.listTargets()).toBeArray();
|
||||
expect(resolver.listTargets().length).toEqual(0);
|
||||
expect(resolver.listTargets()).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---- Source profile resolution ----
|
||||
|
||||
tap.test('should resolve source profile onto a route', async () => {
|
||||
tap.test('should resolve source binding display names without materializing route security', async () => {
|
||||
const profile = makeProfile();
|
||||
injectProfile(resolver, profile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
const route = makeRoute({
|
||||
security: { ipAllowList: ['127.0.0.1'], maxConnections: 42 },
|
||||
});
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.route.security!.ipAllowList).toEqual(['127.0.0.1']);
|
||||
expect(result.route.security!.maxConnections).toEqual(42);
|
||||
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should replace inline route security when source profile is selected', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['127.0.0.1'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList!.includes('127.0.0.1')).toBeFalse();
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should remove stale wildcard security from a profile-backed route', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['*'],
|
||||
maxConnections: 5000,
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.security!.ipAllowList!.includes('*')).toBeFalse();
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
});
|
||||
|
||||
tap.test('should deduplicate IP lists during merge', async () => {
|
||||
const route = makeRoute({
|
||||
security: {
|
||||
ipAllowList: ['192.168.0.0/16', '127.0.0.1'],
|
||||
},
|
||||
});
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'profile-1' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// 192.168.0.0/16 appears in both profile and route, should be deduplicated
|
||||
const count = result.route.security!.ipAllowList!.filter(ip => ip === '192.168.0.0/16').length;
|
||||
expect(count).toEqual(1);
|
||||
});
|
||||
|
||||
tap.test('should handle missing profile gracefully', async () => {
|
||||
tap.test('should keep missing source binding refs fail-closed for compiler validation', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'nonexistent-profile' };
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceBindings: [{ sourceProfileRef: 'nonexistent-profile' }],
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be unchanged
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourceProfileName).toBeUndefined();
|
||||
expect(result.metadata.sourceBindings![0].sourceProfileName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Profile inheritance ----
|
||||
|
||||
tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
tap.test('should resolve source profile inheritance for apply-time compiler use', async () => {
|
||||
const baseProfile = makeProfile({
|
||||
id: 'base-profile',
|
||||
name: 'BASE',
|
||||
@@ -173,46 +116,12 @@ tap.test('should resolve profile inheritance (extendsProfiles)', async () => {
|
||||
});
|
||||
injectProfile(resolver, extendedProfile);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'extended-profile' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Should have IPs from both base and extended profiles
|
||||
expect(result.route.security!.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(result.route.security!.ipAllowList).toContain('160.79.104.0/21');
|
||||
// maxConnections from base (extended doesn't override)
|
||||
expect(result.route.security!.maxConnections).toEqual(500);
|
||||
expect(result.metadata.sourceProfileName).toEqual('EXTENDED');
|
||||
const security = resolver.resolveSourceProfileSecurity('extended-profile')!;
|
||||
expect(security.ipAllowList).toContain('10.0.0.0/8');
|
||||
expect(security.ipAllowList).toContain('160.79.104.0/21');
|
||||
expect(security.maxConnections).toEqual(500);
|
||||
});
|
||||
|
||||
tap.test('should detect circular profile inheritance', async () => {
|
||||
const profileA = makeProfile({
|
||||
id: 'circular-a',
|
||||
name: 'A',
|
||||
security: { ipAllowList: ['1.1.1.1'] },
|
||||
extendsProfiles: ['circular-b'],
|
||||
});
|
||||
const profileB = makeProfile({
|
||||
id: 'circular-b',
|
||||
name: 'B',
|
||||
security: { ipAllowList: ['2.2.2.2'] },
|
||||
extendsProfiles: ['circular-a'],
|
||||
});
|
||||
injectProfile(resolver, profileA);
|
||||
injectProfile(resolver, profileB);
|
||||
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { sourceProfileRef: 'circular-a' };
|
||||
|
||||
// Should not infinite loop — resolves what it can
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeTruthy();
|
||||
expect(result.route.security!.ipAllowList).toContain('1.1.1.1');
|
||||
});
|
||||
|
||||
// ---- Network target resolution ----
|
||||
|
||||
tap.test('should resolve network target onto a route', async () => {
|
||||
const target = makeTarget();
|
||||
injectTarget(resolver, target);
|
||||
@@ -222,86 +131,34 @@ tap.test('should resolve network target onto a route', async () => {
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
expect(result.route.action.targets).toBeTruthy();
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should handle missing target gracefully', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = { networkTargetRef: 'nonexistent-target' };
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route targets should be unchanged (still the placeholder)
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
expect(result.metadata.networkTargetName).toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Combined resolution ----
|
||||
|
||||
tap.test('should resolve both profile and target simultaneously', async () => {
|
||||
tap.test('should resolve source bindings and target references together', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Security from profile
|
||||
expect(result.route.security!.ipAllowList).toContain('192.168.0.0/16');
|
||||
expect(result.route.security!.maxConnections).toEqual(1000);
|
||||
|
||||
// Target from network target
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.route.action.targets![0].host).toEqual('192.168.5.247');
|
||||
expect(result.route.action.targets![0].port).toEqual(443);
|
||||
|
||||
// Both names recorded
|
||||
expect(result.metadata.sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.sourceBindings![0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.networkTargetName).toEqual('INFRA');
|
||||
});
|
||||
|
||||
tap.test('should skip resolution when no metadata refs', async () => {
|
||||
const route = makeRoute({
|
||||
security: { ipAllowList: ['1.2.3.4'] },
|
||||
});
|
||||
const metadata: IRouteMetadata = {};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
|
||||
// Route should be completely unchanged
|
||||
expect(result.route.security!.ipAllowList).toContain('1.2.3.4');
|
||||
expect(result.route.security!.ipAllowList!.length).toEqual(1);
|
||||
expect(result.route.action.targets![0].host).toEqual('placeholder');
|
||||
});
|
||||
|
||||
tap.test('should be idempotent — resolving twice gives same result', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceProfileRef: 'profile-1',
|
||||
networkTargetRef: 'target-1',
|
||||
};
|
||||
|
||||
const first = resolver.resolveRoute(route, metadata);
|
||||
const second = resolver.resolveRoute(first.route, first.metadata);
|
||||
|
||||
expect(second.route.security!.ipAllowList!.length).toEqual(first.route.security!.ipAllowList!.length);
|
||||
expect(second.route.action.targets![0].host).toEqual(first.route.action.targets![0].host);
|
||||
expect(second.route.action.targets![0].port).toEqual(first.route.action.targets![0].port);
|
||||
});
|
||||
|
||||
// ---- Lookup helpers ----
|
||||
|
||||
tap.test('should find routes by profile ref (sync)', async () => {
|
||||
tap.test('should find routes by source binding profile ref only', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-a', {
|
||||
id: 'route-a',
|
||||
route: makeRoute({ name: 'route-a' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
|
||||
});
|
||||
storedRoutes.set('route-b', {
|
||||
id: 'route-b',
|
||||
@@ -313,62 +170,31 @@ tap.test('should find routes by profile ref (sync)', async () => {
|
||||
id: 'route-c',
|
||||
route: makeRoute({ name: 'route-c' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1', networkTargetRef: 'target-1' },
|
||||
});
|
||||
storedRoutes.set('route-d', {
|
||||
id: 'route-d',
|
||||
route: makeRoute({ name: 'route-d' }),
|
||||
enabled: true,
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
networkTargetRef: 'target-1',
|
||||
},
|
||||
});
|
||||
|
||||
const profileRefs = resolver.findRoutesByProfileRefSync('profile-1', storedRoutes);
|
||||
expect(profileRefs.length).toEqual(3);
|
||||
expect(profileRefs).toHaveLength(2);
|
||||
expect(profileRefs).toContain('route-a');
|
||||
expect(profileRefs).toContain('route-c');
|
||||
expect(profileRefs).toContain('route-d');
|
||||
|
||||
const targetRefs = resolver.findRoutesByTargetRefSync('target-1', storedRoutes);
|
||||
expect(targetRefs.length).toEqual(2);
|
||||
expect(targetRefs).toHaveLength(2);
|
||||
expect(targetRefs).toContain('route-b');
|
||||
expect(targetRefs).toContain('route-c');
|
||||
});
|
||||
|
||||
tap.test('should resolve source policy binding display names', async () => {
|
||||
const route = makeRoute();
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'profile-1' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolver.resolveRoute(route, metadata);
|
||||
expect(result.route.security).toBeUndefined();
|
||||
expect(result.metadata.sourcePolicy!.bindings[0].sourceProfileName).toEqual('STANDARD');
|
||||
expect(result.metadata.lastResolvedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
tap.test('should get profile usage for a specific profile ID', async () => {
|
||||
tap.test('should get profile and target usage for specific IDs', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-x', {
|
||||
id: 'route-x',
|
||||
route: makeRoute({ name: 'my-route' }),
|
||||
enabled: true,
|
||||
metadata: { sourceProfileRef: 'profile-1' },
|
||||
metadata: { sourceBindings: [{ sourceProfileRef: 'profile-1' }] },
|
||||
});
|
||||
|
||||
const usage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-x');
|
||||
expect(usage[0].routeName).toEqual('my-route');
|
||||
});
|
||||
|
||||
tap.test('should get target usage for a specific target ID', async () => {
|
||||
const storedRoutes = new Map<string, any>();
|
||||
storedRoutes.set('route-y', {
|
||||
id: 'route-y',
|
||||
route: makeRoute({ name: 'other-route' }),
|
||||
@@ -376,34 +202,20 @@ tap.test('should get target usage for a specific target ID', async () => {
|
||||
metadata: { networkTargetRef: 'target-1' },
|
||||
});
|
||||
|
||||
const usage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||
expect(usage.length).toEqual(1);
|
||||
expect(usage[0].id).toEqual('route-y');
|
||||
expect(usage[0].routeName).toEqual('other-route');
|
||||
const profileUsage = resolver.getProfileUsageForId('profile-1', storedRoutes);
|
||||
expect(profileUsage).toHaveLength(1);
|
||||
expect(profileUsage[0].routeName).toEqual('my-route');
|
||||
|
||||
const targetUsage = resolver.getTargetUsageForId('target-1', storedRoutes);
|
||||
expect(targetUsage).toHaveLength(1);
|
||||
expect(targetUsage[0].routeName).toEqual('other-route');
|
||||
});
|
||||
|
||||
// ---- Profile/target getters ----
|
||||
|
||||
tap.test('should get profile by name', async () => {
|
||||
const profile = resolver.getProfileByName('STANDARD');
|
||||
expect(profile).toBeTruthy();
|
||||
expect(profile!.id).toEqual('profile-1');
|
||||
});
|
||||
|
||||
tap.test('should get target by name', async () => {
|
||||
const target = resolver.getTargetByName('INFRA');
|
||||
expect(target).toBeTruthy();
|
||||
expect(target!.id).toEqual('target-1');
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent profile name', async () => {
|
||||
const profile = resolver.getProfileByName('NONEXISTENT');
|
||||
expect(profile).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('should return undefined for nonexistent target name', async () => {
|
||||
const target = resolver.getTargetByName('NONEXISTENT');
|
||||
expect(target).toBeUndefined();
|
||||
tap.test('should get profiles and targets by name', async () => {
|
||||
expect(resolver.getProfileByName('STANDARD')!.id).toEqual('profile-1');
|
||||
expect(resolver.getTargetByName('INFRA')!.id).toEqual('target-1');
|
||||
expect(resolver.getProfileByName('NONEXISTENT')).toBeUndefined();
|
||||
expect(resolver.getTargetByName('NONEXISTENT')).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -55,13 +55,11 @@ tap.test('source policy compiler expands one route into ordered source variants'
|
||||
}));
|
||||
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'ai' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'ai' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
};
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
||||
@@ -76,7 +74,7 @@ tap.test('source policy compiler expands one route into ordered source variants'
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > variants[2].priority!).toBeTrue();
|
||||
expect(variants.every((variant) => Number.isInteger(variant.priority))).toBeTrue();
|
||||
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority);
|
||||
expect(Math.min(...variants.map((variant) => variant.priority!))).toEqual(makeRoute().priority! + 1);
|
||||
});
|
||||
|
||||
tap.test('source policy binding can override profile rate limit and 429 message', async () => {
|
||||
@@ -91,15 +89,13 @@ tap.test('source policy binding can override profile rate limit and 429 message'
|
||||
}));
|
||||
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
|
||||
onExceeded: { type: '429', errorMessage: 'Slow down' },
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: { enabled: true, maxRequests: 10, window: 60, keyBy: 'ip' },
|
||||
onExceeded: { type: '429', errorMessage: 'Slow down' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const [variant] = SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1');
|
||||
@@ -128,27 +124,25 @@ tap.test('source policy compiler forces source-policy rate limits to source IP k
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'header',
|
||||
headerName: 'x-client-id',
|
||||
},
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/git'],
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
|
||||
},
|
||||
],
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'header',
|
||||
headerName: 'x-client-id',
|
||||
},
|
||||
],
|
||||
},
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/git'],
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'path' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -183,25 +177,23 @@ tap.test('source policy binding can split Gitea path classes before its fallback
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'ai',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'ai',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -234,14 +226,12 @@ tap.test('source policy compiler uses built-in Gitea path class patterns', async
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -274,24 +264,22 @@ tap.test('source policy compiler keeps path-specific variants above fallback var
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'normal-html',
|
||||
rateLimit: { enabled: true, maxRequests: 20, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: ['/*/*.git/info/refs'],
|
||||
rateLimit: { enabled: true, maxRequests: 600, window: 60, keyBy: 'ip' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -320,12 +308,10 @@ tap.test('source policy compiler fails closed when wildcard binding shadows late
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'public' },
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'public' },
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -334,6 +320,32 @@ tap.test('source policy compiler fails closed when wildcard binding shadows late
|
||||
expect(variants).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('source policy compiler adds terminal deny fallback for private-only bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
);
|
||||
|
||||
expect(variants).toHaveLength(2);
|
||||
expect(variants[0].match.clientIp).toEqual(['10.0.0.0/8']);
|
||||
expect(variants[1].id).toEqual('route-1:source:deny-fallback');
|
||||
expect(variants[1].match.clientIp).toBeUndefined();
|
||||
expect(variants[1].action.type).toEqual('socket-handler');
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
expect(variants[1].priority! > makeRoute().priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when expansion would exceed route variant caps', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
@@ -349,12 +361,10 @@ tap.test('source policy compiler fails closed when expansion would exceed route
|
||||
),
|
||||
}));
|
||||
const metadata: IRouteMetadata = {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public', pathPolicies }],
|
||||
};
|
||||
|
||||
expect(SourcePolicyCompiler.validateSourcePolicyShape(metadata.sourcePolicy)).toContain('compiled route variants');
|
||||
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings)).toContain('compiled route variants');
|
||||
expect(SourcePolicyCompiler.compileRoute(makeRoute(), metadata, resolver, 'route-1')).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -372,11 +382,9 @@ tap.test('source policy compiler fails closed when configured bindings cannot co
|
||||
const emptyProfileVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'empty-ai' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'empty-ai' },
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -385,9 +393,7 @@ tap.test('source policy compiler fails closed when configured bindings cannot co
|
||||
const missingResolverVariants = SourcePolicyCompiler.compileRoute(
|
||||
makeRoute(),
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
undefined,
|
||||
'route-1',
|
||||
@@ -414,19 +420,17 @@ tap.test('source policy compiler keeps generated priorities inside SmartProxy bo
|
||||
}));
|
||||
|
||||
const route = makeRoute();
|
||||
route.priority = 10000;
|
||||
route.priority = 9000;
|
||||
const variants = SourcePolicyCompiler.compileRoute(
|
||||
route,
|
||||
{
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [{ pathClass: 'git-smart-http' }, { pathClass: 'normal-html' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolver,
|
||||
'route-1',
|
||||
@@ -437,6 +441,24 @@ tap.test('source policy compiler keeps generated priorities inside SmartProxy bo
|
||||
expect(variants[0].priority! > variants[1].priority!).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('source policy compiler fails closed when route priority lacks variant headroom', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
name: 'Trusted',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
}));
|
||||
|
||||
const route = makeRoute();
|
||||
route.priority = 10000;
|
||||
const metadata: IRouteMetadata = {
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
||||
};
|
||||
|
||||
expect(SourcePolicyCompiler.validateSourceBindingsShape(metadata.sourceBindings, route)).toContain('priority headroom');
|
||||
expect(SourcePolicyCompiler.compileRoute(route, metadata, resolver, 'route-1')).toEqual([]);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager applies source policy as expanded runtime routes', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
@@ -473,12 +495,10 @@ tap.test('RouteConfigManager applies source policy as expanded runtime routes',
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{ sourceProfileRef: 'trusted' },
|
||||
{ sourceProfileRef: 'public' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -522,9 +542,7 @@ tap.test('RouteConfigManager does not apply an uncompiled source-policy route',
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'empty-ai' }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -534,6 +552,39 @@ tap.test('RouteConfigManager does not apply an uncompiled source-policy route',
|
||||
expect(appliedRoutes[0].length).toEqual(0);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager fail-closes managed routes without source bindings', async () => {
|
||||
const appliedRoutes: IRouteConfig[][] = [];
|
||||
const manager = new RouteConfigManager(
|
||||
() => ({
|
||||
updateRoutes: async (routes: IRouteConfig[]) => {
|
||||
appliedRoutes.push(routes);
|
||||
},
|
||||
} as any),
|
||||
() => ({ enabled: false }),
|
||||
);
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
enabled: true,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
gatewayClientAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
},
|
||||
});
|
||||
|
||||
await manager.applyRoutes();
|
||||
|
||||
expect(appliedRoutes).toHaveLength(1);
|
||||
expect(appliedRoutes[0]).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects wildcard source policy bindings before later bindings', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
@@ -562,23 +613,19 @@ tap.test('RouteConfigManager rejects wildcard source policy bindings before late
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }, { sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Wildcard source profile bindings must be last');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('trusted');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects missing source policy profiles', async () => {
|
||||
@@ -604,23 +651,19 @@ tap.test('RouteConfigManager rejects missing source policy profiles', async () =
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'missing' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'missing' not found");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source profiles without source matches', async () => {
|
||||
@@ -651,26 +694,22 @@ tap.test('RouteConfigManager rejects source profiles without source matches', as
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'empty-ai' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain("Source profile 'Empty AI' has no source matches");
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings).toHaveLength(1);
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings).toHaveLength(1);
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies without a final all-source fallback', async () => {
|
||||
tap.test('RouteConfigManager accepts private-only source bindings without public fallback', async () => {
|
||||
const resolver = new ReferenceResolver();
|
||||
injectProfile(resolver, makeProfile({
|
||||
id: 'trusted',
|
||||
@@ -689,6 +728,8 @@ tap.test('RouteConfigManager rejects source policies without a final all-source
|
||||
undefined,
|
||||
resolver,
|
||||
);
|
||||
(manager as any).persistRoute = async () => undefined;
|
||||
(manager as any).applyRoutes = async () => undefined;
|
||||
(manager as any).routes.set('route-1', {
|
||||
id: 'route-1',
|
||||
route: makeRoute(),
|
||||
@@ -698,23 +739,18 @@ tap.test('RouteConfigManager rejects source policies without a final all-source
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('Source policy must end with an all-source fallback profile');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].sourceProfileRef).toEqual('public');
|
||||
expect(result.success).toBeTrue();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].sourceProfileRef).toEqual('trusted');
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects source policies with broad port range expansion', async () => {
|
||||
@@ -745,9 +781,7 @@ tap.test('RouteConfigManager rejects source policies with broad port range expan
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'trusted' }, { sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -785,23 +819,19 @@ tap.test('RouteConfigManager rejects negative source-policy maxConnections overr
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public', maxConnections: -1 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('maxConnections');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].maxConnections).toBeUndefined();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].maxConnections).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects oversized nested source-policy rate limit messages', async () => {
|
||||
@@ -827,34 +857,30 @@ tap.test('RouteConfigManager rejects oversized nested source-policy rate limit m
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
maxRequests: 10,
|
||||
window: 60,
|
||||
keyBy: 'ip',
|
||||
errorMessage: 'x'.repeat(sourcePolicyLimits.maxExceededMessageLength + 1),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('rate limit error message');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].rateLimit).toBeUndefined();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].rateLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
tap.test('RouteConfigManager rejects oversized source policy path patterns', async () => {
|
||||
@@ -880,36 +906,32 @@ tap.test('RouteConfigManager rejects oversized source policy path patterns', asy
|
||||
createdBy: 'test',
|
||||
origin: 'api',
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
sourceBindings: [{ sourceProfileRef: 'public' }],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.updateRoute('route-1', {
|
||||
metadata: {
|
||||
sourcePolicy: {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: Array.from(
|
||||
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
|
||||
(_item, index) => `/too-many-${index}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sourceBindings: [
|
||||
{
|
||||
sourceProfileRef: 'public',
|
||||
pathPolicies: [
|
||||
{
|
||||
pathClass: 'git-smart-http',
|
||||
pathPatterns: Array.from(
|
||||
{ length: sourcePolicyLimits.maxPathPatternsPerPolicy + 1 },
|
||||
(_item, index) => `/too-many-${index}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.message).toContain('path patterns');
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourcePolicy?.bindings[0].pathPolicies).toBeUndefined();
|
||||
expect(manager.getRoute('route-1')?.metadata?.sourceBindings?.[0].pathPolicies).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
@@ -108,6 +108,11 @@ const makeRouteConfigManager = () => {
|
||||
if (!storedRoute) return { success: false, message: 'Route not found' };
|
||||
if (patch.route) {
|
||||
storedRoute.route = { ...storedRoute.route, ...patch.route } as interfaces.data.IDcRouterRouteConfig;
|
||||
for (const [key, value] of Object.entries(patch.route)) {
|
||||
if (value === null) {
|
||||
delete (storedRoute.route as any)[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patch.enabled !== undefined) {
|
||||
storedRoute.enabled = patch.enabled;
|
||||
@@ -126,6 +131,20 @@ const makeRouteConfigManager = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const standardSourceProfile: interfaces.data.ISourceProfile = {
|
||||
id: 'standard',
|
||||
name: 'STANDARD',
|
||||
description: 'Standard test profile',
|
||||
security: { ipAllowList: ['10.0.0.0/8'] },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
createdBy: 'test',
|
||||
};
|
||||
|
||||
const makeReferenceResolver = () => ({
|
||||
listProfiles: () => [standardSourceProfile],
|
||||
});
|
||||
|
||||
const setupHandler = (options: {
|
||||
scopes: TScope[];
|
||||
policy?: interfaces.data.IApiTokenPolicy;
|
||||
@@ -146,6 +165,7 @@ const setupHandler = (options: {
|
||||
dcRouterRef: {
|
||||
options: {},
|
||||
apiTokenManager: makeApiTokenManager(options.scopes, options.policy),
|
||||
referenceResolver: makeReferenceResolver(),
|
||||
...options.dcRouterRef,
|
||||
},
|
||||
};
|
||||
@@ -244,6 +264,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(createdRoute.createdBy).toEqual('token-user');
|
||||
expect(createdRoute.route.name?.startsWith('gateway-client-onebox-box-1-app-1-app-example-com')).toEqual(true);
|
||||
expect(createdRoute.metadata).toEqual({
|
||||
sourceBindings: [{ sourceProfileRef: 'standard', sourceProfileName: 'STANDARD' }],
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: 'onebox',
|
||||
gatewayClientId: 'box-1',
|
||||
@@ -253,6 +274,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
workAppId: 'app-1',
|
||||
externalKey: 'onebox:box-1:app-1:app.example.com',
|
||||
});
|
||||
createdRoute.route.security = { ipAllowList: ['*'] };
|
||||
|
||||
const updateResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
@@ -275,6 +297,7 @@ tap.test('WorkHosterHandler syncs WorkApp routes idempotently with workhosters:w
|
||||
expect(routeConfig.routes.get('route-1')?.enabled).toEqual(false);
|
||||
expect(routeConfig.routes.get('route-1')?.route.name).toEqual('updated-workapp-route');
|
||||
expect(routeConfig.routes.get('route-1')?.route.action.targets?.[0].host).toEqual('10.0.0.3');
|
||||
expect(routeConfig.routes.get('route-1')?.route.security).toBeUndefined();
|
||||
|
||||
const deleteResult = await fireTypedRequest(typedrouter, 'syncWorkAppRoute', {
|
||||
apiToken: 'valid-token',
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.43.1',
|
||||
version: '13.43.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
IRouteMetadata,
|
||||
IRoute,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourceBinding,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
|
||||
const MAX_INHERITANCE_DEPTH = 5;
|
||||
@@ -288,8 +288,8 @@ export class ReferenceResolver {
|
||||
|
||||
/**
|
||||
* Resolve references for a single route.
|
||||
* Materializes source profile and/or network target into the route's fields.
|
||||
* When a source profile is selected, it owns the route security fully.
|
||||
* Resolves source binding display names and/or network target references.
|
||||
* Source profile security is resolved at apply time by SourcePolicyCompiler.
|
||||
* Returns the resolved route and updated metadata.
|
||||
*/
|
||||
public resolveRoute(
|
||||
@@ -298,27 +298,12 @@ export class ReferenceResolver {
|
||||
): { route: plugins.smartproxy.IRouteConfig; metadata: IRouteMetadata } {
|
||||
const resolvedMetadata: IRouteMetadata = { ...metadata };
|
||||
|
||||
if (resolvedMetadata.sourcePolicy?.bindings.length) {
|
||||
const resolvedSourcePolicy = this.resolveRouteSourcePolicy(resolvedMetadata.sourcePolicy);
|
||||
if (resolvedSourcePolicy) {
|
||||
resolvedMetadata.sourcePolicy = resolvedSourcePolicy;
|
||||
resolvedMetadata.sourceProfileRef = undefined;
|
||||
resolvedMetadata.sourceProfileName = undefined;
|
||||
if (resolvedMetadata.sourceBindings?.length) {
|
||||
const resolvedSourceBindings = this.resolveRouteSourceBindings(resolvedMetadata.sourceBindings);
|
||||
if (resolvedSourceBindings) {
|
||||
resolvedMetadata.sourceBindings = resolvedSourceBindings;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
}
|
||||
} else if (resolvedMetadata.sourceProfileRef) {
|
||||
const resolvedSecurity = this.resolveSourceProfile(resolvedMetadata.sourceProfileRef);
|
||||
if (resolvedSecurity) {
|
||||
const profile = this.profiles.get(resolvedMetadata.sourceProfileRef);
|
||||
route = {
|
||||
...route,
|
||||
security: this.cloneSecurityFields(resolvedSecurity),
|
||||
};
|
||||
resolvedMetadata.sourceProfileName = profile?.name;
|
||||
resolvedMetadata.lastResolvedAt = Date.now();
|
||||
} else {
|
||||
logger.log('warn', `Source profile '${resolvedMetadata.sourceProfileRef}' not found during resolution`);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedMetadata.networkTargetRef) {
|
||||
@@ -387,12 +372,12 @@ export class ReferenceResolver {
|
||||
// Private: source profile resolution with inheritance
|
||||
// =========================================================================
|
||||
|
||||
private resolveRouteSourcePolicy(sourcePolicy: IRouteSourcePolicy): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy.bindings
|
||||
private resolveRouteSourceBindings(sourceBindings: IRouteSourceBinding[]): IRouteSourceBinding[] | undefined {
|
||||
const bindings = sourceBindings
|
||||
.map((binding) => {
|
||||
const profile = this.profiles.get(binding.sourceProfileRef);
|
||||
if (!profile) {
|
||||
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source policy resolution`);
|
||||
logger.log('warn', `Source profile '${binding.sourceProfileRef}' not found during source binding resolution`);
|
||||
return binding;
|
||||
}
|
||||
return {
|
||||
@@ -402,7 +387,7 @@ export class ReferenceResolver {
|
||||
})
|
||||
.filter((binding) => binding.sourceProfileRef);
|
||||
|
||||
return bindings.length > 0 ? { bindings } : undefined;
|
||||
return bindings.length > 0 ? bindings : undefined;
|
||||
}
|
||||
|
||||
private metadataUsesSourceProfile(metadata: IRouteMetadata | undefined, profileId: string): boolean {
|
||||
@@ -411,10 +396,7 @@ export class ReferenceResolver {
|
||||
|
||||
private getSourceProfileRefsFromMetadata(metadata: IRouteMetadata | undefined): string[] {
|
||||
const refs = new Set<string>();
|
||||
if (metadata?.sourceProfileRef) {
|
||||
refs.add(metadata.sourceProfileRef);
|
||||
}
|
||||
for (const binding of metadata?.sourcePolicy?.bindings || []) {
|
||||
for (const binding of metadata?.sourceBindings || []) {
|
||||
if (binding.sourceProfileRef) {
|
||||
refs.add(binding.sourceProfileRef);
|
||||
}
|
||||
@@ -623,22 +605,16 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
private clearSourceProfileFromMetadata(metadata: IRouteMetadata, profileId: string): IRouteMetadata {
|
||||
const sourcePolicy = metadata.sourcePolicy?.bindings?.length
|
||||
? {
|
||||
bindings: metadata.sourcePolicy.bindings.filter(
|
||||
(binding) => binding.sourceProfileRef !== profileId,
|
||||
),
|
||||
}
|
||||
const sourceBindings = metadata.sourceBindings?.length
|
||||
? metadata.sourceBindings.filter((binding) => binding.sourceProfileRef !== profileId)
|
||||
: undefined;
|
||||
|
||||
const nextMetadata: IRouteMetadata = {
|
||||
...metadata,
|
||||
sourceProfileRef: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileRef === profileId ? undefined : metadata.sourceProfileName,
|
||||
sourcePolicy: sourcePolicy?.bindings.length ? sourcePolicy : undefined,
|
||||
sourceBindings: sourceBindings?.length ? sourceBindings : undefined,
|
||||
};
|
||||
|
||||
if (!nextMetadata.sourceProfileRef && !nextMetadata.sourcePolicy && !nextMetadata.networkTargetRef) {
|
||||
if (!nextMetadata.sourceBindings && !nextMetadata.networkTargetRef) {
|
||||
nextMetadata.lastResolvedAt = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
IRouteWarning,
|
||||
IRouteMetadata,
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourceBinding,
|
||||
IRouteSecurity,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { IDcRouterRouteConfig } from '../../ts_interfaces/data/remoteingress.js';
|
||||
@@ -142,9 +142,9 @@ export class RouteConfigManager {
|
||||
): Promise<string> {
|
||||
const id = plugins.uuid.v4();
|
||||
const now = Date.now();
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
throw new Error(sourcePolicyPayloadError);
|
||||
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(metadata?.sourceBindings);
|
||||
if (sourceBindingsPayloadError) {
|
||||
throw new Error(sourceBindingsPayloadError);
|
||||
}
|
||||
|
||||
// Ensure route has a name
|
||||
@@ -159,9 +159,9 @@ export class RouteConfigManager {
|
||||
route = resolved.route;
|
||||
resolvedMetadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(resolvedMetadata?.sourcePolicy, route);
|
||||
if (sourcePolicyValidationError) {
|
||||
throw new Error(sourcePolicyValidationError);
|
||||
const sourceBindingsValidationError = this.validateSourceBindings(resolvedMetadata?.sourceBindings, route);
|
||||
if (sourceBindingsValidationError) {
|
||||
throw new Error(sourceBindingsValidationError);
|
||||
}
|
||||
|
||||
const stored: IRoute = {
|
||||
@@ -193,12 +193,11 @@ export class RouteConfigManager {
|
||||
if (!stored) {
|
||||
return { success: false, message: 'Route not found' };
|
||||
}
|
||||
const sourcePolicyPayloadError = SourcePolicyCompiler.validateSourcePolicyPayload(patch.metadata?.sourcePolicy);
|
||||
if (sourcePolicyPayloadError) {
|
||||
return { success: false, message: sourcePolicyPayloadError };
|
||||
const sourceBindingsPayloadError = SourcePolicyCompiler.validateSourceBindingsPayload(patch.metadata?.sourceBindings);
|
||||
if (sourceBindingsPayloadError) {
|
||||
return { success: false, message: sourceBindingsPayloadError };
|
||||
}
|
||||
|
||||
const previousSourceProfileRef = stored.metadata?.sourceProfileRef;
|
||||
const previousRoute = structuredClone(stored.route);
|
||||
const previousMetadata = structuredClone(stored.metadata);
|
||||
const previousEnabled = stored.enabled;
|
||||
@@ -244,13 +243,6 @@ export class RouteConfigManager {
|
||||
...stored.metadata,
|
||||
...patch.metadata,
|
||||
});
|
||||
if (
|
||||
previousSourceProfileRef
|
||||
&& !stored.metadata?.sourceProfileRef
|
||||
&& !patch.route?.security
|
||||
) {
|
||||
delete stored.route.security;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-resolve if metadata refs exist and resolver is available
|
||||
@@ -260,12 +252,12 @@ export class RouteConfigManager {
|
||||
stored.metadata = this.normalizeRouteMetadata(resolved.metadata);
|
||||
}
|
||||
|
||||
const sourcePolicyValidationError = this.validateSourcePolicy(stored.metadata?.sourcePolicy, stored.route);
|
||||
if (sourcePolicyValidationError) {
|
||||
const sourceBindingsValidationError = this.validateSourceBindings(stored.metadata?.sourceBindings, stored.route);
|
||||
if (sourceBindingsValidationError) {
|
||||
stored.route = previousRoute;
|
||||
stored.metadata = previousMetadata;
|
||||
stored.enabled = previousEnabled;
|
||||
return { success: false, message: sourcePolicyValidationError };
|
||||
return { success: false, message: sourceBindingsValidationError };
|
||||
}
|
||||
|
||||
stored.updatedAt = Date.now();
|
||||
@@ -487,10 +479,8 @@ export class RouteConfigManager {
|
||||
};
|
||||
|
||||
const normalized: IRouteMetadata = {
|
||||
sourceProfileRef: normalizeString(metadata.sourceProfileRef),
|
||||
sourcePolicy: this.normalizeSourcePolicy(metadata.sourcePolicy),
|
||||
sourceBindings: this.normalizeSourceBindings(metadata.sourceBindings),
|
||||
networkTargetRef: normalizeString(metadata.networkTargetRef),
|
||||
sourceProfileName: normalizeString(metadata.sourceProfileName),
|
||||
networkTargetName: normalizeString(metadata.networkTargetName),
|
||||
lastResolvedAt: typeof metadata.lastResolvedAt === 'number' && Number.isFinite(metadata.lastResolvedAt)
|
||||
? metadata.lastResolvedAt
|
||||
@@ -511,13 +501,10 @@ export class RouteConfigManager {
|
||||
externalKey: normalizeString(metadata.externalKey),
|
||||
};
|
||||
|
||||
if (!normalized.sourceProfileRef) {
|
||||
normalized.sourceProfileName = undefined;
|
||||
}
|
||||
if (!normalized.networkTargetRef) {
|
||||
normalized.networkTargetName = undefined;
|
||||
}
|
||||
if (!normalized.sourceProfileRef && !normalized.sourcePolicy && !normalized.networkTargetRef) {
|
||||
if (!normalized.sourceBindings && !normalized.networkTargetRef) {
|
||||
normalized.lastResolvedAt = undefined;
|
||||
}
|
||||
if (normalized.ownerType !== 'gatewayClient' && normalized.ownerType !== 'workhoster') {
|
||||
@@ -542,14 +529,13 @@ export class RouteConfigManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeSourcePolicy(sourcePolicy?: Partial<IRouteSourcePolicy>): IRouteSourcePolicy | undefined {
|
||||
const bindings = sourcePolicy?.bindings;
|
||||
if (!Array.isArray(bindings)) {
|
||||
private normalizeSourceBindings(sourceBindings?: Partial<IRouteSourceBinding>[]): IRouteSourceBinding[] | undefined {
|
||||
if (!Array.isArray(sourceBindings)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedBindings: IRouteSourcePolicy['bindings'] = [];
|
||||
for (const binding of bindings) {
|
||||
const normalizedBindings: IRouteSourceBinding[] = [];
|
||||
for (const binding of sourceBindings) {
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
@@ -583,7 +569,7 @@ export class RouteConfigManager {
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedBindings.length > 0 ? { bindings: normalizedBindings } : undefined;
|
||||
return normalizedBindings.length > 0 ? normalizedBindings : undefined;
|
||||
}
|
||||
|
||||
private normalizePathPolicies(
|
||||
@@ -631,15 +617,15 @@ export class RouteConfigManager {
|
||||
return normalizedPathPolicies.length > 0 ? normalizedPathPolicies : undefined;
|
||||
}
|
||||
|
||||
private validateSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
private validateSourceBindings(
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
route: IDcRouterRouteConfig,
|
||||
): string | undefined {
|
||||
const shapeError = SourcePolicyCompiler.validateSourcePolicyShape(sourcePolicy, route);
|
||||
const shapeError = SourcePolicyCompiler.validateSourceBindingsShape(sourceBindings, route);
|
||||
if (shapeError) {
|
||||
return shapeError;
|
||||
}
|
||||
return SourcePolicyCompiler.validateResolvedSourcePolicy(sourcePolicy, this.referenceResolver);
|
||||
return SourcePolicyCompiler.validateResolvedSourceBindings(sourceBindings, this.referenceResolver);
|
||||
}
|
||||
|
||||
private normalizeRateLimit(rateLimit?: IRouteSecurity['rateLimit']): IRouteSecurity['rateLimit'] | undefined {
|
||||
@@ -756,14 +742,29 @@ export class RouteConfigManager {
|
||||
}
|
||||
|
||||
private prepareStoredRoutesForApply(storedRoute: IRoute): plugins.smartproxy.IRouteConfig[] {
|
||||
if (this.isManagedAccessRoute(storedRoute) && !storedRoute.metadata?.sourceBindings?.length) {
|
||||
return [];
|
||||
}
|
||||
const hydratedRoute = this.hydrateStoredRoute?.(storedRoute);
|
||||
const sourcePolicyRoutes = SourcePolicyCompiler.compileRoute(
|
||||
const sourceBoundRoutes = SourcePolicyCompiler.compileRoute(
|
||||
hydratedRoute || storedRoute.route,
|
||||
storedRoute.metadata,
|
||||
this.referenceResolver,
|
||||
storedRoute.id,
|
||||
);
|
||||
return sourcePolicyRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
|
||||
return sourceBoundRoutes.map((route) => this.prepareRouteForApply(route, storedRoute.id));
|
||||
}
|
||||
|
||||
private isManagedAccessRoute(storedRoute: IRoute): boolean {
|
||||
const metadata = storedRoute.metadata;
|
||||
if (storedRoute.origin !== 'api' || !metadata) {
|
||||
return false;
|
||||
}
|
||||
return metadata.ownerType === 'gatewayClient'
|
||||
|| metadata.ownerType === 'workhoster'
|
||||
|| Boolean(metadata.gatewayClientId)
|
||||
|| Boolean(metadata.workHosterId)
|
||||
|| Boolean(metadata.externalKey);
|
||||
}
|
||||
|
||||
private prepareRouteForApply(
|
||||
|
||||
@@ -8,8 +8,7 @@ import type {
|
||||
IRoutePathPolicyBinding,
|
||||
IRouteMetadata,
|
||||
IRouteSecurity,
|
||||
IRouteSourcePolicy,
|
||||
IRouteSourcePolicyBinding,
|
||||
IRouteSourceBinding,
|
||||
} from '../../ts_interfaces/data/route-management.js';
|
||||
import type { ReferenceResolver } from './classes.reference-resolver.js';
|
||||
|
||||
@@ -37,22 +36,23 @@ export class SourcePolicyCompiler {
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig[] {
|
||||
const bindings = metadata?.sourcePolicy?.bindings || [];
|
||||
const bindings = metadata?.sourceBindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return [route];
|
||||
}
|
||||
if (this.validateSourcePolicyShape(metadata?.sourcePolicy, route)) {
|
||||
if (this.validateSourceBindingsShape(bindings, route)) {
|
||||
return [];
|
||||
}
|
||||
if (!referenceResolver) {
|
||||
return [];
|
||||
}
|
||||
if (this.validateResolvedSourcePolicy(metadata?.sourcePolicy, referenceResolver)) {
|
||||
if (this.validateResolvedSourceBindings(bindings, referenceResolver)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compiledRoutes: plugins.smartproxy.IRouteConfig[] = [];
|
||||
const basePriority = route.priority ?? 0;
|
||||
let hasAllSourcesBinding = false;
|
||||
|
||||
bindings.forEach((binding, index) => {
|
||||
const profile = referenceResolver.getProfile(binding.sourceProfileRef);
|
||||
@@ -65,6 +65,9 @@ export class SourcePolicyCompiler {
|
||||
if (sourceMatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.matchesAllSources(sourceMatches)) {
|
||||
hasAllSourcesBinding = true;
|
||||
}
|
||||
const sourcePriority = this.calculateSourcePriority(basePriority, index, bindings.length);
|
||||
const sourceMatch = this.matchesAllSources(sourceMatches)
|
||||
? { ...route.match }
|
||||
@@ -140,39 +143,43 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
});
|
||||
|
||||
if (compiledRoutes.length > 0 && !hasAllSourcesBinding) {
|
||||
compiledRoutes.push(this.buildDenyFallbackRoute(route, basePriority, routeId));
|
||||
}
|
||||
|
||||
return this.applyIntegerPriorities(compiledRoutes, basePriority);
|
||||
}
|
||||
|
||||
public static validateSourcePolicyPayload(sourcePolicy?: Partial<IRouteSourcePolicy>): string | undefined {
|
||||
if (!sourcePolicy) {
|
||||
public static validateSourceBindingsPayload(sourceBindings?: Partial<IRouteSourceBinding>[]): string | undefined {
|
||||
if (sourceBindings === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(sourcePolicy.bindings)) {
|
||||
return 'Source policy bindings must be an array';
|
||||
if (!Array.isArray(sourceBindings)) {
|
||||
return 'Source bindings must be an array';
|
||||
}
|
||||
if (sourcePolicy.bindings.length === 0) {
|
||||
if (sourceBindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (sourcePolicy.bindings.length > sourcePolicyLimits.maxBindings) {
|
||||
if (sourceBindings.length > sourcePolicyLimits.maxBindings) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxBindings} bindings`;
|
||||
}
|
||||
|
||||
const validClasses = new Set<string>(routePathClasses);
|
||||
for (const binding of sourcePolicy.bindings) {
|
||||
for (const binding of sourceBindings) {
|
||||
if (!binding || typeof binding !== 'object') {
|
||||
return 'Source policy binding must be an object';
|
||||
return 'Source binding must be an object';
|
||||
}
|
||||
if (typeof binding.sourceProfileRef !== 'string') {
|
||||
return 'Source policy binding requires a source profile';
|
||||
return 'Source binding requires a source profile';
|
||||
}
|
||||
if (binding.sourceProfileRef.length > sourcePolicyLimits.maxSourceProfileRefLength) {
|
||||
return `Source policy source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
|
||||
return `Source binding source profile ref exceeds ${sourcePolicyLimits.maxSourceProfileRefLength} characters`;
|
||||
}
|
||||
if (binding.sourceProfileRef.trim().length === 0) {
|
||||
return 'Source policy binding requires a source profile';
|
||||
return 'Source binding requires a source profile';
|
||||
}
|
||||
if (typeof binding.id === 'string' && binding.id.length > sourcePolicyLimits.maxIdLength) {
|
||||
return `Source policy binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
||||
return `Source binding id exceeds ${sourcePolicyLimits.maxIdLength} characters`;
|
||||
}
|
||||
if (typeof binding.maxConnections === 'number' && binding.maxConnections < 0) {
|
||||
return 'Source policy maxConnections must be non-negative';
|
||||
@@ -268,14 +275,21 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
|
||||
public static validateSourcePolicyShape(
|
||||
sourcePolicy?: IRouteSourcePolicy,
|
||||
sourceBindings?: IRouteSourceBinding[],
|
||||
route?: plugins.smartproxy.IRouteConfig,
|
||||
): string | undefined {
|
||||
const payloadError = this.validateSourcePolicyPayload(sourcePolicy);
|
||||
return this.validateSourceBindingsShape(sourceBindings, route);
|
||||
}
|
||||
|
||||
public static validateSourceBindingsShape(
|
||||
sourceBindings?: IRouteSourceBinding[],
|
||||
route?: plugins.smartproxy.IRouteConfig,
|
||||
): string | undefined {
|
||||
const payloadError = this.validateSourceBindingsPayload(sourceBindings);
|
||||
if (payloadError) {
|
||||
return payloadError;
|
||||
}
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
const bindings = sourceBindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -310,19 +324,36 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
}
|
||||
|
||||
// Private-only source bindings add one terminal deny route to prevent fall-through
|
||||
// to broader routes with the same host/path/port scope.
|
||||
estimatedCompiledRoutes++;
|
||||
|
||||
const expandedPortCount = route ? this.getExpandedPortCount(route.match?.ports) : 1;
|
||||
if (estimatedCompiledRoutes * expandedPortCount > sourcePolicyLimits.maxCompiledVariantsPerRoute) {
|
||||
return `Source policy exceeds ${sourcePolicyLimits.maxCompiledVariantsPerRoute} compiled route-port variants`;
|
||||
}
|
||||
if (route && typeof route.priority === 'number' && Number.isFinite(route.priority)) {
|
||||
const integerBasePriority = Math.trunc(this.clampPriority(route.priority));
|
||||
if (integerBasePriority + estimatedCompiledRoutes > MAX_ROUTE_PRIORITY) {
|
||||
return `Source policy route priority leaves no priority headroom for ${estimatedCompiledRoutes} compiled variants`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static validateResolvedSourcePolicy(
|
||||
sourcePolicy: IRouteSourcePolicy | undefined,
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
): string | undefined {
|
||||
const bindings = sourcePolicy?.bindings || [];
|
||||
return this.validateResolvedSourceBindings(sourceBindings, referenceResolver);
|
||||
}
|
||||
|
||||
public static validateResolvedSourceBindings(
|
||||
sourceBindings: IRouteSourceBinding[] | undefined,
|
||||
referenceResolver: ReferenceResolver | undefined,
|
||||
): string | undefined {
|
||||
const bindings = sourceBindings || [];
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -346,10 +377,7 @@ export class SourcePolicyCompiler {
|
||||
}
|
||||
const matchesAllSources = this.matchesAllSources(sourceMatches);
|
||||
if (matchesAllSources && index < bindings.length - 1) {
|
||||
return 'Wildcard source profile bindings must be last in a source policy';
|
||||
}
|
||||
if (index === bindings.length - 1 && !matchesAllSources) {
|
||||
return 'Source policy must end with an all-source fallback profile';
|
||||
return 'Wildcard source profile bindings must be last in source bindings';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +389,7 @@ export class SourcePolicyCompiler {
|
||||
sourceMatch: plugins.smartproxy.IRouteConfig['match'];
|
||||
profileName: string;
|
||||
profileSecurity: IRouteSecurity;
|
||||
binding: IRouteSourcePolicyBinding;
|
||||
binding: IRouteSourceBinding;
|
||||
pathPolicy?: IRoutePathPolicyBinding;
|
||||
pathPattern?: string;
|
||||
sourcePriority: number;
|
||||
@@ -414,6 +442,63 @@ export class SourcePolicyCompiler {
|
||||
};
|
||||
}
|
||||
|
||||
private static buildDenyFallbackRoute(
|
||||
route: plugins.smartproxy.IRouteConfig,
|
||||
basePriority: number,
|
||||
routeId?: string,
|
||||
): plugins.smartproxy.IRouteConfig {
|
||||
const routeKey = route.id || routeId || route.name || 'route';
|
||||
return {
|
||||
...route,
|
||||
id: `${routeKey}:source:deny-fallback`,
|
||||
name: `${route.name || routeKey}:source:deny-fallback`,
|
||||
match: { ...route.match },
|
||||
priority: this.clampPriority(basePriority - SOURCE_PRIORITY_BAND - PATH_PRIORITY_BAND),
|
||||
action: {
|
||||
type: 'socket-handler',
|
||||
socketHandler: (socket) => this.denySocket(socket),
|
||||
},
|
||||
security: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private static denySocket(socket: plugins.net.Socket): void {
|
||||
let timeout: ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.removeListener('data', handleData);
|
||||
socket.removeListener('error', cleanup);
|
||||
socket.removeListener('close', cleanup);
|
||||
};
|
||||
|
||||
const handleData = (chunk: string | Uint8Array) => {
|
||||
cleanup();
|
||||
if (this.looksLikeHttpRequest(chunk)) {
|
||||
socket.end('HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: 9\r\nConnection: close\r\n\r\nForbidden');
|
||||
return;
|
||||
}
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
socket.destroy();
|
||||
}, 2000) as ReturnType<typeof setTimeout> & { unref?: () => void };
|
||||
timeout.unref?.();
|
||||
|
||||
socket.once('data', handleData);
|
||||
socket.once('error', cleanup);
|
||||
socket.once('close', cleanup);
|
||||
}
|
||||
|
||||
private static looksLikeHttpRequest(chunk: string | Uint8Array): boolean {
|
||||
const prefix = typeof chunk === 'string'
|
||||
? chunk.slice(0, 16)
|
||||
: String.fromCharCode(...chunk.subarray(0, 16));
|
||||
return /^(GET|POST|HEAD|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s/.test(prefix)
|
||||
|| prefix.startsWith('PRI * HTTP/2.0');
|
||||
}
|
||||
|
||||
private static getPathPatterns(pathPolicy: IRoutePathPolicyBinding): string[] {
|
||||
const patterns: string[] = pathPolicy.pathPatterns?.length
|
||||
? pathPolicy.pathPatterns
|
||||
@@ -469,8 +554,8 @@ export class SourcePolicyCompiler {
|
||||
}))
|
||||
.sort((a, b) => (b.priority - a.priority) || (a.originalIndex - b.originalIndex));
|
||||
const topPriority = Math.trunc(this.clampPriority(
|
||||
basePriority + routes.length - 1,
|
||||
MIN_ROUTE_PRIORITY + routes.length - 1,
|
||||
basePriority + routes.length,
|
||||
MIN_ROUTE_PRIORITY + routes.length,
|
||||
MAX_ROUTE_PRIORITY,
|
||||
));
|
||||
const integerPriorities = new Map<number, number>();
|
||||
@@ -589,7 +674,7 @@ export class SourcePolicyCompiler {
|
||||
private static buildBindingSecurity(
|
||||
routeSecurity: IRouteSecurity | undefined,
|
||||
profileSecurity: IRouteSecurity,
|
||||
binding: IRouteSourcePolicyBinding,
|
||||
binding: IRouteSourceBinding,
|
||||
pathPolicy?: IRoutePathPolicyBinding,
|
||||
): IRouteSecurity | undefined {
|
||||
const baseSecurity = this.omitSourceMatchFields(routeSecurity || {});
|
||||
|
||||
@@ -587,7 +587,13 @@ export class WorkHosterHandler {
|
||||
return { success: false, message: 'route is required unless delete=true' };
|
||||
}
|
||||
|
||||
const sourceBindings = this.getManagedRouteSourceBindings();
|
||||
if (!sourceBindings) {
|
||||
return { success: false, message: 'STANDARD source profile not found' };
|
||||
}
|
||||
|
||||
const metadata: interfaces.data.IRouteMetadata = {
|
||||
sourceBindings,
|
||||
ownerType: 'gatewayClient',
|
||||
gatewayClientType: resolvedOwnership.gatewayClientType,
|
||||
gatewayClientId: resolvedOwnership.gatewayClientId,
|
||||
@@ -600,8 +606,10 @@ export class WorkHosterHandler {
|
||||
const normalizedRoute = this.normalizeGatewayClientRoute(route, resolvedOwnership, externalKey);
|
||||
|
||||
if (existingRoute) {
|
||||
const routePatch: Partial<interfaces.data.IDcRouterRouteConfig> = { ...normalizedRoute };
|
||||
(routePatch as any).security = null;
|
||||
const result = await manager.updateRoute(existingRoute.id, {
|
||||
route: normalizedRoute,
|
||||
route: routePatch,
|
||||
enabled: enabled ?? true,
|
||||
metadata,
|
||||
});
|
||||
@@ -640,10 +648,26 @@ export class WorkHosterHandler {
|
||||
ownership: Required<interfaces.data.IGatewayClientOwnership>,
|
||||
externalKey: string,
|
||||
): interfaces.data.IDcRouterRouteConfig {
|
||||
const normalizedRoute = { ...route };
|
||||
const normalizedRoute = structuredClone(route);
|
||||
delete normalizedRoute.security;
|
||||
if (!normalizedRoute.name) {
|
||||
normalizedRoute.name = `gateway-client-${externalKey.replace(/[^a-zA-Z0-9-]+/g, '-').slice(0, 80)}`;
|
||||
}
|
||||
return normalizedRoute;
|
||||
}
|
||||
|
||||
private getManagedRouteSourceBindings(): interfaces.data.IRouteSourceBinding[] | undefined {
|
||||
const resolver = this.opsServerRef.dcRouterRef.referenceResolver;
|
||||
const standardProfile = resolver?.listProfiles().find((profile: interfaces.data.ISourceProfile) => {
|
||||
return profile.id.trim().toLowerCase() === 'standard'
|
||||
|| profile.name.trim().toLowerCase() === 'standard';
|
||||
});
|
||||
if (!standardProfile) {
|
||||
return undefined;
|
||||
}
|
||||
return [{
|
||||
sourceProfileRef: standardProfile.id,
|
||||
sourceProfileName: standardProfile.name,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export interface IRoutePathPolicyBinding {
|
||||
onExceeded?: IRouteSourcePolicyExceededAction;
|
||||
}
|
||||
|
||||
export interface IRouteSourcePolicyBinding {
|
||||
export interface IRouteSourceBinding {
|
||||
id?: string;
|
||||
sourceProfileRef: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
@@ -205,9 +205,13 @@ export interface IRouteSourcePolicyBinding {
|
||||
pathPolicies?: IRoutePathPolicyBinding[];
|
||||
}
|
||||
|
||||
/** @deprecated Use IRouteSourceBinding and IRouteMetadata.sourceBindings. */
|
||||
export type IRouteSourcePolicyBinding = IRouteSourceBinding;
|
||||
|
||||
/** @deprecated Use IRouteMetadata.sourceBindings. */
|
||||
export interface IRouteSourcePolicy {
|
||||
/** Ordered source profile bindings. The first matching binding wins. */
|
||||
bindings: IRouteSourcePolicyBinding[];
|
||||
bindings: IRouteSourceBinding[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -236,14 +240,10 @@ export interface INetworkTarget {
|
||||
* Metadata on a stored route tracking where its resolved values came from.
|
||||
*/
|
||||
export interface IRouteMetadata {
|
||||
/** ID of the SourceProfileDoc used to resolve this route's security. */
|
||||
sourceProfileRef?: string;
|
||||
/** Ordered source policy. When present, it supersedes sourceProfileRef. */
|
||||
sourcePolicy?: IRouteSourcePolicy;
|
||||
/** Ordered source profile bindings. The first matching source profile wins. */
|
||||
sourceBindings?: IRouteSourceBinding[];
|
||||
/** ID of the NetworkTargetDoc used to resolve this route's targets. */
|
||||
networkTargetRef?: string;
|
||||
/** Snapshot of the profile name at resolution time, for display. */
|
||||
sourceProfileName?: string;
|
||||
/** Snapshot of the target name at resolution time, for display. */
|
||||
networkTargetName?: string;
|
||||
/** Timestamp of last reference resolution. */
|
||||
|
||||
@@ -22,7 +22,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
|
||||
| Export | Purpose |
|
||||
| --- | --- |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, route source policies, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `data` | Shared runtime-shaped models such as identities, routes, route source bindings, DNS records, domains, email domains, remote ingress edges, VPN objects, stats, and security policy data. |
|
||||
| `requests` | TypedRequest request/response contracts for OpsServer methods. |
|
||||
| `typedrequestInterfaces` | Helper types re-exported from `@api.global/typedrequest-interfaces` through `plugins.ts`. |
|
||||
|
||||
@@ -31,7 +31,7 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
| Area | Examples |
|
||||
| --- | --- |
|
||||
| Auth | admin login, first-admin bootstrap status/creation, logout, identity verification, users |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path policies |
|
||||
| Routes | merged route listing, API route CRUD, toggles, warnings, ownership metadata, ordered source/path bindings |
|
||||
| Access | API tokens, source profiles, target profiles, network targets |
|
||||
| DNS and domains | DNS providers, domains, DNS records, ACME config |
|
||||
| Email | email-domain management and email operations |
|
||||
@@ -39,14 +39,15 @@ import { data, requests } from '@serve.zone/dcrouter/interfaces';
|
||||
| Observability | stats, combined stats, logs, configuration |
|
||||
| WorkHoster | external app/workhoster route ownership contracts |
|
||||
|
||||
## Route Source Policy Contracts
|
||||
## Route Source Binding Contracts
|
||||
|
||||
`data/route-management.ts` exports the source-policy contracts used by the dashboard, API client, and route runtime compiler:
|
||||
`data/route-management.ts` exports the source-binding contracts used by the dashboard, API client, and route runtime compiler:
|
||||
|
||||
- `IRouteSourcePolicy` stores ordered route-level source bindings.
|
||||
- `IRouteSourcePolicyBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
|
||||
- `IRouteMetadata.sourceBindings` stores ordered route-level source bindings.
|
||||
- `IRouteSourceBinding` points to a source profile, can override rate limits or connection limits, and can contain path policies.
|
||||
- `IRoutePathPolicyBinding` applies path-class-specific overrides within a source binding.
|
||||
- `IRouteSourcePolicyExceededAction` describes terminal exceeded-limit behavior, currently explicit `429` handling.
|
||||
- `IRouteSourcePolicy` and `IRouteSourcePolicyBinding` remain deprecated type aliases for old integrations; active route metadata uses `sourceBindings[]`.
|
||||
- `TRoutePathClass` is the string-union type derived from `routePathClasses`.
|
||||
- `routePathClasses` lists the supported classes: `git-smart-http`, `static`, `normal-html`, `expensive-html`, `raw`, and `archive`.
|
||||
- `giteaRoutePathClassLabels` and `giteaRoutePathClassPatterns` provide the built-in Gitea labels and path patterns, including Git Smart HTTP and Git LFS patterns.
|
||||
|
||||
@@ -270,6 +270,115 @@ async function seedMissingDefaultSourceProfiles(ctx: {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMigrationSourceBinding(binding: any, profiles: Map<string, any>): any | undefined {
|
||||
if (!binding || typeof binding !== 'object') return undefined;
|
||||
const sourceProfileRef = typeof binding.sourceProfileRef === 'string'
|
||||
? binding.sourceProfileRef.trim()
|
||||
: '';
|
||||
if (!sourceProfileRef) return undefined;
|
||||
|
||||
const profile = profiles.get(sourceProfileRef);
|
||||
const normalizedBinding = structuredClone(binding);
|
||||
normalizedBinding.sourceProfileRef = sourceProfileRef;
|
||||
const sourceProfileName = typeof normalizedBinding.sourceProfileName === 'string'
|
||||
? normalizedBinding.sourceProfileName.trim()
|
||||
: '';
|
||||
if (sourceProfileName) {
|
||||
normalizedBinding.sourceProfileName = sourceProfileName;
|
||||
} else if (typeof profile?.name === 'string' && profile.name.trim()) {
|
||||
normalizedBinding.sourceProfileName = profile.name.trim();
|
||||
} else {
|
||||
delete normalizedBinding.sourceProfileName;
|
||||
}
|
||||
|
||||
return normalizedBinding;
|
||||
}
|
||||
|
||||
async function convertRouteAccessMetadataToSourceBindings(ctx: {
|
||||
mongo?: { collection: (name: string) => any };
|
||||
log: { log: (level: 'info', message: string) => void };
|
||||
}): Promise<void> {
|
||||
const profileCollection = ctx.mongo!.collection('SourceProfileDoc');
|
||||
const routeCollection = ctx.mongo!.collection('RouteDoc');
|
||||
const profiles = new Map<string, any>();
|
||||
const now = Date.now();
|
||||
|
||||
for await (const profile of profileCollection.find({})) {
|
||||
if (typeof (profile as any).id === 'string') {
|
||||
profiles.set((profile as any).id, profile);
|
||||
}
|
||||
}
|
||||
|
||||
let inspected = 0;
|
||||
let migrated = 0;
|
||||
for await (const routeDoc of routeCollection.find({})) {
|
||||
const metadata = (routeDoc as any).metadata || {};
|
||||
const existingSourceBindings = Array.isArray(metadata.sourceBindings)
|
||||
? metadata.sourceBindings
|
||||
: [];
|
||||
const legacyPolicyBindings = Array.isArray(metadata.sourcePolicy?.bindings)
|
||||
? metadata.sourcePolicy.bindings
|
||||
: [];
|
||||
const legacySourceProfileRef = typeof metadata.sourceProfileRef === 'string'
|
||||
? metadata.sourceProfileRef.trim()
|
||||
: '';
|
||||
const hasLegacyAccessFields = legacyPolicyBindings.length > 0
|
||||
|| legacySourceProfileRef.length > 0
|
||||
|| metadata.sourcePolicy !== undefined
|
||||
|| metadata.sourceProfileRef !== undefined
|
||||
|| metadata.sourceProfileName !== undefined;
|
||||
|
||||
if (!hasLegacyAccessFields && existingSourceBindings.length === 0) {
|
||||
continue;
|
||||
}
|
||||
inspected++;
|
||||
|
||||
const sourceBindings = existingSourceBindings.length > 0
|
||||
? existingSourceBindings
|
||||
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
|
||||
.filter(Boolean)
|
||||
: legacyPolicyBindings.length > 0
|
||||
? legacyPolicyBindings
|
||||
.map((binding: any) => normalizeMigrationSourceBinding(binding, profiles))
|
||||
.filter(Boolean)
|
||||
: legacySourceProfileRef
|
||||
? [normalizeMigrationSourceBinding({
|
||||
sourceProfileRef: legacySourceProfileRef,
|
||||
sourceProfileName: metadata.sourceProfileName,
|
||||
}, profiles)].filter(Boolean)
|
||||
: [];
|
||||
|
||||
const $set: Record<string, any> = { updatedAt: now };
|
||||
const $unset: Record<string, ''> = {
|
||||
'metadata.sourcePolicy': '',
|
||||
'metadata.sourceProfileRef': '',
|
||||
'metadata.sourceProfileName': '',
|
||||
};
|
||||
|
||||
if (sourceBindings.length > 0) {
|
||||
$set['metadata.sourceBindings'] = sourceBindings;
|
||||
$set['metadata.lastResolvedAt'] = now;
|
||||
} else if (existingSourceBindings.length === 0) {
|
||||
$unset['metadata.sourceBindings'] = '';
|
||||
}
|
||||
|
||||
if (existingSourceBindings.length === 0 && legacyPolicyBindings.length === 0 && legacySourceProfileRef) {
|
||||
$unset['route.security'] = '';
|
||||
}
|
||||
|
||||
const query = (routeDoc as any)._id
|
||||
? { _id: (routeDoc as any)._id }
|
||||
: { id: (routeDoc as any).id };
|
||||
await routeCollection.updateOne(query, { $set, $unset });
|
||||
migrated++;
|
||||
}
|
||||
|
||||
ctx.log.log(
|
||||
'info',
|
||||
`convert-route-access-metadata-to-source-bindings: migrated ${migrated}/${inspected} route(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a configured SmartMigration runner with all dcrouter migration steps registered.
|
||||
*
|
||||
@@ -379,6 +488,12 @@ export async function createMigrationRunner(
|
||||
.description('Seed missing default source profiles for source-policy presets')
|
||||
.up(async (ctx) => {
|
||||
await seedMissingDefaultSourceProfiles(ctx);
|
||||
})
|
||||
.step('convert-route-access-metadata-to-source-bindings')
|
||||
.from('13.42.0').to('13.43.2')
|
||||
.description('Convert route sourceProfileRef/sourcePolicy metadata to canonical sourceBindings')
|
||||
.up(async (ctx) => {
|
||||
await convertRouteAccessMetadataToSourceBindings(ctx);
|
||||
});
|
||||
|
||||
return migration;
|
||||
|
||||
@@ -43,6 +43,7 @@ The current migration chain covers:
|
||||
- `systemKey` backfill for persisted config, email, and DNS routes
|
||||
- source-profile route-security rematerialization for routes with legacy `metadata.sourceProfileRef`
|
||||
- `seed-missing-default-source-profiles` from `13.40.2` to `13.42.0`, which inserts missing `TRUSTED NETWORKS`, `AI CRAWLERS`, and `PUBLIC` source profiles by name without mutating existing profiles
|
||||
- `convert-route-access-metadata-to-source-bindings` from `13.42.0` to `13.43.2`, which converts legacy `metadata.sourceProfileRef`, `metadata.sourceProfileName`, and `metadata.sourcePolicy.bindings` to canonical `metadata.sourceBindings[]` and removes legacy access metadata fields
|
||||
|
||||
## Migration Rules
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@serve.zone/dcrouter',
|
||||
version: '13.43.1',
|
||||
version: '13.43.2',
|
||||
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
|
||||
}
|
||||
|
||||
@@ -24,10 +24,7 @@ const tlsCertOptions = [
|
||||
{ key: 'auto', option: 'Auto (ACME/Let\'s Encrypt)' },
|
||||
{ key: 'custom', option: 'Custom certificate' },
|
||||
];
|
||||
const sourcePolicyPresetOptions = [
|
||||
{ key: 'manual', option: 'Manual source policy' },
|
||||
{ key: 'gitea', option: 'Gitea bot protection' },
|
||||
];
|
||||
const maxSourceBindingRows = 16;
|
||||
const giteaSourcePolicyProfileNames = ['TRUSTED NETWORKS', 'AI CRAWLERS', 'PUBLIC'] as const;
|
||||
|
||||
function rateLimit(maxRequests: number): interfaces.data.IRouteSecurity['rateLimit'] {
|
||||
@@ -38,10 +35,10 @@ function getDropdownKey(value: any): string {
|
||||
return typeof value === 'string' ? value : value?.key || '';
|
||||
}
|
||||
|
||||
function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[] {
|
||||
function getSourceBindingRefsFromFormData(formData: Record<string, any>): string[] {
|
||||
const refs: string[] = [];
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const ref = getDropdownKey(formData[`sourcePolicyProfileRef${index}`]);
|
||||
for (let index = 0; index < maxSourceBindingRows; index++) {
|
||||
const ref = getDropdownKey(formData[`sourceBindingProfileRef${index}`]);
|
||||
if (ref && !refs.includes(ref)) {
|
||||
refs.push(ref);
|
||||
}
|
||||
@@ -49,25 +46,23 @@ function getSourcePolicyRefsFromFormData(formData: Record<string, any>): string[
|
||||
return refs;
|
||||
}
|
||||
|
||||
function buildSourcePolicyMetadata(
|
||||
function buildSourceBindingsMetadata(
|
||||
profileRefs: string[],
|
||||
existingSourcePolicy?: interfaces.data.IRouteSourcePolicy,
|
||||
): interfaces.data.IRouteSourcePolicy {
|
||||
return {
|
||||
bindings: profileRefs.map((sourceProfileRef) => {
|
||||
const existingBinding = existingSourcePolicy?.bindings.find((binding) => binding.sourceProfileRef === sourceProfileRef);
|
||||
return existingBinding
|
||||
? {
|
||||
...existingBinding,
|
||||
sourceProfileRef,
|
||||
onExceeded: existingBinding.onExceeded || { type: '429' as const },
|
||||
}
|
||||
: {
|
||||
sourceProfileRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
};
|
||||
}),
|
||||
};
|
||||
existingSourceBindings?: interfaces.data.IRouteSourceBinding[],
|
||||
): interfaces.data.IRouteSourceBinding[] {
|
||||
return profileRefs.map((sourceProfileRef) => {
|
||||
const existingBinding = existingSourceBindings?.find((binding) => binding.sourceProfileRef === sourceProfileRef);
|
||||
return existingBinding
|
||||
? {
|
||||
...existingBinding,
|
||||
sourceProfileRef,
|
||||
onExceeded: existingBinding.onExceeded || { type: '429' as const },
|
||||
}
|
||||
: {
|
||||
sourceProfileRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]): {
|
||||
@@ -87,56 +82,54 @@ function getGiteaPresetProfileRefs(profiles: interfaces.data.ISourceProfile[]):
|
||||
return { refs, missingNames };
|
||||
}
|
||||
|
||||
function buildGiteaSourcePolicyMetadata(profileRefs: string[]): interfaces.data.IRouteSourcePolicy {
|
||||
function buildGiteaSourceBindingsMetadata(profileRefs: string[]): interfaces.data.IRouteSourceBinding[] {
|
||||
const [trustedRef, aiRef, publicRef] = profileRefs;
|
||||
return {
|
||||
bindings: [
|
||||
{
|
||||
sourceProfileRef: trustedRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: aiRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(240) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(20) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(20) },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(600) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(120) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(120) },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return [
|
||||
{
|
||||
sourceProfileRef: trustedRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
},
|
||||
{
|
||||
sourceProfileRef: aiRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(240) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(20) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(6) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(20) },
|
||||
],
|
||||
},
|
||||
{
|
||||
sourceProfileRef: publicRef,
|
||||
onExceeded: { type: '429' as const },
|
||||
pathPolicies: [
|
||||
{ pathClass: 'git-smart-http', rateLimit: rateLimit(1200) },
|
||||
{ pathClass: 'static', rateLimit: rateLimit(600) },
|
||||
{ pathClass: 'raw', rateLimit: rateLimit(120) },
|
||||
{ pathClass: 'archive', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'expensive-html', rateLimit: rateLimit(30) },
|
||||
{ pathClass: 'normal-html', rateLimit: rateLimit(120) },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getGiteaPresetSourcePolicy(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourcePolicy | null {
|
||||
function getGiteaPresetSourceBindings(profiles: interfaces.data.ISourceProfile[]): interfaces.data.IRouteSourceBinding[] | null {
|
||||
const { refs, missingNames } = getGiteaPresetProfileRefs(profiles);
|
||||
if (missingNames.length > 0) {
|
||||
alert(`Gitea source-policy preset needs these seeded profiles: ${missingNames.join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
if (!validateSourcePolicySelection(refs, profiles)) {
|
||||
if (!validateSourceBindingSelection(refs, profiles)) {
|
||||
return null;
|
||||
}
|
||||
return buildGiteaSourcePolicyMetadata(refs);
|
||||
return buildGiteaSourceBindingsMetadata(refs);
|
||||
}
|
||||
|
||||
function metadataUsesPathPolicies(metadata?: interfaces.data.IRouteMetadata): boolean {
|
||||
return Boolean(metadata?.sourcePolicy?.bindings.some((binding) => binding.pathPolicies?.length));
|
||||
return Boolean(metadata?.sourceBindings?.some((binding) => binding.pathPolicies?.length));
|
||||
}
|
||||
|
||||
function sourceProfileMatchesAll(profile: interfaces.data.ISourceProfile): boolean {
|
||||
@@ -153,7 +146,7 @@ function sourceProfileHasSourceMatches(profile: interfaces.data.ISourceProfile):
|
||||
});
|
||||
}
|
||||
|
||||
function validateSourcePolicySelection(
|
||||
function validateSourceBindingSelection(
|
||||
profileRefs: string[],
|
||||
profiles: interfaces.data.ISourceProfile[],
|
||||
): boolean {
|
||||
@@ -176,19 +169,14 @@ function validateSourcePolicySelection(
|
||||
return false;
|
||||
}
|
||||
|
||||
const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
|
||||
if (!sourceProfileMatchesAll(fallbackProfile)) {
|
||||
alert('Source policy needs an explicit public/wildcard fallback profile as the last binding. Add a profile with IP Allow List "*".');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (selectedProfiles.slice(0, -1).some((profile) => sourceProfileMatchesAll(profile))) {
|
||||
alert('Wildcard source profiles must be last. Earlier wildcard profiles would shadow all following profiles.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fallbackProfile.security?.rateLimit?.enabled !== true) {
|
||||
return confirm(`The fallback profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
|
||||
const fallbackProfile = selectedProfiles[selectedProfiles.length - 1];
|
||||
if (sourceProfileMatchesAll(fallbackProfile) && fallbackProfile.security?.rateLimit?.enabled !== true) {
|
||||
return confirm(`The wildcard profile "${fallbackProfile.name}" has no enabled rate limit. Save anyway?`);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -521,7 +509,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
const meta = merged.metadata;
|
||||
const isSystemManaged = this.isSystemManagedRoute(merged);
|
||||
const sourcePolicySummary = this.describeSourcePolicy(meta);
|
||||
const sourceBindingSummary = this.describeSourcePolicy(meta);
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Route: ${merged.route.name}`,
|
||||
content: html`
|
||||
@@ -531,7 +519,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
|
||||
<p>ID: <code style="color: #888;">${merged.id}</code></p>
|
||||
${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
|
||||
${sourcePolicySummary ? html`<p>Source Policy: <strong style="color: #a78bfa;">${sourcePolicySummary}</strong></p>` : ''}
|
||||
${sourceBindingSummary ? html`<p>Source Bindings: <strong style="color: #a78bfa;">${sourceBindingSummary}</strong></p>` : ''}
|
||||
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
|
||||
</div>
|
||||
`,
|
||||
@@ -663,8 +651,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
const currentVpnOnly = route.vpnOnly === true;
|
||||
const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
|
||||
const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
|
||||
const currentSourcePolicyRefs = this.getSourcePolicyRefs(merged.metadata);
|
||||
const currentSourcePolicyPreset = metadataUsesPathPolicies(merged.metadata) ? 'gitea' : 'manual';
|
||||
const currentSourceBindingRefs = this.getSourceBindingRefs(merged.metadata);
|
||||
|
||||
// Compute current TLS state for pre-population
|
||||
const currentTls = (route.action as any).tls;
|
||||
@@ -686,21 +673,20 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
|
||||
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
|
||||
<strong>Source Policy</strong>
|
||||
<small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
|
||||
<dees-input-dropdown
|
||||
.key=${'sourcePolicyPreset'}
|
||||
.label=${'Source Policy Preset'}
|
||||
.options=${sourcePolicyPresetOptions}
|
||||
.selectedOption=${sourcePolicyPresetOptions.find((o) => o.key === currentSourcePolicyPreset) || sourcePolicyPresetOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
<small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
|
||||
${[0, 1, 2, 3].map((index) => html`
|
||||
<strong>Source Bindings</strong>
|
||||
<small>First matching source profile wins. Leave all rows empty to remove route-level source access control.</small>
|
||||
<dees-input-checkbox
|
||||
.key=${'useGiteaTemplate'}
|
||||
.label=${'Apply Gitea bot protection template on save'}
|
||||
.description=${'Replaces these rows with TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
|
||||
.value=${false}
|
||||
></dees-input-checkbox>
|
||||
${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
|
||||
<dees-input-dropdown
|
||||
.key=${`sourcePolicyProfileRef${index}`}
|
||||
.label=${`Source Profile ${index + 1}`}
|
||||
.key=${`sourceBindingProfileRef${index}`}
|
||||
.label=${`Binding ${index + 1}`}
|
||||
.options=${profileOptions}
|
||||
.selectedOption=${profileOptions.find((o) => o.key === (currentSourcePolicyRefs[index] || '')) || profileOptions[0]}
|
||||
.selectedOption=${profileOptions.find((o) => o.key === (currentSourceBindingRefs[index] || '')) || profileOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
`)}
|
||||
</div>
|
||||
@@ -744,11 +730,11 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: [];
|
||||
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||
|
||||
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
|
||||
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
|
||||
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
|
||||
const sourceBindingRefs = useGiteaTemplate
|
||||
? []
|
||||
: getSourcePolicyRefsFromFormData(formData);
|
||||
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
|
||||
: getSourceBindingRefsFromFormData(formData);
|
||||
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
|
||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||
const targetPort = preserveMatchPort
|
||||
@@ -812,20 +798,14 @@ export class OpsViewRoutes extends DeesElement {
|
||||
}
|
||||
|
||||
const metadata: any = {};
|
||||
if (sourcePolicyPreset === 'gitea') {
|
||||
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
|
||||
if (!sourcePolicy) return;
|
||||
metadata.sourcePolicy = sourcePolicy;
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
} else if (sourcePolicyRefs.length > 0) {
|
||||
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs, merged.metadata?.sourcePolicy);
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
} else if (merged.metadata?.sourcePolicy || merged.metadata?.sourceProfileRef) {
|
||||
metadata.sourcePolicy = { bindings: [] };
|
||||
metadata.sourceProfileRef = '';
|
||||
metadata.sourceProfileName = '';
|
||||
if (useGiteaTemplate) {
|
||||
const sourceBindings = getGiteaPresetSourceBindings(profiles);
|
||||
if (!sourceBindings) return;
|
||||
metadata.sourceBindings = sourceBindings;
|
||||
} else if (sourceBindingRefs.length > 0) {
|
||||
metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs, merged.metadata?.sourceBindings);
|
||||
} else if (merged.metadata?.sourceBindings) {
|
||||
metadata.sourceBindings = [];
|
||||
}
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
@@ -886,19 +866,18 @@ export class OpsViewRoutes extends DeesElement {
|
||||
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
|
||||
<dees-input-text .key=${'priority'} .label=${'Priority'} .description=${'Higher values are matched first'}></dees-input-text>
|
||||
<div class="sourcePolicyGroup" style="display: flex; flex-direction: column; gap: 12px; padding: 12px; border: 1px solid rgba(255,255,255,0.12); border-radius: 8px;">
|
||||
<strong>Source Policy</strong>
|
||||
<small>First matching profile wins. Exceeded limits return 429 and do not fall through.</small>
|
||||
<dees-input-dropdown
|
||||
.key=${'sourcePolicyPreset'}
|
||||
.label=${'Source Policy Preset'}
|
||||
.options=${sourcePolicyPresetOptions}
|
||||
.selectedOption=${sourcePolicyPresetOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
<small>Gitea preset uses TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and applies path-class limits.</small>
|
||||
${[0, 1, 2, 3].map((index) => html`
|
||||
<strong>Source Bindings</strong>
|
||||
<small>First matching source profile wins. Leave all rows empty for no route-level source access control.</small>
|
||||
<dees-input-checkbox
|
||||
.key=${'useGiteaTemplate'}
|
||||
.label=${'Apply Gitea bot protection template on save'}
|
||||
.description=${'Writes TRUSTED NETWORKS -> AI CRAWLERS -> PUBLIC and path-class limits.'}
|
||||
.value=${false}
|
||||
></dees-input-checkbox>
|
||||
${Array.from({ length: maxSourceBindingRows }, (_item, index) => html`
|
||||
<dees-input-dropdown
|
||||
.key=${`sourcePolicyProfileRef${index}`}
|
||||
.label=${`Source Profile ${index + 1}`}
|
||||
.key=${`sourceBindingProfileRef${index}`}
|
||||
.label=${`Binding ${index + 1}`}
|
||||
.options=${profileOptions}
|
||||
.selectedOption=${profileOptions[0]}
|
||||
></dees-input-dropdown>
|
||||
@@ -944,11 +923,11 @@ export class OpsViewRoutes extends DeesElement {
|
||||
: [];
|
||||
const priority = formData.priority ? parseInt(formData.priority, 10) : undefined;
|
||||
|
||||
const sourcePolicyPreset = getDropdownKey(formData.sourcePolicyPreset) || 'manual';
|
||||
const sourcePolicyRefs = sourcePolicyPreset === 'gitea'
|
||||
const useGiteaTemplate = Boolean(formData.useGiteaTemplate);
|
||||
const sourceBindingRefs = useGiteaTemplate
|
||||
? []
|
||||
: getSourcePolicyRefsFromFormData(formData);
|
||||
if (sourcePolicyPreset !== 'gitea' && !validateSourcePolicySelection(sourcePolicyRefs, profiles)) return;
|
||||
: getSourceBindingRefsFromFormData(formData);
|
||||
if (!useGiteaTemplate && !validateSourceBindingSelection(sourceBindingRefs, profiles)) return;
|
||||
const targetKey = getDropdownKey(formData.networkTargetRef);
|
||||
const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
|
||||
const targetPort = preserveMatchPort
|
||||
@@ -1013,12 +992,12 @@ export class OpsViewRoutes extends DeesElement {
|
||||
|
||||
// Build metadata if profile/target selected
|
||||
const metadata: any = {};
|
||||
if (sourcePolicyPreset === 'gitea') {
|
||||
const sourcePolicy = getGiteaPresetSourcePolicy(profiles);
|
||||
if (!sourcePolicy) return;
|
||||
metadata.sourcePolicy = sourcePolicy;
|
||||
} else if (sourcePolicyRefs.length > 0) {
|
||||
metadata.sourcePolicy = buildSourcePolicyMetadata(sourcePolicyRefs);
|
||||
if (useGiteaTemplate) {
|
||||
const sourceBindings = getGiteaPresetSourceBindings(profiles);
|
||||
if (!sourceBindings) return;
|
||||
metadata.sourceBindings = sourceBindings;
|
||||
} else if (sourceBindingRefs.length > 0) {
|
||||
metadata.sourceBindings = buildSourceBindingsMetadata(sourceBindingRefs);
|
||||
}
|
||||
if (targetKey) {
|
||||
metadata.networkTargetRef = targetKey;
|
||||
@@ -1049,23 +1028,20 @@ export class OpsViewRoutes extends DeesElement {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchMergedRoutesAction, null);
|
||||
}
|
||||
|
||||
private getSourcePolicyRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
|
||||
const policyRefs = metadata?.sourcePolicy?.bindings
|
||||
private getSourceBindingRefs(metadata?: interfaces.data.IRouteMetadata): string[] {
|
||||
const bindingRefs = metadata?.sourceBindings
|
||||
?.map((binding) => binding.sourceProfileRef)
|
||||
.filter(Boolean) || [];
|
||||
if (policyRefs.length > 0) {
|
||||
return policyRefs;
|
||||
}
|
||||
return metadata?.sourceProfileRef ? [metadata.sourceProfileRef] : [];
|
||||
return bindingRefs;
|
||||
}
|
||||
|
||||
private describeSourcePolicy(metadata?: interfaces.data.IRouteMetadata): string {
|
||||
const refs = this.getSourcePolicyRefs(metadata);
|
||||
const refs = this.getSourceBindingRefs(metadata);
|
||||
if (refs.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return refs.map((ref) => {
|
||||
const binding = metadata?.sourcePolicy?.bindings?.find((item) => item.sourceProfileRef === ref);
|
||||
const binding = metadata?.sourceBindings?.find((item) => item.sourceProfileRef === ref);
|
||||
const profile = this.profilesTargetsState.profiles.find((item) => item.id === ref);
|
||||
return binding?.sourceProfileName || profile?.name || ref.slice(0, 8);
|
||||
}).join(' → ');
|
||||
|
||||
Reference in New Issue
Block a user